mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Merge origin/main into mobile-floating-workspace-8405
Resolved two conflicts from main's repo-wide comment-condensing pass: - host index.tsx: kept the status.get logic in useHostStatusGates (this branch extracted it) rather than main's reformatted inline effect. - session screen: kept isFloatingWorkspaceRoute alongside main's new inline comment on isFolderWorkspaceRoute. Condensed the new floating-workspace / host-status-gates comments to match the tightened house style.
This commit is contained in:
+7
-6
@@ -77,20 +77,21 @@ design-docs/
|
||||
# Machine-local agent hook endpoint files may contain auth tokens.
|
||||
/agent-hooks/
|
||||
|
||||
# Local-only design/planning docs (not checked in).
|
||||
# Durable docs must live in one of the allow-listed locations below.
|
||||
# Local-only design/planning docs (not checked in), including most of docs/reference/.
|
||||
# Durable docs that should be tracked must live in one of the allow-listed
|
||||
# locations below (assets, readme, STYLEGUIDE, mobile terminal shortcut bar,
|
||||
# and the tracked reference docs linked from AGENTS.md / README.md).
|
||||
docs/**
|
||||
!docs/
|
||||
!docs/assets/
|
||||
!docs/assets/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/reference/
|
||||
!docs/reference/**
|
||||
# Keep generated React perf scan ledgers local; durable docs still use docs/reference.
|
||||
docs/reference/react-performance-audit.md
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/git-compatibility.md
|
||||
!docs/reference/headless-linux-server.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
|
||||
## Code Comments: Document the "Why", Briefly
|
||||
|
||||
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
|
||||
|
||||
Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
|
||||
|
||||
The comment's audience is the next person reading that line, not a PR description. If a comment lists call sites, recounts the crash/history, or re-describes the code below it, cut it to the one non-obvious fact:
|
||||
When code is non-obvious, Add code comments explaining **why** (not HOW). BE CONCISE — ideally 1 line.
|
||||
|
||||
```ts
|
||||
// 🚫 Over-explained — narrates the crash, the mechanism, where the helper lives, and every caller:
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub stars" /></a>
|
||||
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&label=%E2%98%85&color=08C" alt="GitHub stars" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="License: MIT" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
|
||||
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -22,6 +22,10 @@
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>Download Orca</ins></a></h3>
|
||||
|
||||
<p align="center">
|
||||
<sub>Windows? Grab the <a href="https://github.com/stablyai/orca/releases#release-v1.4.147-rc.4">latest RC</a> — has Windows fixes.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/readme-hero.jpg" alt="Orca desktop app running agents in parallel worktrees, with the Orca mobile companion app in the corner" width="960" />
|
||||
</p>
|
||||
@@ -212,7 +216,8 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
### Desktop — macOS, Windows, Linux
|
||||
|
||||
- **[Download from onOrca.dev](https://onorca.dev/download)**
|
||||
- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
|
||||
- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/download/v1.4.147-rc.4/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
|
||||
- **Windows users:** use the [latest RC (`v1.4.147-rc.4`)](https://github.com/stablyai/orca/releases#release-v1.4.147-rc.4) — it includes Windows-specific bug fixes not yet in stable.
|
||||
- Running `orca serve` on a headless Linux server? See the [headless Linux server guide](docs/reference/headless-linux-server.md).
|
||||
|
||||
_Or via a package manager:_
|
||||
|
||||
@@ -35,13 +35,7 @@ const relayExtraResource = {
|
||||
// from package directories where pnpm's symlink farm is absent. Copy the exact
|
||||
// runtime dependency closure to Resources/node_modules so bare require() calls
|
||||
// do not fall through to a developer checkout's node_modules.
|
||||
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
|
||||
|
||||
const commonExtraResources = [
|
||||
relayExtraResource,
|
||||
...packagedRuntimeNodeModuleResources,
|
||||
skillFreshnessResources
|
||||
]
|
||||
const commonExtraResources = [relayExtraResource, skillFreshnessResources]
|
||||
const macSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-darwin-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-darwin-${arch}'
|
||||
@@ -198,6 +192,7 @@ module.exports = {
|
||||
},
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
...createPackagedRuntimeNodeModuleResources('win32'),
|
||||
winSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/win32/bin/orca.cmd',
|
||||
@@ -261,6 +256,7 @@ module.exports = {
|
||||
notarize: isMacRelease,
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
...createPackagedRuntimeNodeModuleResources('darwin'),
|
||||
macSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/darwin/bin/orca',
|
||||
@@ -324,6 +320,7 @@ module.exports = {
|
||||
},
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
...createPackagedRuntimeNodeModuleResources('linux'),
|
||||
linuxSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/linux/bin/orca-ide',
|
||||
|
||||
@@ -31,6 +31,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'yaml',
|
||||
'zod'
|
||||
]
|
||||
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry']
|
||||
|
||||
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
|
||||
darwin: 'darwin-',
|
||||
@@ -128,7 +129,7 @@ function isKnownOmittedServeSimDependency(packageName, fromDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function collectPackagedRuntimePackages() {
|
||||
function collectPackagedRuntimePackages(electronPlatformName = process.platform) {
|
||||
const packages = new Map()
|
||||
const visit = (packageName, fromDir = projectDir) => {
|
||||
if (packageName === 'electron' || packages.has(packageName)) {
|
||||
@@ -156,7 +157,12 @@ function collectPackagedRuntimePackages() {
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) {
|
||||
// Why: cross-builds must select native dependencies from the artifact target, not the build host.
|
||||
const packageRoots = [
|
||||
...PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
||||
...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : [])
|
||||
]
|
||||
for (const packageName of packageRoots) {
|
||||
visit(packageName)
|
||||
}
|
||||
|
||||
@@ -183,8 +189,8 @@ function collectPackagedRuntimePackages() {
|
||||
return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function createPackagedRuntimeNodeModuleResources() {
|
||||
return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({
|
||||
function createPackagedRuntimeNodeModuleResources(electronPlatformName = process.platform) {
|
||||
return collectPackagedRuntimePackages(electronPlatformName).map(([packageName, packageDir]) => ({
|
||||
from: packageDir,
|
||||
to: join('node_modules', ...packageName.split('/'))
|
||||
}))
|
||||
|
||||
@@ -36,10 +36,11 @@ function log(message) {
|
||||
// Why: the daemon rejects a hello whose protocol version differs, so read the
|
||||
// current version from source rather than hardcoding a number that can drift.
|
||||
function readProtocolVersion() {
|
||||
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
|
||||
const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts'
|
||||
const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8')
|
||||
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
|
||||
throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`)
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ const scriptPath = import.meta.filename
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const runtime = readRuntimeArg()
|
||||
|
||||
const NATIVE_MODULES = ['node-pty']
|
||||
const NATIVE_MODULES = [
|
||||
'node-pty',
|
||||
...(process.platform === 'win32' ? ['windows-native-registry'] : [])
|
||||
]
|
||||
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
|
||||
const CHILD_CHECK_FLAG = '--check-only'
|
||||
|
||||
if (process.argv.includes(CHILD_CHECK_FLAG)) {
|
||||
@@ -240,6 +244,12 @@ function collectNativeModuleFailures() {
|
||||
}
|
||||
|
||||
function loadNativeModule(moduleName) {
|
||||
if (moduleName === 'windows-native-registry') {
|
||||
const registry = require(moduleName)
|
||||
// Why: the package defers loading its .node addon until the first registry call.
|
||||
registry.getRegistryKey(registry.HK.CU, 'Environment')
|
||||
return
|
||||
}
|
||||
if (moduleName === 'node-pty') {
|
||||
loadNodePtyNativeModule()
|
||||
return
|
||||
@@ -256,13 +266,27 @@ function loadNodePtyNativeModule() {
|
||||
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
|
||||
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
||||
const native = loadNativeModule(nativeName)
|
||||
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) {
|
||||
assertNodePtyWindowsConptyRuntime(native?.dir)
|
||||
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
|
||||
throw new Error(
|
||||
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNodePtyWindowsConptyRuntime(nativeDir) {
|
||||
if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) {
|
||||
return
|
||||
}
|
||||
const runtimeDir = resolve(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty')
|
||||
const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find(
|
||||
(filename) => !existsSync(resolve(runtimeDir, filename))
|
||||
)
|
||||
if (missingFile) {
|
||||
throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getNodePtyNativeModuleName() {
|
||||
if (process.platform !== 'win32') {
|
||||
return 'pty'
|
||||
|
||||
@@ -193,6 +193,7 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
@@ -214,6 +215,19 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
}
|
||||
|
||||
function writeFakeWindowsRegistry(projectDir) {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
|
||||
mkdirSync(registryDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(registryDir, 'index.js'),
|
||||
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
|
||||
)
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
|
||||
@@ -144,23 +144,22 @@ describe('orchestration skill guidance', () => {
|
||||
'Sidebar lineage and orchestration lifecycle are related but not identical.'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent'
|
||||
'A same-worktree worker may appear as a peer under that worktree in the sidebar'
|
||||
)
|
||||
expect(workerTerminals).toContain('while remaining a child dispatch in orchestration state')
|
||||
expect(workerTerminals).toContain(
|
||||
'only an actual child worktree creates visible parent/child worktree lineage'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'even though it is a child dispatch in Orca orchestration state'
|
||||
'Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'A visible parent/child worktree relationship requires creating a child worktree'
|
||||
'Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'For supervised new-worktree workers, decide the desired Orca lineage before creation'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks'
|
||||
'When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree'
|
||||
)
|
||||
expect(workerTerminals).toContain('use `--no-parent` when it is not stacked')
|
||||
})
|
||||
|
||||
it('keeps review-only completions and named next-owner fixes in their lanes', () => {
|
||||
@@ -213,7 +212,7 @@ describe('orchestration skill guidance', () => {
|
||||
/```bash\norca worktree create --name <task-name> --agent codex --json\n[\s\S]*?```/
|
||||
)?.[0]
|
||||
|
||||
expect(workerTerminals).toContain('Agent-first (required for ordinary agent workers)')
|
||||
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
|
||||
expect(workerTerminals).toContain('fallback shell + agent pair')
|
||||
expect(workerTerminals).toContain(
|
||||
'Repo setup or default-terminal settings may still add tabs or splits'
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtime-node-modules.cjs')
|
||||
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
|
||||
|
||||
describe('Electron runtime package contract', () => {
|
||||
@@ -25,6 +28,45 @@ describe('Electron runtime package contract', () => {
|
||||
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
|
||||
})
|
||||
|
||||
it('keeps the native Windows registry addon optional and platform-gated', () => {
|
||||
const rebuildScript = readFileSync(
|
||||
join(projectDir, 'config/scripts/rebuild-native-deps.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
const ensureScript = readFileSync(
|
||||
join(projectDir, 'config/scripts/ensure-native-runtime.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
expect(packageJson.optionalDependencies['windows-native-registry']).toBe('3.2.2')
|
||||
// Why: pnpm installs optional target architectures on every host; the root
|
||||
// Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it.
|
||||
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('windows-native-registry')
|
||||
expect(rebuildScript).toContain(
|
||||
"rebuildPlatform === 'win32' ? ['windows-native-registry'] : []"
|
||||
)
|
||||
expect(ensureScript).toContain(
|
||||
"process.platform === 'win32' ? ['windows-native-registry'] : []"
|
||||
)
|
||||
const packageTargets = {
|
||||
win32: createPackagedRuntimeNodeModuleResources('win32'),
|
||||
darwin: createPackagedRuntimeNodeModuleResources('darwin'),
|
||||
linux: createPackagedRuntimeNodeModuleResources('linux')
|
||||
}
|
||||
expect(packageTargets.win32).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }),
|
||||
expect.objectContaining({ to: join('node_modules', 'node-addon-api') })
|
||||
])
|
||||
)
|
||||
for (const platform of ['darwin', 'linux']) {
|
||||
expect(packageTargets[platform]).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ to: join('node_modules', 'windows-native-registry') })
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('guards package scripts that launch Electron tooling', () => {
|
||||
const scripts = packageJson.scripts
|
||||
const guardedScripts = [
|
||||
|
||||
@@ -20,9 +20,18 @@
|
||||
|
||||
import { rebuild } from '@electron/rebuild'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
globSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { platform as osPlatform } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const projectDir = process.cwd()
|
||||
let cliOptions
|
||||
@@ -40,6 +49,7 @@ const electronVersion = JSON.parse(
|
||||
).version
|
||||
|
||||
const ignoreModules = ['cpu-features']
|
||||
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
|
||||
|
||||
if (ignoreModules.length > 0) {
|
||||
console.log(`[rebuild] Skipping optional Electron rebuild modules: ${ignoreModules.join(', ')}`)
|
||||
@@ -49,7 +59,11 @@ if (ignoreModules.length > 0) {
|
||||
// modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to
|
||||
// rebuild via `onlyModules` ensures they're recompiled against Electron's Node
|
||||
// ABI regardless of the package manager's store layout.
|
||||
const NATIVE_MODULES = ['node-pty', 'cpu-features']
|
||||
const NATIVE_MODULES = [
|
||||
'node-pty',
|
||||
'cpu-features',
|
||||
...(rebuildPlatform === 'win32' ? ['windows-native-registry'] : [])
|
||||
]
|
||||
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
|
||||
const forceRebuild =
|
||||
process.env.ORCA_FORCE_NATIVE_REBUILD === '1' ||
|
||||
@@ -58,6 +72,7 @@ const forceRebuild =
|
||||
rebuildArch !== process.arch
|
||||
|
||||
ensureElectronPackageInstalled()
|
||||
restoreNodePtyWindowsConptyRuntime()
|
||||
|
||||
const patchedNodePtyRebuildReason = forceRebuild ? null : getPatchedNodePtyRebuildReason()
|
||||
|
||||
@@ -124,6 +139,7 @@ try {
|
||||
// Node before postinstall runs this script.
|
||||
force: true
|
||||
})
|
||||
restoreNodePtyWindowsConptyRuntime()
|
||||
} catch (/** @type {any} */ err) {
|
||||
console.error('[rebuild] Native module rebuild failed:', err?.message ?? err)
|
||||
if (isWindowsNativeLockError(err)) {
|
||||
@@ -143,6 +159,37 @@ try {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function restoreNodePtyWindowsConptyRuntime() {
|
||||
if (rebuildPlatform !== 'win32' || !onlyModules.includes('node-pty')) {
|
||||
return
|
||||
}
|
||||
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
if (!existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))) {
|
||||
return
|
||||
}
|
||||
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty')
|
||||
const sourceDir = readdirSync(conptyRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => join(conptyRoot, entry.name, `win10-${rebuildArch}`))
|
||||
.find((candidate) => existsSync(candidate))
|
||||
if (!sourceDir) {
|
||||
throw new Error(`node-pty has no ConPTY runtime payload for win10-${rebuildArch}`)
|
||||
}
|
||||
|
||||
const runtimeDir = join(nodePtyDir, 'build', 'Release', 'conpty')
|
||||
mkdirSync(runtimeDir, { recursive: true })
|
||||
for (const filename of NODE_PTY_CONPTY_RUNTIME_FILES) {
|
||||
const sourceFile = join(sourceDir, filename)
|
||||
if (!existsSync(sourceFile)) {
|
||||
throw new Error(`node-pty is missing ${sourceFile}`)
|
||||
}
|
||||
copyFileSync(sourceFile, join(runtimeDir, filename))
|
||||
}
|
||||
// Why: @electron/rebuild bypasses node-pty's postinstall step that normally copies these DLLs.
|
||||
console.log(`[rebuild] Restored node-pty ConPTY runtime files for win10-${rebuildArch}.`)
|
||||
}
|
||||
|
||||
function ensureElectronPackageInstalled() {
|
||||
if (electronPackageIsUsable()) {
|
||||
return
|
||||
@@ -390,6 +437,7 @@ function probeElectronNativeModules(moduleNames) {
|
||||
|
||||
const probeSource = `
|
||||
const { createRequire } = require('node:module')
|
||||
const { existsSync } = require('node:fs')
|
||||
const { release } = require('node:os')
|
||||
const { resolve } = require('node:path')
|
||||
const projectRequire = createRequire(resolve(process.cwd(), 'package.json'))
|
||||
@@ -411,10 +459,17 @@ if (failures.length > 0) {
|
||||
}
|
||||
|
||||
function loadNativeModule(moduleName) {
|
||||
if (moduleName === 'windows-native-registry') {
|
||||
const registry = projectRequire(moduleName)
|
||||
// Why: the package defers loading its .node addon until the first registry call.
|
||||
registry.getRegistryKey(registry.HK.CU, 'Environment')
|
||||
return
|
||||
}
|
||||
if (moduleName === 'node-pty') {
|
||||
projectRequire('node-pty')
|
||||
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
|
||||
const native = loadNativeModule(getNodePtyNativeModuleName())
|
||||
assertNodePtyWindowsConptyRuntime(native.dir)
|
||||
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
|
||||
throw new Error(
|
||||
'node-pty resolved to ' +
|
||||
@@ -427,6 +482,26 @@ function loadNativeModule(moduleName) {
|
||||
projectRequire(moduleName)
|
||||
}
|
||||
|
||||
function assertNodePtyWindowsConptyRuntime(nativeDir) {
|
||||
if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) {
|
||||
return
|
||||
}
|
||||
const runtimeDir = resolve(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'build',
|
||||
'Release',
|
||||
'conpty'
|
||||
)
|
||||
for (const filename of ${JSON.stringify(NODE_PTY_CONPTY_RUNTIME_FILES)}) {
|
||||
const runtimeFile = resolve(runtimeDir, filename)
|
||||
if (!existsSync(runtimeFile)) {
|
||||
throw new Error('node-pty ConPTY runtime file is missing: ' + runtimeFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNodePtyReleaseBuildDir(nativeDir) {
|
||||
return typeof nativeDir === 'string' && nativeDir.replace(/\\\\/g, '/').includes('build/Release/')
|
||||
}
|
||||
|
||||
@@ -124,6 +124,61 @@ describe('rebuild-native-deps Electron install fallback', () => {
|
||||
})
|
||||
|
||||
describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'repairs a missing ConPTY runtime before probing without recompiling node-pty',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, process.arch)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
ORCA_REBUILD_TEST_LOG: rebuildLogPath,
|
||||
npm_config_platform: 'win32',
|
||||
npm_config_arch: process.arch
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('Restored node-pty ConPTY runtime files')
|
||||
expect(result.stdout).toContain(
|
||||
'Native modules already load in Electron; skipping rebuild.'
|
||||
)
|
||||
expect(existsSync(rebuildLogPath)).toBe(false)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, 'x64')
|
||||
|
||||
const result = runRebuildScript(
|
||||
projectDir,
|
||||
{ npm_config_platform: 'win32', npm_config_arch: 'x64' },
|
||||
['--platform=win32', '--arch=x64', '--force']
|
||||
)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('Restored node-pty ConPTY runtime files for win10-x64')
|
||||
const runtimeDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty')
|
||||
expect(readFileSync(join(runtimeDir, 'conpty.dll'), 'utf8')).toBe('conpty.dll x64')
|
||||
expect(readFileSync(join(runtimeDir, 'OpenConsole.exe'), 'utf8')).toBe('OpenConsole.exe x64')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds when Electron can load node-pty but patched build artifacts are missing',
|
||||
() => {
|
||||
@@ -225,7 +280,7 @@ function mkTempProject() {
|
||||
return projectDir
|
||||
}
|
||||
|
||||
function runRebuildScript(projectDir, extraEnv = {}) {
|
||||
function runRebuildScript(projectDir, extraEnv = {}, args = []) {
|
||||
const env = {
|
||||
...process.env,
|
||||
npm_config_platform: 'linux',
|
||||
@@ -239,7 +294,7 @@ function runRebuildScript(projectDir, extraEnv = {}) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs'], {
|
||||
return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs', ...args], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
@@ -360,16 +415,20 @@ export async function rebuild(options) {
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeUsableElectronPackage(projectDir) {
|
||||
function writeFakeUsableElectronPackage(projectDir, { platform = 'linux' } = {}) {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
const electronDir = join(projectDir, 'node_modules', 'electron')
|
||||
const electronPath = join(electronDir, 'dist', 'electron')
|
||||
const platformExecutable = platform === 'win32' ? 'electron.exe' : 'electron'
|
||||
const electronPath = join(electronDir, 'dist', platformExecutable)
|
||||
mkdirSync(join(electronDir, 'dist'), { recursive: true })
|
||||
writeFileSync(join(electronDir, 'path.txt'), 'electron')
|
||||
writeFileSync(join(electronDir, 'path.txt'), platformExecutable)
|
||||
writeFileSync(join(electronDir, 'dist', 'version'), 'v41.5.0')
|
||||
writeFileSync(
|
||||
electronPath,
|
||||
`#!/usr/bin/env node
|
||||
if (platform === 'win32') {
|
||||
copyFileSync(process.execPath, electronPath)
|
||||
} else {
|
||||
writeFileSync(
|
||||
electronPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const result = spawnSync(process.execPath, process.argv.slice(2), {
|
||||
@@ -385,8 +444,27 @@ if (result.error) {
|
||||
|
||||
process.exit(result.status ?? 0)
|
||||
`
|
||||
)
|
||||
chmodSync(electronPath, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakeNodePtyConptyPayload(projectDir, arch) {
|
||||
const releaseDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(releaseDir, { recursive: true })
|
||||
writeFileSync(join(releaseDir, 'conpty.node'), 'native addon')
|
||||
const sourceDir = join(
|
||||
projectDir,
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'third_party',
|
||||
'conpty',
|
||||
'0.1.0',
|
||||
`win10-${arch}`
|
||||
)
|
||||
chmodSync(electronPath, 0o755)
|
||||
mkdirSync(sourceDir, { recursive: true })
|
||||
writeFileSync(join(sourceDir, 'conpty.dll'), `conpty.dll ${arch}`)
|
||||
writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`)
|
||||
}
|
||||
|
||||
function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) {
|
||||
@@ -403,6 +481,15 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeWindowsRegistry(projectDir) {
|
||||
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
|
||||
mkdirSync(registryDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(registryDir, 'index.js'),
|
||||
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
|
||||
)
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
|
||||
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
|
||||
|
||||
@@ -22,7 +22,10 @@ function log(message) {
|
||||
}
|
||||
|
||||
function readProtocolVersion() {
|
||||
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
|
||||
const source = readFileSync(
|
||||
join(projectDir, 'src/main/daemon/daemon-protocol-version.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('Could not read the daemon protocol version')
|
||||
@@ -30,19 +33,99 @@ function readProtocolVersion() {
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
function createStreamSocket(socketPath, token, protocolVersion, clientId, onFailure) {
|
||||
const socket = connect(socketPath)
|
||||
let buffer = ''
|
||||
|
||||
return new Promise((resolveStream, rejectStream) => {
|
||||
let handshakeComplete = false
|
||||
const rejectHandshake = (error) => {
|
||||
if (handshakeComplete) {
|
||||
onFailure(error)
|
||||
return
|
||||
}
|
||||
handshakeComplete = true
|
||||
clearTimeout(timer)
|
||||
socket.removeListener('data', onData)
|
||||
socket.removeListener('error', onError)
|
||||
socket.removeListener('close', onClose)
|
||||
socket.destroy()
|
||||
rejectStream(error)
|
||||
}
|
||||
const onError = (error) => rejectHandshake(error)
|
||||
const onClose = () => rejectHandshake(new Error('Daemon stream socket closed'))
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
const message = JSON.parse(buffer.slice(0, newline))
|
||||
if (message.type !== 'hello') {
|
||||
return
|
||||
}
|
||||
if (!message.ok) {
|
||||
rejectHandshake(new Error(message.error ?? 'Daemon rejected stream hello'))
|
||||
return
|
||||
}
|
||||
handshakeComplete = true
|
||||
clearTimeout(timer)
|
||||
socket.removeListener('data', onData)
|
||||
// Why: drain terminal events even though this lifecycle repro only asserts through RPC.
|
||||
socket.on('data', () => {})
|
||||
resolveStream(socket)
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => rejectHandshake(new Error('Daemon stream hello timed out')),
|
||||
requestTimeoutMs
|
||||
)
|
||||
socket.on('error', onError)
|
||||
socket.on('close', onClose)
|
||||
socket.on('data', onData)
|
||||
socket.once('connect', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
type: 'hello',
|
||||
version: protocolVersion,
|
||||
token,
|
||||
clientId,
|
||||
role: 'stream'
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createRpcClient(socketPath, tokenPath) {
|
||||
const socket = connect(socketPath)
|
||||
const clientId = randomUUID()
|
||||
const protocolVersion = readProtocolVersion()
|
||||
const token = readFileSync(tokenPath, 'utf8').trim()
|
||||
const pending = new Map()
|
||||
let buffer = ''
|
||||
let requestId = 0
|
||||
let streamSocket
|
||||
let connectionError
|
||||
let helloResolve
|
||||
let helloReject
|
||||
let helloTimer
|
||||
const hello = new Promise((resolveHello, rejectHello) => {
|
||||
helloResolve = resolveHello
|
||||
helloReject = rejectHello
|
||||
helloResolve = () => {
|
||||
clearTimeout(helloTimer)
|
||||
resolveHello()
|
||||
}
|
||||
helloReject = (error) => {
|
||||
clearTimeout(helloTimer)
|
||||
rejectHello(error)
|
||||
}
|
||||
})
|
||||
helloTimer = setTimeout(() => {
|
||||
helloReject(new Error('Daemon control hello timed out'))
|
||||
socket.destroy()
|
||||
}, requestTimeoutMs)
|
||||
|
||||
const rejectPending = (error) => {
|
||||
connectionError ??= error
|
||||
helloReject(error)
|
||||
for (const { reject, timer } of pending.values()) {
|
||||
clearTimeout(timer)
|
||||
@@ -82,25 +165,40 @@ function createRpcClient(socketPath, tokenPath) {
|
||||
}
|
||||
})
|
||||
|
||||
const connected = new Promise((resolveConnected, rejectConnected) => {
|
||||
const socketConnected = new Promise((resolveConnected, rejectConnected) => {
|
||||
socket.once('connect', resolveConnected)
|
||||
socket.once('error', rejectConnected)
|
||||
}).then(() => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
type: 'hello',
|
||||
version: readProtocolVersion(),
|
||||
token: readFileSync(tokenPath, 'utf8').trim(),
|
||||
clientId: randomUUID(),
|
||||
role: 'control'
|
||||
})}\n`
|
||||
})
|
||||
const connected = Promise.all([
|
||||
socketConnected.then(() => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
type: 'hello',
|
||||
version: protocolVersion,
|
||||
token,
|
||||
clientId,
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
}),
|
||||
hello
|
||||
]).then(async () => {
|
||||
// Why: v24 only admits terminals for the same complete control+stream pair as production.
|
||||
streamSocket = await createStreamSocket(
|
||||
socketPath,
|
||||
token,
|
||||
protocolVersion,
|
||||
clientId,
|
||||
rejectPending
|
||||
)
|
||||
return hello
|
||||
})
|
||||
|
||||
return {
|
||||
async request(type, payload) {
|
||||
await connected
|
||||
if (connectionError) {
|
||||
throw connectionError
|
||||
}
|
||||
const id = `repro-${++requestId}`
|
||||
return new Promise((resolveRequest, rejectRequest) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -112,6 +210,7 @@ function createRpcClient(socketPath, tokenPath) {
|
||||
})
|
||||
},
|
||||
close() {
|
||||
streamSocket?.destroy()
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 6.5m">
|
||||
<title>downloads: 6.5m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 6.7m">
|
||||
<title>downloads: 6.7m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">6.5m</text>
|
||||
<text x="93.5" y="14">6.5m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">6.7m</text>
|
||||
<text x="93.5" y="14">6.7m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -25,7 +25,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri
|
||||
2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite.
|
||||
3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp.
|
||||
4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks.
|
||||
5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility.
|
||||
5. Keep malformed, unrelated, or absent entries non-fatal. Do NOT gate on `is_active`: it marks which limit is currently binding, not whether the entry's data is valid, so an `is_active: false` Fable entry with a finite `percent` must still render (#8979). Accept a missing activity flag for compatibility.
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -40,7 +40,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri
|
||||
- `limits` is missing, null, malformed, or contains null entries.
|
||||
- A scoped entry names another model.
|
||||
- Fable percent is missing, non-numeric, or non-finite.
|
||||
- Fable is inactive and should not be rendered.
|
||||
- Fable is inactive (`is_active: false`) but still carries a finite `percent`/reset, so it must render (#8979).
|
||||
- `is_active` is omitted by an older server response but the remaining scoped entry is valid.
|
||||
- Both current and legacy fields exist; the current scoped entry wins.
|
||||
- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent.
|
||||
@@ -50,7 +50,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri
|
||||
|
||||
- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt.
|
||||
- Unit: assert scoped data wins over a legacy field.
|
||||
- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available.
|
||||
- Unit: assert malformed and unrelated scoped entries are ignored while legacy fallback remains available; assert an inactive-but-valid Fable entry still surfaces (#8979).
|
||||
- Regression: retain existing legacy-field and bare-`fable` behavior tests.
|
||||
- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet.
|
||||
- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface.
|
||||
@@ -76,8 +76,8 @@ No UI implementation changes. The existing Fable row must reappear with the same
|
||||
|
||||
- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable.
|
||||
- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement.
|
||||
- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution.
|
||||
- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback.
|
||||
- Failure modes covered: malformed optional data, unrelated models, inactive-but-valid limits (still rendered, #8979), missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution.
|
||||
- Test coverage required: current-schema success without PTY, precedence, inactive-but-valid rendering, malformed/unrelated entries, and legacy fallback.
|
||||
- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work.
|
||||
- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row.
|
||||
- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context.
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrellas en GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&label=%E2%98%85&color=08C" alt="Estrellas en GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Descargas totales en todas las versiones" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licencia" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="Licencia: MIT" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Únete al Discord de Orca" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Sigue a Orca en X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatibles: macOS, Windows y Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<h1 align="center">
|
||||
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Étoiles GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Téléchargements totaux sur toutes les versions" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licence" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Rejoindre le Discord Orca" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Suivre Orca sur X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plateformes prises en charge : macOS, Windows et Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>L'orchestrateur d'IA pour les builders 100x.</strong><br/>
|
||||
Lancez Codex, Claude Code, OpenCode ou Pi côte à côte — chacun dans son propre worktree, le tout suivi au même endroit.
|
||||
</p>
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>Télécharger Orca</ins></a></h3>
|
||||
|
||||
<p align="center">
|
||||
<sub>Sous Windows ? Prenez la <a href="https://github.com/stablyai/orca/releases#release-v1.4.147-rc.3">dernière RC</a> — elle inclut des correctifs Windows.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../assets/readme-hero.jpg" alt="Application de bureau Orca exécutant des agents dans des worktrees parallèles, avec l'app companion mobile Orca dans le coin" width="960" />
|
||||
</p>
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Companion mobile
|
||||
|
||||
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
|
||||
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop avec l'app companion mobile" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Worktrees parallèles
|
||||
|
||||
Lancez un même prompt sur cinq agents, chacun dans son propre worktree git isolé — comparez les résultats et mergez le gagnant.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/model/worktrees)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orchestration de worktrees parallèles" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Splits de terminal
|
||||
|
||||
Terminaux de niveau Ghostty avec rendu WebGL, splits infinis et un scrollback qui survit aux redémarrages.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/terminal)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Splits de terminal" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Mode Design
|
||||
|
||||
Cliquez sur n'importe quel élément d'UI dans une vraie fenêtre Chromium pour envoyer son HTML, son CSS et une capture recadrée directement dans le prompt de votre agent.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navigateur intégré et Mode Design" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### GitHub & Linear, natifs
|
||||
|
||||
Parcourez PRs, issues et boards de projet dans l'app — ouvrez un worktree depuis n'importe quelle tâche et reviewz sans changer de contexte.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/linear)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Workflows GitHub et Linear dans Orca" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Worktrees SSH
|
||||
|
||||
Faites tourner des agents sur une machine distante costaude, avec édition de fichiers, git et terminaux complets — reconnexion auto et port forwarding inclus.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/ssh)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees distants via SSH" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Annoter les diffs IA
|
||||
|
||||
Posez des commentaires sur n'importe quelle ligne de diff et renvoyez-les à l'agent — review, édition et commit sans quitter Orca.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Annoter les diffs générés par l'IA" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Glisser-déposer vers les agents
|
||||
|
||||
L'éditeur VS Code avec autosave partout — glissez fichiers ou images directement dans le prompt d'un agent.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Glisser des fichiers et images dans le prompt d'un agent" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Orca CLI
|
||||
|
||||
Les agents pilotent aussi Orca — scriptez n'importe quel workflow avec `orca worktree create`, `snapshot`, `click` et `fill`.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Scripter Orca depuis la CLI" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Aussi dans la boîte :**
|
||||
|
||||
- **[Quick open](https://www.onorca.dev/docs/model/quick-open)** — Cherchez parmi worktrees, fichiers, agents, commandes et contexte du repo sans quitter votre flow.
|
||||
- **[Sélecteur de comptes & suivi d'usage](https://www.onorca.dev/docs/agents/usage-tracking)** — Suivez l'usage Claude et Codex et les resets de rate limit, et basculez de compte à chaud sans vous reconnecter.
|
||||
- **[Aperçus riches du repo](https://www.onorca.dev/docs/editing/markdown)** — Prévisualisez Markdown, images, PDF et docs du repo dans le workspace.
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Laissez les agents piloter des apps desktop et l'UI visible quand un workflow demande une vraie interaction.
|
||||
- **[Notifications et non-lus](https://www.onorca.dev/docs/notifications)** — Sachez quand un agent termine ou a besoin d'attention, puis marquez des fils comme non lus pour y revenir plus tard.
|
||||
- **Et bien plus encore** — on ship tous les jours, donc cette liste est toujours en retard. Le [changelog](https://github.com/stablyai/orca/releases) est la vraie liste des features.
|
||||
|
||||
---
|
||||
|
||||
## Agents pris en charge
|
||||
|
||||
Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, il tourne dans Orca.
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Logo Claude Code" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Logo Codex" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logo Grok" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logo Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logo GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logo OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logo MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logo Amp" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="Logo OpenClaude" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Logo Antigravity" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Logo Pi" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="Logo oh-my-pi" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Logo Hermes Agent" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Logo Devin" width="16" valign="middle" /> Devin</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Logo Goose" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Logo Auggie" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Logo Autohand Code" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Logo Charm" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Logo Cline" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Logo Codebuff" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Logo Command Code" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Logo Continue" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Logo Droid" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Logo Kilocode" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Logo Kimi" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Logo Kiro" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Logo Mistral Vibe" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Logo Qwen Code" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Logo Rovo Dev" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ n'importe quel agent CLI</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Desktop — macOS, Windows, Linux
|
||||
|
||||
- **[Télécharger depuis onOrca.dev](https://onorca.dev/download)**
|
||||
- Ou récupérez un build directement : [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/download/v1.4.147-rc.3/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Tous les builds](https://github.com/stablyai/orca/releases/latest)
|
||||
- **Sous Windows :** utilisez la [dernière RC (`v1.4.147-rc.3`)](https://github.com/stablyai/orca/releases#release-v1.4.147-rc.3) — elle inclut des correctifs Windows absents de la stable.
|
||||
- Vous lancez `orca serve` sur un serveur Linux headless ? Consultez le [guide serveur Linux headless](../reference/headless-linux-server.md).
|
||||
|
||||
_Ou via un gestionnaire de paquets :_
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
|
||||
# Arch Linux (AUR) — ou stably-orca-git pour compiler depuis les sources
|
||||
yay -S stably-orca-bin
|
||||
```
|
||||
|
||||
### Companion mobile — iOS, Android
|
||||
|
||||
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
|
||||
|
||||
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android :** [Télécharger l'APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## Communauté & support
|
||||
|
||||
- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces.
|
||||
- **WeChat :** Les groupes 1 et 2 sont complets — vous pouvez rejoindre le troisième.
|
||||
|
||||
<img src="../assets/wechat-qr.jpg" alt="QR code WeChat de la communauté Orca" width="160" />
|
||||
|
||||
- **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
|
||||
- **Confidentialité :** Voir la [doc confidentialité & télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
|
||||
- **Soutenez-nous :** [Mettez une star](https://github.com/stablyai/orca) sur ce repo pour suivre nos ships quotidiens.
|
||||
|
||||
---
|
||||
|
||||
## Développement
|
||||
|
||||
Envie de contribuer ou de lancer le projet en local ? Consultez notre guide [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
|
||||
|
||||
<a href="https://github.com/stablyai/orca/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Contributeurs Orca" />
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
<img src="../assets/star-history.png" alt="Graphique d'historique des étoiles GitHub pour stablyai/orca" width="880" />
|
||||
</p>
|
||||
|
||||
## Builds signés
|
||||
|
||||
Signature de code Windows sponsorisée / fournie par [SignPath.io](https://signpath.io), certificat par [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## Licence
|
||||
|
||||
Orca est libre et open source sous la [licence MIT](../../LICENSE).
|
||||
@@ -3,16 +3,16 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub スター数" /></a>
|
||||
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&label=%E2%98%85&color=08C" alt="GitHub スター数" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="全リリースの合計ダウンロード数" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="ライセンス" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="ライセンス: MIT" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca の Discord に参加" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X で Orca をフォロー" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="対応プラットフォーム: macOS、Windows、Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub 스타" /></a>
|
||||
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&label=%E2%98%85&color=08C" alt="GitHub 스타" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="전체 릴리스 누적 다운로드 수" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="라이선스" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="라이선스: MIT" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca Discord 참여" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X에서 Orca 팔로우" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="지원 플랫폼: macOS, Windows, Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrelas no GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&label=%E2%98%85&color=08C" alt="Estrelas no GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Total de downloads em todas as versões" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licença" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="Licença: MIT" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Entre no Discord do Orca" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Siga o Orca no X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatíveis: macOS, Windows e Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub Star 数" /></a>
|
||||
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&label=%E2%98%85&color=08C" alt="GitHub Star 数" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="所有版本的总下载量" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="许可证" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="许可证: MIT" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="加入 Orca Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="在 X 上关注 Orca" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="支持的平台:macOS、Windows 和 Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
# 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 0–255). 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`.
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,204 +0,0 @@
|
||||
# Agent Hook Stdin Lifecycle
|
||||
|
||||
## Problem
|
||||
|
||||
Orca installs agent hooks in global agent configuration, so a managed hook can
|
||||
run in an Orca pane, an SSH/WSL runtime, a detached session, or an ordinary
|
||||
terminal with no Orca environment. Hook runners write an event payload to the
|
||||
child process's stdin after spawn.
|
||||
|
||||
The generated scripts currently inspect Orca environment variables before they
|
||||
read stdin. When a guard exits first, the hook runner can still be writing to a
|
||||
pipe whose reader has closed. POSIX reports `EPIPE`; Windows reports
|
||||
`ERROR_BROKEN_PIPE`. The same failure exists when a managed launcher finds that
|
||||
its script was removed or is no longer executable.
|
||||
|
||||
This is an ownership bug rather than an agent-specific parsing bug. Every
|
||||
process that accepts a hook payload owns the read end until it reaches EOF,
|
||||
including processes that decide the event is irrelevant.
|
||||
|
||||
## Goals
|
||||
|
||||
- Make successful hook no-ops consume stdin to EOF on macOS, Linux, Windows,
|
||||
WSL, and SSH hosts.
|
||||
- Encode stdin ownership once per platform instead of copying ad hoc drains
|
||||
into every early-exit branch.
|
||||
- Preserve payload bytes, hook output, transport timeouts, exit-code behavior,
|
||||
and provider-specific event metadata on the forwarding path.
|
||||
- Cover every generated script and missing-script launcher with behavioral
|
||||
regression tests.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not redesign hook configuration schemas, endpoint discovery, relay HTTP
|
||||
payloads, or event selection.
|
||||
- Do not make hook payloads unbounded; existing agent and config timeouts remain
|
||||
the outer lifecycle bound.
|
||||
- Do not suppress failures from a script that exists and actually runs. Only a
|
||||
deliberate no-op path exits successfully after consuming stdin.
|
||||
- Do not change plugin-based integrations that do not receive piped hook stdin.
|
||||
|
||||
## Contract
|
||||
|
||||
1. A generated hook script consumes its input exactly once.
|
||||
2. No whole-script success exit may occur before that consumption completes.
|
||||
3. A launcher that declines to start a missing, unreadable, or non-executable
|
||||
managed script becomes the stdin owner and drains to EOF before returning
|
||||
success.
|
||||
4. A launcher propagates the exit code of a managed script that was started.
|
||||
5. Output required by an agent protocol may be emitted before stdin is read,
|
||||
but the process must still retain the read end until EOF.
|
||||
6. Drain commands are platform-qualified where the platform searches the
|
||||
working directory implicitly.
|
||||
|
||||
## Design
|
||||
|
||||
### POSIX generated scripts
|
||||
|
||||
Capture stdin near the start of the generated script, after any protocol output
|
||||
that must be immediate and before endpoint refresh, environment guards, or
|
||||
provider-specific skips:
|
||||
|
||||
```sh
|
||||
payload=$(cat)
|
||||
if [ -z "$payload" ]; then
|
||||
exit 0
|
||||
fi
|
||||
```
|
||||
|
||||
Antigravity is the one semantic exception: events without payload still post an
|
||||
empty object, so its shared capture policy maps empty input to `{}` instead of
|
||||
exiting. Claude's Devin-import skip happens after capture. Command Code captures
|
||||
before ancestor/endpoint recovery so its comparatively expensive discovery
|
||||
cannot leave the writer blocked.
|
||||
|
||||
The common payload-capture fragments live in
|
||||
`src/main/agent-hooks/hook-stdin-contract.ts`. Templates choose the required or
|
||||
empty-object policy instead of spelling the lifecycle independently.
|
||||
|
||||
### Windows batch generated scripts
|
||||
|
||||
Batch scripts stream stdin directly into system `curl.exe`; buffering arbitrary
|
||||
JSON in an environment variable would corrupt metacharacters and hit size
|
||||
limits. Their posting path therefore remains streaming.
|
||||
|
||||
All environment guard failures jump to one epilogue:
|
||||
|
||||
```bat
|
||||
if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin
|
||||
...
|
||||
exit /b 0
|
||||
:orca_agent_hook_drain_stdin
|
||||
"%SystemRoot%\System32\more.com" >nul 2>nul
|
||||
exit /b 0
|
||||
```
|
||||
|
||||
`more.com` is qualified because Windows searches the current working directory
|
||||
for executables. Shared guard and epilogue builders keep labels and commands
|
||||
identical across templates. Command Code's existing endpoint-discovery labels
|
||||
remain subroutines and the drain epilogue is placed after them.
|
||||
|
||||
### PowerShell generated scripts
|
||||
|
||||
PowerShell captures with `[Console]::In.ReadToEnd()` before endpoint and
|
||||
environment guards. Copilot then parses the captured value only on the posting
|
||||
path. This mirrors POSIX ownership without starting an additional process.
|
||||
|
||||
### Managed launchers
|
||||
|
||||
- POSIX `/bin/sh` launchers require a regular, readable, executable file. The
|
||||
rejected path drains with `cat`; the started-script branch keeps propagating
|
||||
status.
|
||||
- Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script
|
||||
calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves
|
||||
`$LASTEXITCODE`.
|
||||
- Codex's (and Antigravity's/Devin's) cmd fast path is the bare, directly
|
||||
spawnable `.cmd` path. These agents launch the hook command as a program
|
||||
(argv[0]), not through cmd.exe, so the launcher cannot lead with a cmd builtin
|
||||
such as `if` — that argv[0] is unspawnable and fails every hook. A missing
|
||||
script therefore surfaces a normal launch failure on this fast path; only
|
||||
launchers that already require a real interpreter (encoded PowerShell, Git
|
||||
Bash) drain a missing script. Paths cmd.exe cannot invoke verbatim fall back to
|
||||
the encoded PowerShell launcher, which owns stdin for the missing case.
|
||||
- Claude's Git Bash fast path uses a POSIX file guard and drain while continuing
|
||||
to execute the `.cmd` directly rather than interpreting it as shell source.
|
||||
- Agent-specific direct launchers, including Antigravity event wrappers and
|
||||
Copilot's PowerShell-file command, adopt the same missing-file behavior.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
hook runner spawns command
|
||||
-> runner writes payload and closes stdin
|
||||
-> launcher starts managed script
|
||||
-> script consumes payload
|
||||
-> refreshes endpoint / evaluates guards
|
||||
-> posts or exits zero
|
||||
-> OR launcher cannot start script
|
||||
-> launcher drains payload
|
||||
-> exits zero
|
||||
```
|
||||
|
||||
Local, WSL, and SSH installs serialize the same POSIX template. Windows local
|
||||
installs use the batch or PowerShell template. No host assumes another host's
|
||||
path syntax or shell.
|
||||
|
||||
## Failure Semantics
|
||||
|
||||
- Missing Orca environment: consume input, exit zero, emit only protocol-required
|
||||
output.
|
||||
- Empty payload: consume EOF, then follow the agent's existing empty-event rule.
|
||||
- Missing/unreadable/non-executable script: launcher consumes input and exits
|
||||
zero.
|
||||
- Endpoint parse/read failure: preserve the existing fail-open behavior after
|
||||
stdin ownership has been satisfied.
|
||||
- Existing script returns nonzero: propagate its status; do not drain again or
|
||||
disguise the script failure.
|
||||
- Hook runner never closes stdin: the existing config-level timeout terminates
|
||||
the hook. Reading to EOF does not introduce an unbounded lifecycle beyond that
|
||||
already required by normal payload parsing.
|
||||
|
||||
## Verification
|
||||
|
||||
Add one cross-agent lifecycle suite rather than per-service string-position
|
||||
assertions.
|
||||
|
||||
- Generate every SSH-compatible POSIX managed script, spawn it with Orca
|
||||
environment removed, write a payload larger than pipe buffers, and assert:
|
||||
exit zero, no stdin error, and required protocol output remains valid.
|
||||
- Exercise the Claude Devin skip independently because it is a second no-op
|
||||
condition before endpoint forwarding.
|
||||
- Exercise POSIX, encoded PowerShell, cmd.exe, and Git Bash missing-script
|
||||
launchers with a large payload and verify zero write errors.
|
||||
- On Windows CI, install and execute every local batch/PowerShell managed script
|
||||
with missing Orca environment and a large payload.
|
||||
- Keep structural assertions for shared batch guard/epilogue generation, but do
|
||||
not use substring placement as the primary regression gate.
|
||||
- Preserve existing successful-post, timeout, quoting, SSH install, WSL, and
|
||||
nonzero-exit propagation tests.
|
||||
|
||||
For a macOS smoke test, launch the real Electron app with an isolated `HOME`
|
||||
and `ORCA_DEV_USER_DATA_PATH`, record the launch time, then run:
|
||||
|
||||
```sh
|
||||
node config/scripts/verify-agent-hook-stdin-lifecycle.mjs \
|
||||
--home "$ISOLATED_HOME" \
|
||||
--min-mtime "$LAUNCH_START_MS"
|
||||
```
|
||||
|
||||
The verifier rejects stale files, then exercises the 12 POSIX scripts written
|
||||
by that Electron launch with a payload larger than pipe buffers. It covers
|
||||
successful no-ops, loopback forwarding without payload changes, required JSON
|
||||
stdout, Claude's Devin skip, and both branches of the installed missing-script
|
||||
launcher.
|
||||
|
||||
## Rollout And Compatibility
|
||||
|
||||
The generated scripts are rewritten by existing install/update flows, so no
|
||||
config migration is needed. Commands use POSIX `sh` primitives, Windows inbox
|
||||
executables, and PowerShell features available on supported Windows releases.
|
||||
No Git behavior or provider-specific review behavior changes.
|
||||
|
||||
The change should ship atomically across templates and launchers. A partial
|
||||
rollout would leave global hooks with platform- or agent-dependent pipe safety,
|
||||
which is the inconsistency this design removes.
|
||||
@@ -1,341 +0,0 @@
|
||||
# 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
|
||||
@@ -1,926 +0,0 @@
|
||||
# Built-in Browser Local HTTPS and Certificate Trust
|
||||
|
||||
## Status
|
||||
|
||||
Implemented reference design for
|
||||
[issue #8454](https://github.com/stablyai/orca/issues/8454).
|
||||
|
||||
The branch includes scheme-less local-dev URL classification, certificate-specific
|
||||
failure copy, `Try HTTPS`, exact per-WebContents certificate grants,
|
||||
`Proceed Anyway (Unsafe)`, local IPC, remote/headless runtime propagation, and
|
||||
focused unit and real-Electron coverage. A session-level request gate contains
|
||||
Chromium's cached certificate continuation so approval cannot leak to sibling
|
||||
tabs, assets, fetches, iframes, or WebSockets. The release gates below remain
|
||||
the acceptance contract for Electron upgrades and cross-platform validation.
|
||||
|
||||
## Summary
|
||||
|
||||
Orca's built-in browser cannot open a local HTTPS development server when the
|
||||
server presents an untrusted certificate. A scheme-less local address also
|
||||
defaults to HTTP, so entering `localhost:3000` does not discover an HTTPS-only
|
||||
server.
|
||||
|
||||
This design keeps HTTP as the default for the scheme-less local forms that
|
||||
already use it, adds a visible `Try HTTPS` recovery action after a failed local
|
||||
HTTP navigation, and adds an explicit `Proceed Anyway (Unsafe)` path for an
|
||||
untrusted local HTTPS certificate. Certificate approval is narrow and
|
||||
temporary: it is bound to one browser WebContents, one secure endpoint, the
|
||||
SHA-256 digest of the exact leaf certificate, and the specific certificate
|
||||
error. A secure endpoint treats `https` and its companion `wss` scheme as one
|
||||
TLS endpoint but still includes the canonical hostname and effective port. No
|
||||
approval state is persisted.
|
||||
|
||||
The certificate decision runs in the process that owns the browser page. Local
|
||||
desktop webviews use the desktop main process; SSH/headless pages use the remote
|
||||
runtime process and expose the decision through runtime RPC.
|
||||
|
||||
## Problem
|
||||
|
||||
There are three independent behaviors behind the current failure.
|
||||
|
||||
1. `normalizeBrowserNavigationUrl` and the tab-create entry classifier prepend
|
||||
`http://` to scheme-less loopback input. Explicit `https://` input is already
|
||||
preserved.
|
||||
2. Electron rejects an untrusted server certificate by default. Orca has no
|
||||
`certificate-error` decision flow, so a self-signed development certificate
|
||||
fails with a Chromium certificate error such as
|
||||
`ERR_CERT_AUTHORITY_INVALID (-202)`.
|
||||
3. Browser load errors are presented as generic connectivity failures. For a
|
||||
loopback URL, Orca advises the user to check that the server is running even
|
||||
when the server responded and only certificate verification failed.
|
||||
|
||||
The same trust limitation exists in the offscreen browser backend used by
|
||||
headless and SSH-owned browser pages. A desktop-only handler would fix the
|
||||
visible local webview while leaving remote browser ownership inconsistent.
|
||||
|
||||
## Goals
|
||||
|
||||
- Show certificate-specific failure copy for main-frame certificate errors.
|
||||
- Let a user explicitly approve an untrusted local development certificate.
|
||||
- Bind approval to the exact browser surface, secure endpoint, leaf certificate
|
||||
digest, and certificate error.
|
||||
- Keep approval in memory only and clear it with the owning browser surface.
|
||||
- Support both desktop webviews and SSH/headless offscreen browser pages.
|
||||
- Give scheme-less local input a discoverable path to HTTPS without silently
|
||||
changing the existing HTTP default.
|
||||
- Preserve the current browser sandbox, navigation allowlist, session profiles,
|
||||
and mixed-content policy.
|
||||
- Keep browser failure state accurate across event-order races, early webview
|
||||
attachment, reload, stale user actions, and remote latency.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not automatically trust a self-signed certificate.
|
||||
- Do not disable `webSecurity`, enable insecure mixed content, or add
|
||||
`--ignore-certificate-errors`.
|
||||
- Do not install a certificate into the operating-system trust store.
|
||||
- Do not persist certificate exceptions across app restarts, browser surface
|
||||
recreation, or session-profile changes.
|
||||
- Do not default every scheme-less localhost URL to HTTPS.
|
||||
- Do not automatically probe HTTPS and then downgrade to HTTP.
|
||||
- Do not add a general-purpose exception flow for public websites in v1.
|
||||
- Do not make every Chromium certificate error overridable in v1.
|
||||
- Do not extend localhost worktree-label proxying to HTTPS targets.
|
||||
|
||||
## Product Decisions
|
||||
|
||||
### Scheme-less local input continues to use HTTP
|
||||
|
||||
`localhost:3000` remains `http://localhost:3000/`. HTTP is still the common
|
||||
development-server default, and changing the inference globally would turn
|
||||
working HTTP servers into certificate or TLS failures.
|
||||
|
||||
When an HTTP navigation to a loopback URL fails, the browser failure overlay
|
||||
offers `Try HTTPS`. The action replaces only the URL scheme and preserves the
|
||||
host, explicit non-default port, path, query, and fragment. An absent port, or
|
||||
an explicit `:80` normalized away by the URL parser, becomes HTTPS's default
|
||||
port 443. Orca does not automatically retry because an invisible
|
||||
cross-protocol retry would add latency, send an unexpected second request, and
|
||||
make downgrade behavior opaque.
|
||||
|
||||
Explicit input remains authoritative:
|
||||
|
||||
- `http://localhost:3000` stays HTTP.
|
||||
- `https://localhost:3000` stays HTTPS.
|
||||
- URLs advertised by a workspace process keep their advertised scheme.
|
||||
|
||||
The shared browser URL domain becomes the source of truth for the existing
|
||||
scheme-less local-dev classification. The tab-create entry classifier calls
|
||||
that shared logic instead of keeping a second regular expression and scheme
|
||||
rule. This consolidation must not reuse the stricter certificate-eligibility
|
||||
predicate: doing so would change current behavior for inputs such as
|
||||
`0.0.0.0`, bracketed non-loopback IPv6 addresses, and `.localhost` subdomains.
|
||||
|
||||
### V1 approval covers untrusted local authorities only
|
||||
|
||||
The first version offers `Proceed Anyway (Unsafe)` only when all of these are
|
||||
true:
|
||||
|
||||
- the failure is for the main frame;
|
||||
- the attempted URL is HTTPS;
|
||||
- Electron reports `net::ERR_CERT_AUTHORITY_INVALID`, corresponding to
|
||||
Chromium load error `-202`;
|
||||
- the host is an eligible loopback host;
|
||||
- the WebContents is a browser surface managed by Orca; and
|
||||
- Orca retained a live pending challenge for that browser surface.
|
||||
|
||||
Other certificate errors receive accurate copy but no bypass action. In
|
||||
particular, v1 does not bypass revoked certificates, malformed certificates,
|
||||
weak keys, date errors, or hostname/SAN mismatches. Expanding the allowlist
|
||||
requires a separate security decision and dedicated tests.
|
||||
|
||||
### Eligible loopback hosts
|
||||
|
||||
The shared predicate accepts:
|
||||
|
||||
- `localhost`, case-insensitively, with an optional trailing dot;
|
||||
- valid subdomains of `.localhost`, also with an optional trailing dot;
|
||||
- valid IPv4 addresses in `127.0.0.0/8`; and
|
||||
- IPv6 loopback `::1`.
|
||||
|
||||
The predicate does not treat `0.0.0.0`, `::`, arbitrary bracketed IPv6
|
||||
addresses, private LAN addresses, or a DNS name that merely resolves to
|
||||
loopback as certificate-bypass eligible. Wildcard bind addresses should already
|
||||
be normalized to a connectable loopback address before navigation.
|
||||
|
||||
SSH local forwards are eligible when their browser-facing URL uses one of these
|
||||
loopback hosts. Forwarding does not fix certificate names: a URL using
|
||||
`127.0.0.1` still needs an IP SAN for `127.0.0.1`; a certificate containing only
|
||||
`localhost` will fail with `ERR_CERT_COMMON_NAME_INVALID`, which v1 does not
|
||||
bypass. Custom advertised DNS names remain subject to normal system trust in
|
||||
v1, even if the name maps to loopback.
|
||||
|
||||
### Approval lifetime
|
||||
|
||||
An accepted grant is scoped to:
|
||||
|
||||
```text
|
||||
browser WebContents ID + secure endpoint + leaf SHA-256 digest + error name
|
||||
```
|
||||
|
||||
The secure endpoint canonicalizes `wss:` to its companion `https:` endpoint and
|
||||
includes the normalized hostname and effective port. This lets an approved
|
||||
local page load same-endpoint HTTPS assets and its WSS development socket. A
|
||||
grant for `https://localhost:3000` does not apply to port 3001, to
|
||||
`https://127.0.0.1:3000`, to another browser tab, or to a replacement
|
||||
certificate.
|
||||
|
||||
Construct the secure endpoint from the parsed request URL: map `https:` and
|
||||
`wss:` to `https:`, lowercase the hostname, remove one optional trailing dot,
|
||||
use the URL parser's canonical IP representation, and use port 443 when the
|
||||
parsed port is empty. Exclude credentials, path, query, and fragment. Reject
|
||||
every other scheme.
|
||||
|
||||
Accepted grants survive reloads and same-surface navigation back to the exact
|
||||
endpoint. They are cleared when the WebContents commits a main-frame navigation
|
||||
whose canonical secure endpoint differs from the grant's endpoint (reusing the
|
||||
per-WebContents navigation-sequence tracking), when the WebContents is destroyed
|
||||
or replaced, the session profile changes, the browser-owning process exits, or
|
||||
the app restarts. Binding grant lifetime to the granting main-frame document
|
||||
prevents a later top-level document in the same tab — for example a navigation
|
||||
to a public site — from silently reusing the loopback grant for a same-endpoint
|
||||
iframe, fetch, or WSS subrequest.
|
||||
Clearing a grant affects future requests; it cannot retroactively terminate a
|
||||
document that already loaded. Grants are not written to settings, browser
|
||||
history, workspace session state, or logs.
|
||||
|
||||
Chromium caches an accepted `certificate-error` continuation across the
|
||||
Electron session partition. Orca therefore retains every endpoint/certificate
|
||||
identity accepted in that partition until the browser-owning process exits and
|
||||
gates all later HTTPS/WSS requests with `webRequest.onBeforeRequest`. Only the
|
||||
WebContents holding the matching grant may reach a cached endpoint. A sibling
|
||||
main-frame navigation receives a synthetic challenge for the known identity;
|
||||
sibling subresources, fetches, iframes, workers, and WSS requests fail silently.
|
||||
|
||||
Because a preflight request gate cannot inspect the certificate that will be
|
||||
presented after Chromium's cache is consulted, one endpoint is locked to the
|
||||
first accepted untrusted leaf identity for that process lifetime. If that
|
||||
endpoint later presents a different untrusted leaf, strict verification rejects
|
||||
it and Orca does not offer another bypass until the browser-owning process
|
||||
restarts. A replacement system-trusted certificate may load normally; the
|
||||
conservative request gate still keeps sibling WebContents blocked until restart.
|
||||
|
||||
## User Experience
|
||||
|
||||
### Local HTTP failure
|
||||
|
||||
For a failed `http://` loopback navigation, keep the current connectivity
|
||||
title and recovery hint. Add `Try HTTPS` when an HTTPS form can be constructed.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Can't reach localhost:3000
|
||||
We couldn't connect to your local server.
|
||||
|
||||
[Try HTTPS] [Retry] [Copy Address] [Open Externally]
|
||||
```
|
||||
|
||||
`Try HTTPS` is the default button because it is the targeted recovery action
|
||||
for a local HTTP failure. Keep the existing `Copy Address` recovery action;
|
||||
`Retry` uses `outline`, while `Copy Address` and `Open Externally` stay quiet
|
||||
secondary actions. `Try HTTPS` does not appear for an already-HTTPS URL or a
|
||||
non-eligible host.
|
||||
|
||||
### Certificate failure
|
||||
|
||||
For `ERR_CERT_AUTHORITY_INVALID (-202)` on an eligible local endpoint, replace
|
||||
the generic connectivity content with:
|
||||
|
||||
```text
|
||||
Connection isn't secure
|
||||
Orca doesn't trust the authority that issued the certificate for localhost:3000.
|
||||
|
||||
For local development, use a trusted local certificate when possible.
|
||||
|
||||
[Open Externally] [Retry] [Copy Address] [Proceed Anyway (Unsafe)]
|
||||
```
|
||||
|
||||
- `Open Externally` is the default safe recovery action when the URL is
|
||||
reachable from the desktop.
|
||||
- When `Open Externally` is unavailable, `Retry` becomes the default action.
|
||||
- `Proceed Anyway (Unsafe)` uses `outline`; `Copy Address` stays quiet.
|
||||
- `Proceed Anyway (Unsafe)` is always visibly labeled; it is not hidden behind
|
||||
a tooltip.
|
||||
- The unsafe action does not use the destructive color. It does not delete or
|
||||
irreversibly mutate user data, and the explicit label carries the warning.
|
||||
- Use a muted `ShieldAlert` icon from `lucide-react`; do not add a warning color
|
||||
or new token.
|
||||
|
||||
For a remote-owned page whose URL is remote localhost, `Open Externally` is
|
||||
hidden because the desktop system browser cannot reach the remote loopback
|
||||
address. It remains available for a desktop-owned page or a locally forwarded
|
||||
URL.
|
||||
|
||||
For certificate errors that v1 does not permit bypassing, show the same
|
||||
certificate-specific title with error-specific body text, `Retry`,
|
||||
`Copy Address`, and an eligible `Open Externally` action. `Copy Address` remains
|
||||
present as a quiet secondary action in every certificate-failure branch,
|
||||
eligible or not, so only `Try HTTPS` and `Proceed Anyway (Unsafe)` toggle in and
|
||||
out across cases. The same default-action rule applies as for `-202`:
|
||||
`Open Externally` is the default when the URL is reachable from the desktop,
|
||||
otherwise `Retry` becomes the default. Do not show the local-server-running
|
||||
hint.
|
||||
|
||||
Use this presentation mapping, inserting the display host into the copy:
|
||||
|
||||
| Chromium code | Body copy | Proceed in v1 |
|
||||
| ----------------------- | -------------------------------------------------------------------------- | ----------------------------- |
|
||||
| `-200` | `The certificate doesn't match {host}.` | No |
|
||||
| `-201` | `The certificate for {host} isn't valid at the current date and time.` | No |
|
||||
| `-202` | `Orca doesn't trust the authority that issued the certificate for {host}.` | Eligible local endpoints only |
|
||||
| Other certificate error | `Orca couldn't verify the certificate for {host}.` | No |
|
||||
|
||||
Keep the raw error name/code available to diagnostics and optional details, but
|
||||
do not make users interpret it to understand the primary failure.
|
||||
|
||||
### Interaction behavior
|
||||
|
||||
- The overlay is persistent inline UI because the user must read and act on the
|
||||
failure; do not use a toast.
|
||||
- The overlay does not steal focus when it appears.
|
||||
- Actions are reachable by Tab and have visible focus rings.
|
||||
- Use `aria-live="polite"`; repeated background failures must not repeatedly
|
||||
announce the same message.
|
||||
- Clicking `Proceed Anyway (Unsafe)` disables the action immediately, and also
|
||||
disables the other overlay actions (`Retry`, `Try HTTPS`, `Copy Address`,
|
||||
`Open Externally`) for the duration of the approval round-trip so a stale
|
||||
click cannot race the controller's own `loadURL`. Re-enable them once a
|
||||
success or typed-failure response returns.
|
||||
- Map every `proceedCertificate` failure reason to an overlay outcome:
|
||||
`expired`, `changed`, `ineligible`, and `missing` keep the overlay open and
|
||||
show the inline recovery message below; `navigated` shows no message because
|
||||
the overlay for that navigation is already cleared. In every non-success case
|
||||
`Proceed Anyway (Unsafe)` stays disabled until a fresh pending challenge is
|
||||
announced rather than re-enabling on its own.
|
||||
- Delay the spinner by 200 ms, matching the style guide's remote-latency rule.
|
||||
After that delay, show the canonical `Loader2` with `Connecting…` and use a
|
||||
fixed button width so the label swap cannot move adjacent actions.
|
||||
- If approval expired or the certificate changed, keep the overlay open and
|
||||
show `The certificate changed or the approval expired. Retry the page.`
|
||||
- A successful main-frame navigation clears the visible certificate failure.
|
||||
|
||||
The UI must use existing `background`, `foreground`, `muted-foreground`,
|
||||
`border`, and `ring` tokens plus the existing shadcn `Button` primitive. No new
|
||||
color, radius, or shadow tier is required. Do not apply a blanket opacity to
|
||||
the overlay's interactive content; use semantic muted text classes so button
|
||||
and focus-ring contrast remains intact. Put every new user-visible string
|
||||
through the existing localization catalog.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Ownership
|
||||
|
||||
The browser-owning main process is the only authority that can accept a
|
||||
certificate challenge.
|
||||
|
||||
- Desktop-owned `<webview>` pages are controlled by the desktop main process.
|
||||
- Headless/SSH-owned pages are controlled by the remote `orca serve` main
|
||||
process through `OffscreenBrowserBackend`.
|
||||
- The renderer only presents a pending challenge and requests approval. It
|
||||
never decides whether a certificate is trusted.
|
||||
|
||||
### New main-process controller
|
||||
|
||||
Add `src/main/browser/browser-certificate-trust-controller.ts` to manage pending
|
||||
certificate challenges, and
|
||||
`src/main/browser/browser-certificate-request-guard.ts` to manage grants plus
|
||||
the session-level cached-certificate request boundary.
|
||||
|
||||
The controller owns:
|
||||
|
||||
```ts
|
||||
type BrowserCertificateFailure = {
|
||||
challengeId: string
|
||||
browserPageId: string
|
||||
errorCode: number | null
|
||||
error: string
|
||||
origin: string
|
||||
displayHost: string
|
||||
canProceed: boolean
|
||||
observedAt: number
|
||||
}
|
||||
|
||||
type PendingBrowserCertificateChallenge = {
|
||||
challengeId: string
|
||||
guestWebContentsId: number
|
||||
browserPageId: string | null
|
||||
navigationSequence: number
|
||||
navigationUrl: string
|
||||
origin: string
|
||||
secureEndpoint: string
|
||||
leafCertificateSha256: string
|
||||
errorCode: number | null
|
||||
error: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
type BrowserCertificateGrant = {
|
||||
guestWebContentsId: number
|
||||
secureEndpoint: string
|
||||
leafCertificateSha256: string
|
||||
error: string
|
||||
}
|
||||
```
|
||||
|
||||
Generate challenge IDs with `randomUUID()`. Compute the certificate identity in
|
||||
the browser-owning main process as SHA-256 over the DER bytes of Electron's leaf
|
||||
`certificate.data`; do not depend on the undocumented formatting or algorithm
|
||||
of `certificate.fingerprint`. Renderer and runtime payloads expose no
|
||||
certificate bytes or digest—only the challenge ID, display host, origin, error
|
||||
code, error name, `canProceed`, and observation time.
|
||||
|
||||
Pending challenges expire after five minutes and are bounded to 32 entries.
|
||||
Accepted grants are bounded to 32 entries per browser-owning process and are
|
||||
also removed with their WebContents. Bounds evict the oldest entry and notify
|
||||
the affected UI when a live challenge is evicted. Ordinary cleanup should keep
|
||||
both collections much smaller. Accepted endpoint identities are not evicted:
|
||||
Chromium's corresponding cache cannot be cleared narrowly, so forgetting one
|
||||
would reopen cross-WebContents access until process exit.
|
||||
|
||||
### Certificate event handling
|
||||
|
||||
Register exactly one app-level `certificate-error` listener after Electron is
|
||||
ready but before either a desktop window or the offscreen backend can create a
|
||||
browser page. Every branch must call Electron's callback exactly once; an
|
||||
unexpected exception fails closed with `callback(false)`.
|
||||
|
||||
Electron supplies the certificate error as a string on this event, while
|
||||
`did-fail-load` supplies the Chromium number later. Normalize the string to one
|
||||
canonical error name and map only known names to numbers for presentation. An
|
||||
unknown name keeps `errorCode: null` and is never bypass-eligible.
|
||||
|
||||
For every event:
|
||||
|
||||
1. Resolve the WebContents through `BrowserManager` and reject unmanaged,
|
||||
retired, or popup WebContents.
|
||||
2. Parse the request URL, normalize its secure endpoint, and compute the leaf
|
||||
certificate SHA-256 digest. Invalid data fails closed.
|
||||
3. Compare the normalized error name, endpoint, and digest against an accepted
|
||||
grant for this exact WebContents. On an exact match, first retain the accepted
|
||||
identity in the owning Electron session, then call `event.preventDefault()`
|
||||
and `callback(true)`.
|
||||
4. If no grant matches, reject non-main-frame failures without creating a
|
||||
challenge. Subresources and iframes can consume an existing grant but cannot
|
||||
mint one.
|
||||
5. For a main-frame failure, record a pending challenge only when the URL and
|
||||
error are eligible. Notify the owning page when its page ID is known, then
|
||||
call `callback(false)`.
|
||||
|
||||
Track a monotonically increasing main-frame navigation sequence per
|
||||
WebContents. A new main-frame navigation invalidates its pending challenge in
|
||||
both main and renderer state before the next certificate decision. Repeated
|
||||
identical events within one sequence reuse the challenge ID; a retry or a
|
||||
different navigation gets a new ID. This prevents an old button or compromised
|
||||
renderer from approving a page the user has already left.
|
||||
|
||||
The first failure is always rejected. User approval records a grant and reloads
|
||||
the page; the next certificate event is the one that succeeds.
|
||||
|
||||
Do not leave Electron's callback pending while waiting for user input. Rejecting
|
||||
first gives Orca a normal load-failure lifecycle, avoids an unbounded blocked
|
||||
navigation, and makes stale approval cleanup deterministic.
|
||||
|
||||
Do not use `session.setCertificateVerifyProc`. It applies to an entire session
|
||||
partition, cannot bind a decision to one browser page, and verification results
|
||||
can be cached by Chromium's network service.
|
||||
|
||||
### Session request gate and Electron upgrade guard
|
||||
|
||||
The pinned Electron 43 binary caches `callback(true)` broadly enough that a
|
||||
second WebContents in the same partition can load the endpoint without another
|
||||
`certificate-error` event. The certificate callback alone is therefore not an
|
||||
isolation boundary.
|
||||
|
||||
Install exactly one `webRequest.onBeforeRequest` listener on every Orca browser
|
||||
session before its first page loads. Electron permits only one listener for this
|
||||
event, so the browser session registry owns installation and removal. Once an
|
||||
endpoint identity has been accepted, the listener blocks every HTTPS/WSS
|
||||
request to that endpoint unless `details.webContentsId` has the matching grant.
|
||||
Requests without a WebContents ID, including background/worker traffic, fail
|
||||
closed. A blocked main-frame request recreates the challenge and certificate
|
||||
load error; other resource types are canceled without replacing the current
|
||||
page's overlay.
|
||||
|
||||
The real-Electron integration suite must prove same-tab reload, same-endpoint
|
||||
HTTPS assets and WSS, different-tab documents and subresources, and
|
||||
different-port isolation against the exact Electron version in
|
||||
`pnpm-lock.yaml`. Any Electron upgrade that breaks those tests blocks release
|
||||
until the design is revised; unit mocks are insufficient.
|
||||
|
||||
### BrowserManager integration
|
||||
|
||||
Add a narrow BrowserManager query API instead of exposing its internal maps:
|
||||
|
||||
```ts
|
||||
getManagedBrowserGuestContext(webContentsId: number): {
|
||||
browserPageId: string | null
|
||||
worktreeId: string | null
|
||||
sessionProfileId: string | null
|
||||
owner: 'desktop-webview' | 'offscreen'
|
||||
} | null
|
||||
```
|
||||
|
||||
For guest lifecycle, follow the existing extracted-controller precedent
|
||||
(`browser-grab-session-controller.ts`, `browser-guest-ui.ts`,
|
||||
`browser-download-destination.ts`), all of which BrowserManager drives by
|
||||
calling a plain method on the controller directly from `registerGuest` /
|
||||
`unregisterGuest`. Have BrowserManager call
|
||||
`certificateTrustController.onGuestRegistered({ browserPageId, webContentsId })`
|
||||
and `.onGuestRetired(webContentsId)` from those same bodies, rather than adding a
|
||||
new listener/emitter registration API to BrowserManager — there is no such
|
||||
pub-sub surface in `src/main/browser` today and this single consumer does not
|
||||
warrant introducing one.
|
||||
|
||||
`getManagedBrowserGuestContext` must recognize an attached primary desktop
|
||||
guest before renderer registration, returning a null page ID, while rejecting
|
||||
popup descendants that merely inherited browser policies. It must also
|
||||
recognize offscreen pages after `registerOffscreenGuest` and stop recognizing a
|
||||
guest immediately when BrowserManager retires or unregisters it. The controller
|
||||
re-checks this ownership on every certificate event even if it still has an
|
||||
in-memory grant.
|
||||
|
||||
An attached desktop webview can encounter a certificate error before its
|
||||
document reaches `dom-ready`, which is when BrowserPane currently registers the
|
||||
guest. Keep pending challenges keyed by WebContents ID until registration
|
||||
supplies the page ID, then flush the failure to the renderer.
|
||||
|
||||
BrowserPane should also register idempotently on the webview's `did-attach`
|
||||
event, with `dom-ready` retained as a fallback. `registerGuest` should report
|
||||
whether the main process accepted the registration so a rare attach-policy race
|
||||
can retry at `dom-ready`; the renderer must not cache the WebContents ID as
|
||||
registered until it receives `true`.
|
||||
|
||||
Offscreen pages already register before `loadURL`, so their page ID is normally
|
||||
available when the certificate event fires. Bring their main-frame navigation
|
||||
and `did-fail-load` observation under the same BrowserManager lifecycle used by
|
||||
desktop guests; the current offscreen loader only logs a rejected `loadURL`, so
|
||||
it cannot by itself populate runtime failure snapshots.
|
||||
|
||||
### Renderer state
|
||||
|
||||
Certificate challenges are transient runtime state and must not be added to
|
||||
the persisted `BrowserPage` schema.
|
||||
|
||||
Add a transient map to the browser store:
|
||||
|
||||
```ts
|
||||
browserCertificateFailuresByPageId: Record<string, BrowserCertificateFailure>
|
||||
```
|
||||
|
||||
The workspace session serializer must omit this map. Clear an entry when:
|
||||
|
||||
- the page completes a successful main-frame navigation;
|
||||
- the user starts a different navigation;
|
||||
- the page or workspace closes;
|
||||
- the owning browser WebContents is replaced; or
|
||||
- the main process reports that the challenge expired.
|
||||
|
||||
The existing `BrowserLoadError` remains the persisted diagnostic source. Add a
|
||||
pure Chromium-error classifier for Electron 43's certificate range (`-200`
|
||||
through `-219`, excluding the unused values) so certificate failures still
|
||||
render accurate copy after restore even when no live challenge exists. A live
|
||||
certificate failure's error code takes presentation precedence over a
|
||||
synthesized `-1` Chromium-error-page fallback. Independently, only a live,
|
||||
matching challenge controls whether `Proceed Anyway (Unsafe)` is enabled.
|
||||
|
||||
Use both the page ID and attempted origin when combining the live challenge
|
||||
with `BrowserLoadError`. A stale challenge from a prior navigation must never
|
||||
add a proceed action to a different failure. Main-process navigation-sequence
|
||||
invalidation is the security boundary; renderer cleanup is defense in depth and
|
||||
keeps the UI honest.
|
||||
|
||||
### Local IPC
|
||||
|
||||
Extend the browser preload API with:
|
||||
|
||||
```ts
|
||||
onCertificateFailureChanged(
|
||||
callback: (event: {
|
||||
browserPageId: string
|
||||
failure: BrowserCertificateFailure | null
|
||||
}) => void
|
||||
): () => void
|
||||
|
||||
proceedCertificate(args: {
|
||||
browserPageId: string
|
||||
challengeId: string
|
||||
}): Promise<
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'expired' | 'changed' | 'ineligible' | 'missing' | 'navigated'
|
||||
}
|
||||
>
|
||||
```
|
||||
|
||||
Register the handler with the existing browser IPC group and reuse
|
||||
`isTrustedBrowserRenderer`. The main process resolves the page to its current
|
||||
WebContents and then consumes the exact pending challenge. Renderer-provided
|
||||
origin, error code, or certificate identity are never trusted.
|
||||
|
||||
On success, main consumes the challenge, records the grant, and calls
|
||||
`loadURL` with the challenge's main-owned `navigationUrl`. It must not use
|
||||
`webContents.getURL()`, which may be `chrome-error://chromewebdata/` or a
|
||||
previously committed page. It also must not reload if the page's current
|
||||
navigation sequence no longer matches the challenge. The renderer does not
|
||||
assign `webview.src` as a second side effect.
|
||||
|
||||
### Remote runtime RPC
|
||||
|
||||
The remote browser owner exposes equivalent behavior through:
|
||||
|
||||
```text
|
||||
browser.certificate.proceed
|
||||
```
|
||||
|
||||
with the runtime worktree selector, remote browser page ID, and challenge ID.
|
||||
Advertise support through a new `browser.certificate-trust.v1` capability only
|
||||
when the runtime has both a browser backend and the certificate controller.
|
||||
Keep the method out of the mobile-scope RPC allowlist; it is for an
|
||||
authenticated runtime-scope Orca client presenting browser chrome, not a guest
|
||||
page or unauthenticated caller.
|
||||
|
||||
Extend `RuntimeMobileSessionBrowserTab` compatibly with optional `loadError`
|
||||
and `certificateFailure` fields. Both are emitted from live runtime state.
|
||||
`loadError` keeps the existing `BrowserPage` last-known diagnostic semantics,
|
||||
including workspace persistence; `certificateFailure` is transient and is
|
||||
never written to workspace persistence. Controller changes, challenge expiry,
|
||||
navigation, and offscreen `did-fail-load` must mark the runtime session snapshot
|
||||
dirty so clients do not wait for unrelated state to change. Web clients and
|
||||
desktop clients reconcile these fields through the existing remote page-handle
|
||||
mapping. Each full snapshot replaces the prior transient certificate failure
|
||||
for that page, including clearing it when the field is absent. An older runtime
|
||||
without the capability always clears any previously mirrored proceed action.
|
||||
|
||||
When the active page has a remote owner, BrowserPane routes approval to that
|
||||
environment. It must not call local desktop IPC. If the runtime lacks the new
|
||||
capability, the UI still classifies the certificate failure accurately but does
|
||||
not render `Proceed Anyway (Unsafe)`. `Try HTTPS`, retry, and other navigation
|
||||
actions continue through the existing owner-aware navigation path rather than
|
||||
touching a local webview for a remote-owned page.
|
||||
|
||||
### Scheme recovery
|
||||
|
||||
Add pure functions in the shared browser URL domain:
|
||||
|
||||
```ts
|
||||
classifySchemeLessLocalDevAddress(rawInput: string): URL | null
|
||||
isEligibleLocalCertificateHost(hostname: string): boolean
|
||||
toHttpsRecoveryUrl(rawUrl: string): string | null
|
||||
```
|
||||
|
||||
`classifySchemeLessLocalDevAddress` preserves the exact current HTTP-default
|
||||
input set and is shared by address-bar normalization and tab-create entry. It
|
||||
is deliberately broader than certificate eligibility.
|
||||
|
||||
`isEligibleLocalCertificateHost` accepts a parsed/canonical hostname; normalize
|
||||
case and an optional trailing dot and handle the brackets returned by
|
||||
`URL.hostname` for IPv6. Validate IPv4 octets and DNS label boundaries instead
|
||||
of using a substring or loose suffix check.
|
||||
|
||||
`toHttpsRecoveryUrl` returns a value only for an HTTP URL with an eligible
|
||||
loopback host. It changes `protocol` to `https:` on a parsed `URL` object and
|
||||
preserves credentials, hostname, an explicit non-default port, path, query, and
|
||||
fragment. When no port is present, including when the parsed HTTP URL normalized
|
||||
`:80` away, the result uses HTTPS's default port 443. It never probes either
|
||||
protocol.
|
||||
|
||||
Both address-bar submission and tab-create entry use the shared local-address
|
||||
classifier. Existing advertised workspace-port URLs remain authoritative and
|
||||
require no new probing.
|
||||
|
||||
## Security Model
|
||||
|
||||
### Invariants
|
||||
|
||||
- Default certificate verification remains strict.
|
||||
- Only a trusted Orca renderer or authenticated runtime-scope Orca client can
|
||||
request approval.
|
||||
- The renderer cannot choose the origin, certificate digest, error,
|
||||
WebContents, or eligibility result.
|
||||
- A grant is exact-match only and never applies across tabs, ports,
|
||||
certificates, or browser-owning processes.
|
||||
- Session-cached accepted endpoints remain request-gated until process exit,
|
||||
even after their original WebContents or bounded grant is removed.
|
||||
- Certificate PEM/DER data and certificate digests do not enter workspace
|
||||
state, history, telemetry, or ordinary logs.
|
||||
- Subresource and iframe certificate failures cannot mint an approval action;
|
||||
they can consume an already-approved exact-endpoint grant on the same
|
||||
WebContents.
|
||||
- The proceed affordance exists only in Orca chrome; guest content has no
|
||||
direct approval channel. Main-process challenge validation remains the
|
||||
security boundary and does not assume proof of a physical click.
|
||||
- A remote client cannot approve a certificate in a different runtime
|
||||
environment.
|
||||
- Starting another main-frame navigation invalidates the pending approval in
|
||||
the browser-owning process, not only in renderer state.
|
||||
|
||||
### Threat cases
|
||||
|
||||
| Threat | Required behavior |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Compromised web page sends forged IPC | Guest WebContents has no preload API; trusted-renderer check rejects it. |
|
||||
| Compromised renderer invents a challenge | Main consumes only a server-created pending challenge tied to the current page and WebContents. |
|
||||
| Certificate changes after approval | Leaf-digest mismatch rejects; a second untrusted leaf for that endpoint cannot be bypassed until restart. |
|
||||
| Same host uses a different port | Secure-endpoint mismatch rejects. |
|
||||
| Another tab visits the approved endpoint | WebContents mismatch rejects. |
|
||||
| Approved tab later loads a foreign top document | Grant is invalidated when the main frame navigates to a different endpoint; the new document cannot reuse it. |
|
||||
| Challenge is replayed | Challenge ID is single-use and expires. |
|
||||
| User leaves the failed page before clicking | Navigation-sequence mismatch rejects without reloading the stale URL. |
|
||||
| Approved page opens same-endpoint WSS/HMR | Existing endpoint grant permits it; WSS cannot create a grant by itself. |
|
||||
| Public site presents an untrusted certificate | Host eligibility fails; no proceed action is available. |
|
||||
| Remote page approval is sent locally | Ownership routing and remote page handles select the browser-owning runtime. |
|
||||
| App restarts after approval | No persisted grant exists. |
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- If a certificate event arrives before guest registration, retain it by
|
||||
WebContents ID and flush after registration.
|
||||
- If guest registration never completes, expire the pending challenge without
|
||||
notifying unrelated pages.
|
||||
- If `Proceed` arrives after expiry, navigation, or WebContents replacement,
|
||||
return a typed failure and leave strict verification in place.
|
||||
- If reload fails for a different reason, clear the certificate challenge and
|
||||
present the new load error.
|
||||
- If the same rejected challenge fires repeatedly in one navigation, coalesce
|
||||
it without rotating the challenge ID or stacking announcements.
|
||||
- If the certificate-error listener cannot parse the URL or leaf certificate,
|
||||
reject normally and do not offer approval.
|
||||
- If the remote connection drops during approval, keep the local UI stable and
|
||||
surface the runtime error inline after the latency threshold.
|
||||
- If an unexpected controller error occurs, call Electron's callback once with
|
||||
`false`; never leave the request suspended.
|
||||
- If an accepted main-frame page later opens a new same-endpoint TLS connection,
|
||||
apply the exact grant to that HTTPS/WSS subrequest. Reject unmatched
|
||||
subrequests silently without replacing the main-frame overlay.
|
||||
- If an accepted endpoint presents a different untrusted leaf, reject it without
|
||||
replacing the process-lifetime accepted identity or offering a second bypass.
|
||||
|
||||
## Data and Privacy
|
||||
|
||||
No migration is required.
|
||||
|
||||
Add no new product analytics in v1. Do not record URLs, certificate digests,
|
||||
challenge IDs, certificate subjects, or approval results. If product analytics
|
||||
becomes a requirement later, define coarse actions in the existing typed
|
||||
feature-interaction catalog as a separate reviewed change.
|
||||
|
||||
Existing diagnostics may retain the Chromium numerical error code, as they do
|
||||
for other browser load failures. Debug logging must contain at most the error
|
||||
name, page ID, owner kind, and normalized loopback host class; omit query
|
||||
strings, full URLs, and certificate digests.
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Shared URL tests
|
||||
|
||||
- Explicit HTTP and HTTPS local URLs remain unchanged.
|
||||
- Scheme-less `localhost`, IPv4 loopback, and IPv6 loopback still normalize to
|
||||
HTTP.
|
||||
- Tab-create and address-bar local-address classification stay in parity for
|
||||
the full legacy set, including wildcard and bracketed IPv6 inputs.
|
||||
- The broader scheme-less classifier is not accidentally substituted for the
|
||||
stricter certificate-eligibility predicate.
|
||||
- `toHttpsRecoveryUrl` preserves host, an explicit non-default port, path,
|
||||
query, and fragment; a URL with no parsed port changes from HTTP's default 80
|
||||
to HTTPS's default 443.
|
||||
- `Try HTTPS` is unavailable for HTTPS, file URLs, public hosts, wildcard bind
|
||||
hosts, private LAN hosts, and invalid URLs.
|
||||
- Loopback eligibility covers `localhost`, trailing-dot localhost,
|
||||
`.localhost` subdomains with and without a trailing dot, the full valid IPv4
|
||||
127/8 range, and bracketed or unbracketed `::1` after URL parsing.
|
||||
- Reject malformed IPv4, arbitrary bracketed IPv6, `0.0.0.0`, `::`, and names
|
||||
that only contain the word `localhost`.
|
||||
|
||||
### Main-process trust-controller tests
|
||||
|
||||
- Unmanaged, retired, and popup WebContents are rejected without pending
|
||||
challenges.
|
||||
- A managed `-202` loopback failure creates one pending challenge and calls
|
||||
`callback(false)`.
|
||||
- Every event path calls its callback exactly once; only an accepted grant calls
|
||||
`preventDefault()` and `callback(true)`.
|
||||
- The digest is SHA-256 over DER leaf bytes and is stable across PEM formatting.
|
||||
- An exact accepted grant covers same-endpoint HTTPS assets, WSS, and iframes on
|
||||
the same WebContents without allowing those requests to mint a challenge.
|
||||
- Wrong page, WebContents, origin, port, certificate digest, error type, and
|
||||
challenge ID fail closed.
|
||||
- A token is single-use and expires after five minutes.
|
||||
- Repeated identical failures in one navigation coalesce; a new navigation
|
||||
invalidates the token and rotates the challenge ID.
|
||||
- Approving after navigation returns `navigated` and never reloads the old URL.
|
||||
- Approval loads the main-owned challenged URL, not the current Chromium error
|
||||
URL or a renderer-supplied URL.
|
||||
- Certificate rotation fails closed and cannot replace the process-lifetime
|
||||
accepted endpoint identity.
|
||||
- WebContents destruction removes pending challenges and grants.
|
||||
- Bounds evict the oldest pending challenge/grant.
|
||||
- Registering a page after an early certificate event flushes the event to the
|
||||
correct renderer.
|
||||
- Concurrent tabs and session profiles remain isolated.
|
||||
|
||||
### IPC and runtime tests
|
||||
|
||||
- Browser certificate IPC rejects untrusted senders and malformed arguments.
|
||||
- Renderer-supplied origin or certificate-identity fields are neither accepted
|
||||
nor used.
|
||||
- Local approval reloads only the currently mapped guest.
|
||||
- Remote approval calls the owning environment's RPC with the remote page ID.
|
||||
- Local IPC is not called for a remote-owned page.
|
||||
- Mobile-scope runtime clients cannot call the approval method.
|
||||
- Older runtimes without `browser.certificate-trust.v1` never show an enabled
|
||||
proceed action.
|
||||
- Runtime snapshots carry live load errors and certificate failure state,
|
||||
publish immediately on changes, and do not add certificate challenges to
|
||||
persisted session schemas.
|
||||
- A snapshot that omits or clears a challenge removes the mirrored proceed
|
||||
action rather than leaving a stale one behind.
|
||||
- Two runtime environments with identical page IDs cannot approve each other's
|
||||
challenge.
|
||||
|
||||
### Renderer tests
|
||||
|
||||
- `-202` renders certificate-specific copy and no server-running hint.
|
||||
- A live certificate challenge still wins when Chromium's error-page fallback
|
||||
produced diagnostic code `-1`.
|
||||
- A live eligible challenge renders `Proceed Anyway (Unsafe)`.
|
||||
- A restored `-202` load error without a live challenge remains accurate but
|
||||
cannot proceed.
|
||||
- Other certificate errors render accurate copy without v1 bypass.
|
||||
- Local HTTP failures render `Try HTTPS`; HTTPS and non-loopback failures do not.
|
||||
- Starting a new navigation clears stale certificate state.
|
||||
- Success clears the overlay.
|
||||
- Proceed disables immediately, delays the spinner, and preserves its width.
|
||||
- Expired/changed responses show the inline recovery message.
|
||||
- Keyboard focus and `aria-live` behavior do not regress the address bar.
|
||||
- `Copy Address` remains available, interactive content is not blanket-dimmed,
|
||||
and all new strings are present in the localization catalog.
|
||||
|
||||
### Electron integration
|
||||
|
||||
Run a local HTTPS server with a generated certificate whose SAN includes
|
||||
`localhost` but whose authority is not trusted.
|
||||
|
||||
1. `https://localhost:<port>` fails with `-202` and shows certificate copy.
|
||||
2. `Proceed Anyway (Unsafe)` reloads and renders the page.
|
||||
3. HTTPS assets and a WSS echo/HMR endpoint on the same host and port load after
|
||||
approval; a different-port asset remains blocked.
|
||||
4. Reload in the same tab remains allowed without broadening the grant.
|
||||
5. A second tab using the same session profile, origin, and certificate remains
|
||||
blocked. This is a release-blocking assertion against the pinned Electron
|
||||
binary, not a mocked controller test.
|
||||
6. Navigating away before clicking makes the old challenge unusable and does
|
||||
not pull the tab back to the failed URL.
|
||||
7. Replacing the certificate keeps the original tab blocked and offers no
|
||||
second bypass until the browser-owning process restarts.
|
||||
8. Closing and recreating the browser surface removes the grant.
|
||||
9. Restarting Orca removes the grant.
|
||||
10. A system-trusted local certificate loads without an interstitial.
|
||||
11. A public/non-loopback untrusted origin has no proceed action.
|
||||
12. A hostname/SAN mismatch shows certificate copy but no proceed action.
|
||||
13. `localhost:<port>` first attempts HTTP and its failure offers `Try HTTPS`.
|
||||
|
||||
Repeat the ownership-sensitive cases for:
|
||||
|
||||
- a local desktop webview;
|
||||
- an SSH local port forward opened in the desktop browser, using a certificate
|
||||
whose SAN matches the browser-facing forwarded hostname; and
|
||||
- a headless/offscreen browser page owned by `orca serve` under Linux/Xvfb.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Add the shared scheme-less local classifier, strict certificate-host
|
||||
predicate, secure-endpoint canonicalizer, and HTTPS-recovery function with
|
||||
focused tests.
|
||||
2. Consolidate address-bar and tab-create local classification without changing
|
||||
current scheme defaults.
|
||||
3. Add BrowserManager ownership/registration/retirement hooks, early
|
||||
`did-attach` registration with a `dom-ready` fallback, and offscreen load
|
||||
failure observation.
|
||||
4. Add shared certificate failure/result types and the main-process trust
|
||||
controller, including navigation-sequence invalidation and the app-level
|
||||
listener. Its certificate-event handling resolves ownership through the
|
||||
BrowserManager hooks from step 3, so those must land first.
|
||||
5. Add local IPC, preload APIs, and transient renderer state.
|
||||
6. Add the certificate-specific classification and copy as new exported
|
||||
functions/branches in the existing `browser-notices.ts` (which already owns
|
||||
load-failure copy via `formatLoadFailureDescription` /
|
||||
`formatLoadFailureRecoveryHint`), and extract only the overlay JSX into a
|
||||
focused `browser-load-failure-overlay.tsx` module instead of growing the
|
||||
already grandfathered `BrowserPane.tsx`; do not add or extend a max-lines
|
||||
disable.
|
||||
7. Add certificate-specific and `Try HTTPS` UI, preserve existing recovery
|
||||
actions, and update the localization catalog.
|
||||
8. Add the offscreen/runtime capability, live snapshot fields and publication,
|
||||
and approval RPC.
|
||||
9. Add unit, IPC, renderer, runtime, and real-Electron integration coverage.
|
||||
10. Run formatting, typecheck, lint, localization verification, targeted tests,
|
||||
and cross-platform manual validation.
|
||||
|
||||
## Rollout
|
||||
|
||||
No feature flag is required for accurate error copy or `Try HTTPS` because both
|
||||
paths preserve strict certificate verification.
|
||||
|
||||
The proceed path should ship only after desktop and offscreen ownership tests
|
||||
pass. If remote runtime support cannot land in the same release, gate the
|
||||
button on `browser.certificate-trust.v1`; local desktop support may ship while
|
||||
older/remote owners retain accurate error copy and trusted-CA guidance.
|
||||
|
||||
Do not close issue #8454 until a packaged build has been validated against a
|
||||
real self-signed local HTTPS server. Unit mocks alone do not establish Electron
|
||||
certificate-event ordering.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
- Follow `docs/STYLEGUIDE.md` and the adjacent browser failure overlay.
|
||||
- Use only existing semantic tokens, shadcn buttons, and lucide icons.
|
||||
- Keep the overlay quiet and monochrome; certificate failure must not introduce
|
||||
an amber warning treatment.
|
||||
- Copy must distinguish connection, DNS, and certificate failures without
|
||||
claiming more than the Chromium error proves.
|
||||
- Buttons must remain on one line at supported pane widths or wrap as one
|
||||
deliberate group without overlap.
|
||||
- Validate light/dark mode, macOS/Windows/Linux font metrics, and 200 ms remote
|
||||
latency.
|
||||
- Capture review evidence for local HTTP failure, eligible certificate failure,
|
||||
ineligible certificate failure, accepted certificate, and the same browser
|
||||
chrome after recovery.
|
||||
|
||||
## Release Gates and Open Risks
|
||||
|
||||
- The pinned Electron binary must prove that the session request gate blocks a
|
||||
second tab, sibling HTTPS subresources/WSS, and a second port even when
|
||||
Chromium does not re-emit `certificate-error`. If any isolation assertion
|
||||
fails, omit the proceed path or redesign it around a dedicated ephemeral
|
||||
partition; do not silently widen the grant.
|
||||
- Same-endpoint WSS and fresh HTTPS subresource connections must work after
|
||||
approval. A main document that renders while HMR/API traffic remains blocked
|
||||
does not fix the local-development use case.
|
||||
- A remote/headless certificate failure must reach the client as both accurate
|
||||
load-error copy and a live challenge. Logging a rejected offscreen `loadURL`
|
||||
is not sufficient.
|
||||
- Stale approval must be rejected in the browser-owning process after any new
|
||||
main-frame navigation, including when renderer cleanup or a remote snapshot
|
||||
is delayed.
|
||||
- SSH-forward validation must use a certificate whose SAN matches the
|
||||
browser-facing hostname. Authority bypass must not mask a hostname mismatch.
|
||||
- The app-level listener and real-certificate tests must pass on macOS, Windows,
|
||||
and Linux/Xvfb. Platform trust-store differences do not justify enabling a
|
||||
broader error allowlist.
|
||||
|
||||
## Review Decisions and Follow-ups
|
||||
|
||||
### From 2026-07-12 review
|
||||
|
||||
- The integrated browser is part of the development workflow (including
|
||||
automation, annotations, and remote ownership), so `Open Externally` and trusted
|
||||
local-certificate guidance remain safer alternatives but do not replace an
|
||||
explicit in-browser decision.
|
||||
- V1 intentionally limits approval to `ERR_CERT_AUTHORITY_INVALID (-202)`.
|
||||
Hostname/SAN, date, revoked, and malformed-certificate failures remain blocked;
|
||||
broadening that set requires a separate security review and real-certificate
|
||||
tests.
|
||||
- Raw error details remain available to existing diagnostics rather than adding a
|
||||
new disclosure affordance to the quiet recovery overlay.
|
||||
@@ -1,146 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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?"
|
||||
@@ -1,182 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,245 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,105 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,972 +0,0 @@
|
||||
# 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 "{entry.address}"
|
||||
</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.
|
||||
@@ -1,131 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Daemon lifecycle retirement for issue #9138
|
||||
|
||||
## Status
|
||||
|
||||
Implemented and locally validated for PR #9277.
|
||||
|
||||
The earlier full ownership/audit prototype is preserved at:
|
||||
|
||||
- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot`
|
||||
- commit: `7c915909bd26670b8af36aa683cff85077395dd1`
|
||||
- GitHub: <https://github.com/stablyai/orca/tree/Jinwoo-H/issue-9138-full-ownership-audit-snapshot>
|
||||
|
||||
That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup
|
||||
audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future
|
||||
legacy enforcement. None of those systems ship in this PR.
|
||||
|
||||
Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was
|
||||
in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation
|
||||
alongside v22 and older versions.
|
||||
|
||||
## Inputs and scope decision
|
||||
|
||||
Issue: <https://github.com/stablyai/orca/issues/9138>
|
||||
|
||||
Reviewed design comment by AmethystLiang:
|
||||
<https://github.com/stablyai/orca/issues/9138#issuecomment-5006601124>
|
||||
|
||||
The reviewed comment correctly identifies two different problems:
|
||||
|
||||
1. current-generation daemons have no daemon-owned empty lifecycle;
|
||||
2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act.
|
||||
|
||||
This PR solves only the first problem. The second problem is much larger, adds steady-state
|
||||
persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement.
|
||||
Keeping it out makes the user-visible fix small enough to review and benchmark independently.
|
||||
|
||||
The narrow implementation retains these important rules from AmethystLiang's design:
|
||||
|
||||
- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it;
|
||||
- live sessions always win over cleanup;
|
||||
- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence;
|
||||
- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation;
|
||||
- the daemon, not app startup, makes the atomic empty decision;
|
||||
- pre-v24 daemons stay reattachable and are not automatically shut down;
|
||||
- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged.
|
||||
|
||||
The narrow implementation changes one policy from the original comment: it does not use a blanket
|
||||
30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle
|
||||
event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime
|
||||
inactivity heuristics and periodic ownership work.
|
||||
|
||||
## User-visible behavior
|
||||
|
||||
```text
|
||||
Before
|
||||
|
||||
app disconnects ──> daemon stays forever
|
||||
├── live sessions stay (wanted)
|
||||
└── zero sessions also stay (leak)
|
||||
|
||||
After
|
||||
|
||||
clean app detach ──> daemon atomically checks itself
|
||||
├── any live session/work/client ──> stay alive
|
||||
└── exactly empty ──> exit immediately
|
||||
|
||||
unexpected drop ──> daemon atomically checks itself
|
||||
├── live session/work/connection ──> stay alive
|
||||
└── exactly empty ──> exit immediately
|
||||
|
||||
v23 and older ──> existing reattach behavior; no automatic retirement
|
||||
```
|
||||
|
||||
An end user with live terminals should notice no change. An end user who quits with no daemon-backed
|
||||
terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket,
|
||||
live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later
|
||||
app restart launches a fresh daemon instead of reusing an empty process.
|
||||
|
||||
## Protocol and lifecycle design
|
||||
|
||||
### Endpoint identity
|
||||
|
||||
Protocol v24 hello responses include:
|
||||
|
||||
```ts
|
||||
type DaemonEndpointIdentity = {
|
||||
pid: number
|
||||
startedAtMs: number
|
||||
launchNonce: string
|
||||
}
|
||||
```
|
||||
|
||||
The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes
|
||||
the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client
|
||||
sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps
|
||||
the previous identity-free handshake.
|
||||
|
||||
PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a
|
||||
write failure terminates the new child and fails launch instead of leaving an untracked daemon.
|
||||
|
||||
### Clean detach
|
||||
|
||||
At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a
|
||||
v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared
|
||||
250 ms budget. v23 and older adapters skip it.
|
||||
|
||||
Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is
|
||||
opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still
|
||||
receive clean retirement on quit. If startup fallback has already won, the late daemon is not
|
||||
installed; it instead receives the same bounded retirement attempt, which an adopted live session
|
||||
will reject.
|
||||
|
||||
The daemon accepts retirement only when, in one event-loop turn:
|
||||
|
||||
- the requesting authenticated client has both control and stream sockets;
|
||||
- it is the only authenticated client;
|
||||
- every accepted transport belongs to that client;
|
||||
- no `createOrAttach` operation is in flight;
|
||||
- the terminal host has zero sessions.
|
||||
|
||||
When all conditions hold, the daemon synchronously closes the listening server before replying. That
|
||||
is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then
|
||||
runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to
|
||||
the same event-driven empty check when the authenticated sockets close.
|
||||
|
||||
### Initial adoption watchdog
|
||||
|
||||
A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated
|
||||
client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the
|
||||
normal launch handoff before the parent could connect; without a bound, a parent crash during that
|
||||
handoff would orphan the new daemon forever.
|
||||
|
||||
A complete pair permanently cancels this watchdog, and terminal admission requires that complete
|
||||
pair. Raw and partial transports pause it without extending its original deadline. It is never
|
||||
rearmed after adoption and is not a terminal inactivity or crash-reconnect timer.
|
||||
|
||||
### Unexpected disconnect
|
||||
|
||||
When the last client that completed both authenticated sockets loses its control connection, the
|
||||
daemon records an event-driven retirement request with no wall-clock grace.
|
||||
|
||||
- A complete authenticated reconnect cancels the request if existing work or a transport kept the
|
||||
daemon alive long enough to reconnect.
|
||||
- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request
|
||||
before admission can begin.
|
||||
- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but
|
||||
cannot erase evidence that the last fully connected app left.
|
||||
- Replacing a client ID first records the old full connection's loss; completing the replacement
|
||||
stream cancels that evidence, while an incomplete replacement only blocks retirement.
|
||||
- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately
|
||||
rechecks every guard and retires only if it is then exactly empty.
|
||||
|
||||
The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the
|
||||
token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is
|
||||
not broadened into destructive or respawn authority.
|
||||
|
||||
### Artifact cleanup
|
||||
|
||||
The daemon removes only artifacts it can claim as its own:
|
||||
|
||||
- token contents must match the daemon's in-memory token;
|
||||
- PID and launch nonce must match the daemon process and launch nonce;
|
||||
- cleanup first renames the canonical entry to a unique claim, validates that claim, and never
|
||||
overwrites or unlinks a replacement installed at the canonical path.
|
||||
|
||||
Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24
|
||||
PID/socket artifacts. Legacy cleanup behavior is unchanged.
|
||||
|
||||
## Performance design
|
||||
|
||||
There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or
|
||||
steady-state persistence write in this PR.
|
||||
|
||||
The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one
|
||||
two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its
|
||||
connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection
|
||||
attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect
|
||||
bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one
|
||||
unref'ed startup watchdog, which is canceled permanently on adoption.
|
||||
|
||||
Validation compares current main and the branch for:
|
||||
|
||||
- daemon connect plus two-socket hello latency;
|
||||
- repeated `listSessions` RPC latency;
|
||||
- terminal echo/stream throughput through a real socket daemon;
|
||||
- clean empty-detach latency;
|
||||
- event-loop delay under repeated RPC/stream work;
|
||||
- idle CPU/RSS and timer count where observable.
|
||||
|
||||
The acceptance target is no statistically meaningful terminal throughput regression and no new
|
||||
steady-state disk writes. Results are recorded in the PR body.
|
||||
|
||||
### Local performance regression screen
|
||||
|
||||
The final local host was not quiet enough for publication-grade absolute numbers: load averages were
|
||||
17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired
|
||||
same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24
|
||||
branch samples; medians across sample medians were:
|
||||
|
||||
| Measure | `main` v23 | branch v24 |
|
||||
| -------------------- | ---------: | ---------: |
|
||||
| two-socket connect | 1.38 ms | 1.32 ms |
|
||||
| `listSessions` RPC | 0.0366 ms | 0.0374 ms |
|
||||
| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s |
|
||||
|
||||
All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and
|
||||
event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression
|
||||
screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work
|
||||
runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY
|
||||
byte and no steady-state persistence or polling.
|
||||
|
||||
## Verification and validation
|
||||
|
||||
### Focused unit and integration tests
|
||||
|
||||
- v24 requires valid matching endpoint identity on both sockets.
|
||||
- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols.
|
||||
- production launch passes PID path and nonce and writes the exact readiness identity.
|
||||
- incomplete readiness identity or failed exclusive PID publication kills and rejects the child.
|
||||
- clean empty detach exits immediately.
|
||||
- a never-used current adapter connects and retires cleanly on quit.
|
||||
- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old
|
||||
deadline.
|
||||
- a live session, another client, raw transport, or in-flight admission rejects clean retirement.
|
||||
- a control-only overlapping client blocks but cannot erase the last full-client retirement request.
|
||||
- a same-client-ID control replacement cannot erase the prior full connection's retirement request.
|
||||
- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed
|
||||
request.
|
||||
- startup fail-open performs bounded empty retirement without installing a late provider.
|
||||
- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later.
|
||||
- the synchronous listener fence rejects post-fence connections as retryable.
|
||||
- an unexpected empty disconnect retires immediately without a runtime inactivity timer.
|
||||
- a real reconnect cancels pending retirement while live work keeps the daemon available.
|
||||
- raw and health probes block but cannot erase pending retirement.
|
||||
- final session exit triggers an immediate guarded retirement check.
|
||||
- token/PID cleanup preserves malformed, stale, and replacement artifacts.
|
||||
- authenticated token disappearance performs one coalesced respawn; initial token absence does not.
|
||||
|
||||
### Process/E2E tests
|
||||
|
||||
- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts;
|
||||
- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts
|
||||
exit;
|
||||
- prove a live session rejects retirement and remains reattachable;
|
||||
- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable;
|
||||
- never target a production runtime directory or signal a process not created by the fixture.
|
||||
|
||||
### Repository validation
|
||||
|
||||
- Node typecheck;
|
||||
- oxlint and repository max-lines policy;
|
||||
- focused daemon, adapter, launcher, restart, and legacy-routing suites;
|
||||
- full daemon test suite;
|
||||
- desktop and web production builds;
|
||||
- `git diff --check` and review of every changed file against `origin/main`;
|
||||
- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`;
|
||||
- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication.
|
||||
|
||||
Final local results on macOS arm64 after the event-driven policy revision:
|
||||
|
||||
- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped;
|
||||
- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned
|
||||
artifacts retired immediately after its final authenticated client disconnected;
|
||||
- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed;
|
||||
- full desktop, web, and native production build passed with existing build warnings;
|
||||
- three independent post-revision review tracks covering architecture/state machines,
|
||||
ownership/adoption, and process lifecycle ended clean after actionable findings were fixed.
|
||||
|
||||
The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed
|
||||
successfully, and repository CI remains responsible for the supported Node/platform matrix.
|
||||
|
||||
## Rollout and rollback
|
||||
|
||||
This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves
|
||||
v24 as another legacy generation and does not give an older app authority to shut it down.
|
||||
|
||||
If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated
|
||||
ownership/audit prototype remains recoverable from the archived branch and commit above; it should
|
||||
return only as a separately reviewed, benchmarked follow-up.
|
||||
@@ -0,0 +1,523 @@
|
||||
# Windows ConPTY Startup Query and Focus Authority Design
|
||||
|
||||
Date: 2026-07-19
|
||||
|
||||
Status: Final decision; implementation pending
|
||||
|
||||
## Problem
|
||||
|
||||
On native Windows ConPTY sessions, a new agent can sometimes show terminal protocol bytes as user
|
||||
text:
|
||||
|
||||
```text
|
||||
]10;rgb:2e2e/3434/3434\]11;rgb:ffff/ffff/ffff\
|
||||
```
|
||||
|
||||
An independent symptom can prefix user input with `[I`, the printable tail of the standard terminal
|
||||
focus-in report `CSI I`.
|
||||
|
||||
The OSC text is an Orca-generated reply to the agent's OSC 10/11 foreground/background query. The
|
||||
agent can issue that query before a daemon-backed `spawn()` resolves to the renderer, and waits only
|
||||
about 100 ms for the answer. Orca therefore has a short-lived main-side startup responder in
|
||||
addition to the normal renderer/model query authorities. On affected ConPTY timing, the reply sent
|
||||
to the PTY is returned as cooked output with its ESC bytes removed. The current ingestion order
|
||||
records that cooked echo in the authoritative runtime model before any renderer-bound filtering.
|
||||
|
||||
The focus symptom must not be treated as the same bug without evidence. ConPTY deliberately emits
|
||||
DECSET 1004 (focus reporting) and DECSET 9001 (Win32 input mode) at startup. A direct native
|
||||
PowerShell ConPTY capture consistently produced the bootstrap pair, while an injected `CSI I` was
|
||||
consumed as a focus event and did not render as `[I`. The bootstrap is valid transport protocol;
|
||||
the failure requires an additional agent, timing, input-mode, or replay condition.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The startup OSC responder currently runs in Electron main only after runtime ingestion
|
||||
([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public
|
||||
data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while
|
||||
daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out
|
||||
data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A
|
||||
renderer-only filter can therefore hide the symptom without removing it from the authoritative
|
||||
model, daemon history, snapshots, or remote delivery.
|
||||
|
||||
The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer
|
||||
downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met
|
||||
that evidentiary bar, so this design fixes the proven OSC path and adds the real focus-path harness
|
||||
without pre-approving a speculative focus workaround.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
node-pty / remote relay PTY
|
||||
-> shell-ready marker scan
|
||||
-> source-owned serialized ingress transaction
|
||||
-> consume an early OSC query and write one canonical reply
|
||||
-> match or losslessly release the measured native-Windows echo projection
|
||||
-> authoritative emulator + persistence/history
|
||||
-> runtime side effects and mobile/remote stream
|
||||
-> renderer delivery or hidden-drop decision
|
||||
-> live terminal / snapshot restore
|
||||
```
|
||||
|
||||
Raw sequence spans travel beside cleaned strings through every downstream hop. Empty transformed
|
||||
spans advance sequence and flow-control state without writing bytes into an emulator or view.
|
||||
|
||||
## Rejected Prototype
|
||||
|
||||
The current working-tree prototype is not the implementation of this design:
|
||||
|
||||
- It removes ConPTY's leading `?1004h` in the renderer. This changes valid native-console focus
|
||||
semantics for every Windows PTY, including programs unrelated to agents.
|
||||
- It filters OSC echo text only after runtime/model ingestion. The live pane can look clean while a
|
||||
hidden restore, reconnect, mobile view, or CLI snapshot still contains the garbage.
|
||||
- Its echo state activates only after both color slots are answered. An echo of the first response
|
||||
can escape if it arrives before the second query.
|
||||
- Its partial-match buffer can be lost on timeout, and its substring search can remove a later
|
||||
legitimate string that happens to equal a reply.
|
||||
|
||||
Implementation starts by removing the prototype's bootstrap output filter and renderer-only cooked
|
||||
echo filtering. Existing unrelated Windows focus-idle safeguards remain.
|
||||
|
||||
## Existing Contracts Preserved
|
||||
|
||||
This design extends, rather than replaces:
|
||||
|
||||
- [`terminal-model-view-contract.md`](../terminal-model-view-contract.md), especially singular query
|
||||
authority, authoritative model restore, and raw sequence ordering;
|
||||
- [`terminal-query-authority.md`](../terminal-query-authority.md), especially delivered-versus-
|
||||
hidden-dropped ownership and replay silence;
|
||||
- [`terminal-side-effect-authority.md`](../terminal-side-effect-authority.md), especially parsing PTY
|
||||
bytes once in main before renderer delivery.
|
||||
|
||||
The startup responder is a bounded exception needed before normal delivered/dropped ownership can be
|
||||
established. It must join the same ingestion decision, not operate as an unrelated renderer scrub.
|
||||
|
||||
## Decision 1: Source-Owned PTY Ingress Transaction
|
||||
|
||||
Sanitization must run on the host that owns the PTY, before that host mutates any authoritative
|
||||
model or persistence. Electron main is too late for daemon sessions: `Session` has already advanced
|
||||
its sequence, written its emulator, recorded pending output, and broadcast the data before main's
|
||||
provider callback runs.
|
||||
|
||||
The transaction therefore has three installations of the same shared state machine:
|
||||
|
||||
- in `LocalPtyProvider`, before its configured runtime callback and data listeners;
|
||||
- in daemon `Session`, before `outputSequence`, emulator writes, pending/checkpoint records, and
|
||||
attached-client fanout;
|
||||
- in relay `PtyHandler`, before relay replay/history buffering and `pty.data` fanout. Main's
|
||||
`SshPtyProvider` is only an RPC proxy and never sanitizes relay output.
|
||||
|
||||
Electron main receives already-classified daemon data. A remote-runtime desktop client does not
|
||||
reinterpret the stream; the remote Orca host owns its transaction. WSL sessions follow their actual
|
||||
provider owner but never enable the native-Windows compatibility projection.
|
||||
|
||||
Fresh-session creation carries startup-transaction intent atomically. Daemon `createOrAttach`
|
||||
receives the recognized-agent intent, execution-host kind, deadline, and validated renderer-pushed
|
||||
color attributes. It first decides fresh versus reattach; only a fresh result constructs the
|
||||
transaction before releasing the subprocess's already-buffered early output. A reattach discards
|
||||
the intent without arming state. Cancellation, spawn failure, and teardown clear intent before any
|
||||
PTY id can be reused.
|
||||
|
||||
Local creation installs the transaction before subscribing to node-pty output. Relay `pty.spawn`
|
||||
carries the same fresh-session intent and installs it before releasing relay PTY output; relay
|
||||
`pty.attach` never accepts it. If any owner cannot establish this ordering, the early responder is
|
||||
not armed and normal query authority handles the query.
|
||||
|
||||
Increment `DAEMON_PROTOCOL_VERSION` for the new create/wire shape and add a named numeric-threshold
|
||||
predicate in the daemon adapter/router. Current exact-version hello behavior remains; supported
|
||||
legacy versions route through their existing adapters and fail the predicate. The SSH relay gets a
|
||||
separate versioned spawn/data capability because daemon protocol support says nothing about relay
|
||||
support.
|
||||
|
||||
An old daemon or relay keeps the legacy behavior and is never treated as having sanitized snapshots
|
||||
or history. Main must not apply a second best-effort scrub to legacy output. New sessions receive
|
||||
the invariant only after the owning daemon/relay is upgraded or restarted; an already-attached
|
||||
legacy session remains explicitly outside it.
|
||||
|
||||
### Composition with shell-ready preprocessing
|
||||
|
||||
The ingress sequence domain begins after the existing shell-ready scanner. That scanner may hold
|
||||
transport bytes and removes its private ready marker; marker bytes have never belonged to terminal
|
||||
model or delivery sequence space and continue not to count. Its released non-marker bytes enter the
|
||||
new ingress transaction in original order.
|
||||
|
||||
Snapshot and teardown barriers drain in pipeline order: the shell-ready scanner first releases its
|
||||
non-marker buffer, then the ingress transaction resolves or abandons its candidate, then the model,
|
||||
persistence, and views observe the resulting emissions. No later stage may introduce an unmetered
|
||||
string transform.
|
||||
|
||||
### Raw sequence and emission contract
|
||||
|
||||
The state machine accepts source chunks with an explicit raw half-open range and returns zero or
|
||||
more ordered emissions:
|
||||
|
||||
```ts
|
||||
type PtyIngressSourceChunk = {
|
||||
data: string
|
||||
rawStartSeq: number
|
||||
rawEndSeq: number
|
||||
}
|
||||
|
||||
type PtyIngressEmission = {
|
||||
data: string
|
||||
rawStartSeq: number
|
||||
rawEndSeq: number
|
||||
transformed: boolean
|
||||
}
|
||||
```
|
||||
|
||||
The raw range is the post-shell-ready ingress sequence domain and remains contiguous even when
|
||||
`data` is shorter after sanitization. The ordered emissions partition accepted source ranges without
|
||||
overlap. Producer flow-control acknowledgement follows those emitted raw spans and is counted once
|
||||
from `rawEndSeq - rawStartSeq`, never from emitted string length. A held prefix delays its ACK; the
|
||||
buffer is strictly bounded and the serialized queue cannot acknowledge later output ahead of it.
|
||||
|
||||
The ingress raw high-water advances when a source chunk is accepted. Model-applied and
|
||||
view-delivered high-waters advance only through ordered emissions, including empty transformed
|
||||
emissions that consume a raw range. Runtime/mobile listener metadata carries
|
||||
`rawLength = rawEndSeq - rawStartSeq`, not `data.length`. A restore that overlaps a transformed span
|
||||
cannot slice cleaned text by raw offset and must request a fresh authoritative snapshot, matching
|
||||
the existing `rawLength !== data.length` safety rule.
|
||||
|
||||
This metadata is end-to-end, not local to the state machine. Implementation changes the complete
|
||||
path:
|
||||
|
||||
1. daemon `Session` or relay `PtyHandler` emission callback;
|
||||
2. daemon/relay batching, coalescing, splitting, and wire notification;
|
||||
3. daemon adapter or `SshPtyProvider` decode;
|
||||
4. `OrcaRuntimeService` model and runtime/mobile listeners;
|
||||
5. main renderer batching, pending/drop accounting, and preload payload;
|
||||
6. renderer reconciliation and remote-runtime binary/live frame decoding.
|
||||
|
||||
The wire representation carries `data`, `seq = rawEndSeq`, `rawLength`, and `transformed`. A
|
||||
span-only emission has empty `data` but non-zero `rawLength`; no layer may drop it before advancing
|
||||
its high-water and producer ACK. It is not written to an emulator or xterm. Coalescing is permitted
|
||||
only for contiguous spans and sums raw lengths independently of string lengths. A transformed
|
||||
emission is indivisible because there is no byte-for-byte raw-to-clean offset; splitters must flush
|
||||
it as its own frame or request snapshot reconciliation instead of slicing it.
|
||||
|
||||
If a snapshot is requested while a partial candidate is held, the transaction first abandons that
|
||||
candidate and releases its bytes unchanged as an ordered emission. The authoritative emulator and
|
||||
snapshot sequence therefore describe the same raw high-water.
|
||||
|
||||
### Serialization and teardown
|
||||
|
||||
Each PTY has a non-reentrant serialized ingress queue. A provider write may synchronously produce a
|
||||
nested callback, but that callback is appended after the current source chunk rather than delivered
|
||||
ahead of its remaining bytes. Timeout releases and snapshot barriers enter the same queue.
|
||||
|
||||
On exit, the queue releases all buffered bytes, applies those emissions to the authoritative model
|
||||
and persistence, and fans them out before `onPtyExit`, `pty:exit`, or PTY state cleanup. Relay
|
||||
disposal must flush both ingress prefixes and its existing pending output batches before clearing
|
||||
them or killing PTYs, matching the natural-exit flush-before-`pty.exit` order. No drain may recreate
|
||||
state after teardown. Ordinary chunks pass through as one unchanged emission.
|
||||
|
||||
## Decision 2: Startup OSC Queries Are Consumed Authoritatively
|
||||
|
||||
The early OSC 10/11 responder remains because removing it would regress daemon-hosted agent startup
|
||||
and the agent's short color-query timeout. Its implementation moves into the source-owned ingress
|
||||
transaction.
|
||||
|
||||
When registered for an agent spawn, it:
|
||||
|
||||
1. recognizes exact OSC 10/11 query grammar across provider chunks;
|
||||
2. builds replies from validated renderer-pushed foreground/background attributes;
|
||||
3. emits the canonical ST-terminated, 16-bit-channel reply used by renderer/model query authority,
|
||||
regardless of whether the query ended with BEL or ST;
|
||||
4. records each reply transaction before writing it to the provider;
|
||||
5. consumes the answered query from the authoritative model and view emissions so neither the
|
||||
hidden model nor a delivered renderer can answer it a second time;
|
||||
6. begins echo recognition as soon as each individual reply is written.
|
||||
|
||||
If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged
|
||||
to normal query authority. A reattach never registers startup response state, matching current
|
||||
behavior.
|
||||
|
||||
### Exact authority transfer
|
||||
|
||||
Startup query interception opens only for a fresh session whose atomic creation installed the
|
||||
transaction before buffered output release. It closes at the first of:
|
||||
|
||||
- both OSC 10 and OSC 11 slots have been answered;
|
||||
- the startup deadline expires;
|
||||
- main sends an ordered authority-close control after either the consuming-view handshake or the
|
||||
hidden-runtime ownership mark is established;
|
||||
- the spawn fails, is cancelled, reattaches, or exits.
|
||||
|
||||
Closing query interception does not discard already-written reply candidates. Echo recognition has
|
||||
its own bounded lifetime and may finish or drain after normal authority takes over. A close and a
|
||||
provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to
|
||||
a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control
|
||||
method, and the source owner acknowledges the applied ingress sequence. Queries before that ordered
|
||||
boundary are either consumed at source or removed from emissions; queries after it pass unchanged
|
||||
to the normal delivered/hidden decision. Each query therefore belongs to exactly one authority.
|
||||
|
||||
The regular authority rules continue after the bounded startup window:
|
||||
|
||||
- delivered live bytes are answered by the live view;
|
||||
- hidden-dropped live bytes are answered by the runtime model;
|
||||
- replayed, seeded, and snapshot bytes are answered by nobody;
|
||||
- the daemon's persistence emulator never writes replies.
|
||||
|
||||
Implementation must amend `terminal-model-view-contract.md` and `terminal-query-authority.md` to
|
||||
name this source-owner startup authority, its opening/closing events, and its no-replay rule. It is a
|
||||
real third responder class, not an undocumented exception.
|
||||
|
||||
## Decision 3: Matched Echo Suppression Is Lossless and Pre-Model
|
||||
|
||||
Only native Windows ConPTY agent spawns that pass the deterministic provider harness enable a
|
||||
compatibility projection for replies written by the startup transaction. WSL and POSIX SSH PTYs do
|
||||
not. A remote-runtime PTY can enable it only on its owning Windows host under the same evidence and
|
||||
capability gate.
|
||||
|
||||
The harness records the exact projection ConPTY returns for the canonical ST reply, including its
|
||||
chunking and any console transformation. The implementation must not assume that the projection is
|
||||
always merely "remove ESC", and BEL or other reply forms are not added without separate evidence.
|
||||
For every written reply, the transaction records that exact expected projection. Recognition is:
|
||||
|
||||
- FIFO in reply-write order;
|
||||
- anchored at the next possible output position, not an unbounded substring search;
|
||||
- active immediately for each reply instead of waiting for both color slots;
|
||||
- bounded by the startup deadline and maximum reply length;
|
||||
- streaming across chunk boundaries.
|
||||
|
||||
If incoming bytes diverge from the expected projection, all buffered bytes are released unchanged
|
||||
and that candidate is abandoned. If the deadline expires or the PTY is cleared while a prefix is
|
||||
buffered, the prefix is released through the serialized ingestion queue; it is never discarded. A
|
||||
match advances the raw span but emits no bytes to the model, persistence, or views.
|
||||
|
||||
Projection matching cannot prove provenance. An application can print the same projected text at
|
||||
the candidate position, causing a false-positive removal while the later real echo remains visible.
|
||||
This is the central drawback of the workaround. It is accepted only if the real provider harness
|
||||
shows a stable, immediate projection inside the narrow registered-agent startup window; otherwise
|
||||
Orca disables the projection and keeps the visible output rather than risking deletion.
|
||||
|
||||
When the projection is enabled, the exact-collision behavior is explicit: the first identical
|
||||
anchored candidate is removed and a later real echo is allowed through. The test fixture must assert
|
||||
that result. This accepts a narrowly bounded false-positive risk instead of pretending provenance
|
||||
is knowable; the release gate must document the observed timing window and justify that tradeoff.
|
||||
Unknown, delayed, or interleaved transformations pass through visibly.
|
||||
|
||||
## Decision 4: Preserve ConPTY Focus Protocol
|
||||
|
||||
Orca must deliver the ConPTY bootstrap `?1004h`/`?9001h` to live terminal emulators unchanged. It
|
||||
must not remove, reorder, or fabricate transport bootstrap modes.
|
||||
|
||||
The `[I` investigation gets a deterministic harness before a behavior change. The harness must
|
||||
exercise the actual renderer focus callback and provider write path, not only inject `CSI I`
|
||||
directly into node-pty. It records:
|
||||
|
||||
- raw provider output and its order;
|
||||
- whether the focus report came from live ConPTY bootstrap state, an application-owned DECSET 1004,
|
||||
or replayed snapshot state;
|
||||
- the exact bytes written to the provider;
|
||||
- the exact bytes returned by the provider and stored in the model;
|
||||
- agent lifecycle state when the report was emitted.
|
||||
|
||||
The implementation gate is strict:
|
||||
|
||||
- If stale snapshot modes cause the report, fix snapshot rehydration. Transport bootstrap focus mode
|
||||
is not persisted as application ownership.
|
||||
- If a live agent receives transport focus before it owns terminal focus reporting, record that
|
||||
evidence without shipping a suppression heuristic from this document.
|
||||
- If provider input/output transformation corrupts a correctly owned focus report, fix or sanitize
|
||||
that transformation at the same pre-model ingress boundary used for OSC replies.
|
||||
|
||||
No global `?1004h` filter ships under any outcome.
|
||||
|
||||
If the harness proves a focus-ownership race, a follow-up design is required before implementation.
|
||||
That design must define the output-to-input ownership signal, distinguish xterm focus events from
|
||||
identical typed/pasted/programmatic bytes, specify startup transitions and deadlines, define any
|
||||
snapshot/wire metadata, and cover the separate explicit reattach-focus write path. This document
|
||||
does not pre-approve an ownership state machine whose input provenance cannot yet be represented.
|
||||
|
||||
## Snapshot and Replay Rules
|
||||
|
||||
Interactive modes in a snapshot are capabilities of the live application, not proof that a new
|
||||
view should emit input immediately.
|
||||
|
||||
- Cold restore into a fresh shell keeps the existing full mode reset.
|
||||
- Reattach to a live agent may rehydrate application-owned focus mode, but never transport-only
|
||||
bootstrap ownership.
|
||||
- Snapshot serialization/replay must not mutate live ownership trackers.
|
||||
- A snapshot containing an OSC query or an old Orca reply never produces a provider write.
|
||||
- Model and renderer snapshots must both be free of matched cooked reply projections.
|
||||
|
||||
Any future focus ownership metadata must be explicit; it cannot be inferred from serialized
|
||||
`?1004h` text.
|
||||
|
||||
## Failure Policy
|
||||
|
||||
Safety is asymmetric:
|
||||
|
||||
- This design does not intentionally suppress a focus notification; evidence of a focus-ownership
|
||||
race triggers a follow-up design instead.
|
||||
- Passing through an unrecognized OSC echo is safer than deleting output that may belong to the
|
||||
application.
|
||||
- Missing a startup color reply falls back to the existing renderer/model authority. Duplicate
|
||||
replies are forbidden.
|
||||
- Losing buffered output on timeout or teardown is forbidden.
|
||||
|
||||
## Cross-Platform and Remote Scope
|
||||
|
||||
- Native Windows local and daemon ConPTY: startup response plus evidence-gated echo compatibility
|
||||
path; focus investigation only until a proven root cause has its own complete design.
|
||||
- WSL: normal Linux terminal semantics; no ConPTY echo or focus workaround.
|
||||
- SSH: the same ingress/query ownership ordering, but no native Windows echo projection unless the
|
||||
remote host protocol later supplies explicit equivalent evidence.
|
||||
- Remote runtime: the remote Orca host owns ingestion and must implement the same contract there;
|
||||
desktop local main does not reinterpret its stream.
|
||||
- Mobile/web views: consume the sanitized authoritative model stream and retain exactly-one query
|
||||
response authority through the existing terminal-driver election.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- A query or echo split at any byte boundary, including OSC ST split across chunks.
|
||||
- The first reply echo arriving before the second color query.
|
||||
- Unrelated output, an exact application-text collision, or a partial match before the real echo.
|
||||
- Timeout, snapshot, detach, process exit, daemon shutdown, or relay disposal while bytes are held.
|
||||
- A provider write causing a synchronous nested output callback.
|
||||
- Fresh spawn versus reattach, cancellation, PTY-id reuse, and an authority-close control racing
|
||||
with output.
|
||||
- BEL-terminated queries still receiving the canonical ST reply.
|
||||
- Empty transformed spans crossing coalescing, splitting, ACK, mobile, and remote-runtime layers.
|
||||
- Old daemon/relay protocol versions and sessions that survive an application upgrade.
|
||||
- WSL or POSIX SSH running from a Windows desktop without inheriting local ConPTY workarounds.
|
||||
- Focus gained through normal terminal input versus the separate explicit reattach-focus write path.
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Deterministic provider harness
|
||||
|
||||
- Capture the native ConPTY bootstrap across natural and forced chunk boundaries.
|
||||
- Reproduce the OSC reply echo with the real provider and an agent query fixture.
|
||||
- Exercise BEL and ST queries and assert the same canonical ST reply, plus separate OSC 10/11
|
||||
queries and combined OSC 10 `?;?`.
|
||||
- Force the first reply echo before the second query.
|
||||
- Split every byte boundary in query and echo fixtures.
|
||||
- Print the expected projection immediately before the real echo and assert that the first
|
||||
identical candidate is removed while the later echo passes through.
|
||||
- Exercise focus gain/loss through the actual xterm focus path.
|
||||
|
||||
### Ingress integration
|
||||
|
||||
- Assert local runtime ingestion and daemon emulator/pending/checkpoint persistence receive
|
||||
classified data before storing it.
|
||||
- Assert renderer and mobile delivery receive the same visible output.
|
||||
- Assert raw start/end spans advance by original provider length, producer ACK contribution is
|
||||
counted exactly once, and mobile/runtime metadata reports the raw span rather than string length.
|
||||
- Assert a partial candidate is released unchanged on mismatch, timeout, snapshot barrier, move,
|
||||
and teardown, including a prefix held from an earlier callback.
|
||||
- Assert re-entrant provider callbacks remain ordered behind the source chunk that caused the
|
||||
write.
|
||||
- Assert restore overlap across a transformed or delayed span requests a fresh snapshot rather
|
||||
than slicing cleaned text.
|
||||
- Assert `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` install the source-side seam;
|
||||
assert `SshPtyProvider` does not reinterpret relay data.
|
||||
- Assert WSL, SSH, and remote-runtime streams do not enable the native-Windows projection.
|
||||
- Assert a capable daemon sanitizes live output, snapshots, pending records, checkpoints, and cold
|
||||
restore; assert capable relay replay/history has the same property. Assert old daemon and relay
|
||||
versions are detected and never represented as sanitized.
|
||||
|
||||
### Authority and restore
|
||||
|
||||
- Assert an early-consumed query is answered once by its source owner and never by renderer or
|
||||
hidden model.
|
||||
- Assert normal delivered/dropped query authority resumes after startup state clears.
|
||||
- Assert the main snapshot, renderer snapshot, hidden reveal, reconnect, and mobile subscription do
|
||||
not contain cooked OSC text.
|
||||
- Assert replay never sends OSC or focus replies.
|
||||
- Assert ordinary native console focus behavior remains enabled.
|
||||
- Assert the focus harness captures both terminal `onData` and explicit reattach-focus writes. Any
|
||||
later ownership implementation defines its tests in the required follow-up design.
|
||||
|
||||
### End-to-end acceptance
|
||||
|
||||
On native Windows, repeatedly create, hide, reveal, and reconnect new and resumed agent sessions.
|
||||
Before typing, neither live output nor any restore path may contain `]10;rgb`/`]11;rgb`. Repeat the
|
||||
focus/blur scenarios to classify `[I`; if it reproduces through an ownership race, this work stops
|
||||
at the follow-up-design gate rather than claiming the symptom fixed. Application-owned focus
|
||||
behavior must still move the TUI caret correctly after focus and reattach. Repeat with a plain
|
||||
PowerShell terminal and a native focus-event consumer to prove no global regression.
|
||||
|
||||
Run renderer and main PTY suites, runtime snapshot/query suites, node/web typechecks, lint,
|
||||
formatting, max-lines ratchet, reliability gates, and Electron validation. SSH ingestion changes also
|
||||
require the repository's SSH end-to-end procedure.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
This is not a layout, styling, or copy change. Existing terminal rendering and focus behavior must
|
||||
look unchanged except that matched protocol garbage is absent. A passing terminal screenshot has a
|
||||
clean prompt, no clipped or duplicated startup output, no restore flash, and the existing cursor and
|
||||
focus presentation.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. A fresh native-Windows agent prompt after startup, with no OSC reply text.
|
||||
2. The same session after hide/reveal restore, still clean and without duplicated output.
|
||||
3. The session after focus, blur, and reconnect, showing the focus-harness outcome and prompt state.
|
||||
4. A plain native PowerShell terminal after focus/blur, showing unchanged adjacent behavior.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. **Correct seam and rollback.** Remove the prototype filters. Add the shared serialized ingress
|
||||
state machine, explicit raw-span emissions, and separate ACK accounting after shell-ready
|
||||
preprocessing. Install pass-through mode in `LocalPtyProvider`, daemon `Session`, and relay
|
||||
`PtyHandler` before their models, persistence/replay, and fanout.
|
||||
2. **Protocol and authority contract.** Bump the daemon protocol, add relay capability/version gates,
|
||||
carry atomic fresh-spawn intent, define legacy fallback, and amend the canonical model/query
|
||||
authority documents with exact transfer events.
|
||||
3. **Startup transaction.** Move OSC startup recognition/reply into source ingress, retain canonical
|
||||
ST replies, consume answered queries for model and views, and retain raw sequence accounting.
|
||||
4. **Windows compatibility projection.** Gate the measured projection on provider evidence; add FIFO
|
||||
anchored recognition, serialized re-entrant delivery, lossless drains, false-positive coverage,
|
||||
and authoritative daemon/model/snapshot tests.
|
||||
5. **Focus evidence.** Land the real focus-path harness. If it proves a focus-ownership race, stop
|
||||
for the required follow-up design; a direct snapshot or provider-transformation bug may be fixed
|
||||
only with a failing regression test that selects that branch.
|
||||
6. **Electron and SSH gates.** Validate visible, hidden, restored, mobile-owned, native console, WSL,
|
||||
and SSH scenarios before removing the old startup implementation.
|
||||
|
||||
Each slice must keep query authority singular. The compatibility projection does not ship without a
|
||||
model-snapshot assertion, and no focus behavior change ships before the deterministic focus harness
|
||||
fails on the old behavior and passes on the new behavior.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: limited to the proven OSC startup corruption plus deterministic focus evidence. Global
|
||||
focus filtering and an unproven ownership state machine remain out of scope.
|
||||
- Architecture/data flow: classification belongs at each PTY source owner after shell-ready
|
||||
preprocessing and before every authoritative model, persistence, replay, or delivery consumer.
|
||||
- Failure modes covered: partial/mismatched projections, false-positive collision, nested writes,
|
||||
authority races, snapshot and teardown drains, protocol-version skew, reattach, and host
|
||||
isolation.
|
||||
- Test coverage required: byte-boundary unit tests for the shared transaction; local, daemon, relay,
|
||||
runtime/mobile, restore, and legacy-protocol integration tests; real native-Windows provider and
|
||||
renderer-focus harnesses; Electron and SSH end-to-end validation.
|
||||
- Performance/blast radius: ordinary output is a pass-through emission. Buffering is bounded by one
|
||||
startup reply candidate and its deadline. Protocol and sequence metadata touch every delivery
|
||||
path, so existing high-throughput, ACK, hidden-drop, and reconnect tests are mandatory.
|
||||
- UI quality bar: terminal layout and styling are unchanged; only matched startup garbage
|
||||
disappears, without cursor/focus regressions or restore flashes.
|
||||
- Required review screenshots: the four terminal states in `Review Screenshots`.
|
||||
- Residual risks: the native projection may be too unstable to enable; exact projected application
|
||||
output can collide; `[I` may require a separately reviewed focus-ownership design.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing xterm's general query parser.
|
||||
- Filtering arbitrary escape-looking terminal output.
|
||||
- Disabling ConPTY focus or Win32 input mode globally.
|
||||
- Changing WSL, SSH, or remote-runtime terminal semantics to imitate local Windows.
|
||||
- Solving unrelated MCP startup warnings reported beside the terminal garbage.
|
||||
|
||||
## Final Invariants
|
||||
|
||||
1. Provider bytes are classified once before model ingestion and view delivery.
|
||||
2. A live query has exactly one responder; replay has none.
|
||||
3. Source-owner persistence, runtime/model state, and every view agree on removal of a matched
|
||||
startup-reply projection.
|
||||
4. Raw provider sequence accounting survives sanitization.
|
||||
5. Buffered unmatched output is always released; the sanitizer cannot silently lose user data.
|
||||
6. ConPTY bootstrap modes reach live emulators unchanged.
|
||||
7. No focus suppression ships from this design; a proven ownership race requires a follow-up design.
|
||||
8. PTY teardown clears all startup-transaction buffers and focus-harness measurement state.
|
||||
@@ -1,108 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,783 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,712 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Kerberos / GSSAPI SSH Authentication
|
||||
|
||||
Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library
|
||||
has no GSSAPI userauth support, and adding it would mean forking ssh2's
|
||||
protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal /
|
||||
Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead,
|
||||
hosts that need Kerberos ride the existing **system OpenSSH transport** — the
|
||||
same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts —
|
||||
which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon),
|
||||
and credential delegation to the platform's own `ssh` binary on macOS, Linux,
|
||||
and Windows (Win32-OpenSSH uses SSPI).
|
||||
|
||||
## Transport selection
|
||||
|
||||
Two tiers, deliberately asymmetric because RHEL-family distros ship
|
||||
`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes
|
||||
`ssh -G` report GSSAPI enabled for *every* host:
|
||||
|
||||
1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an
|
||||
explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or
|
||||
set on the target directly) tries the system-ssh probe first. If that fails
|
||||
(e.g. no ticket), the connect falls through to the normal ssh2 key/agent
|
||||
path where passphrase/password prompts remain available — OpenSSH semantics
|
||||
allow other auth methods alongside GSSAPI.
|
||||
2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the
|
||||
`ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate`
|
||||
in `ssh-connection-utils.ts`), the connection retries over system ssh
|
||||
*before* prompting the user for credentials. Kerberos-only hosts on
|
||||
distro-default configs connect this way; hosts where keys work never leave
|
||||
the ssh2 path.
|
||||
|
||||
Manual (non-ssh-config) targets flagged for GSSAPI get an explicit
|
||||
`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets
|
||||
inherit the option from their own `Host` block since the system binary re-reads
|
||||
ssh_config.
|
||||
|
||||
Both tiers work headless (`orca serve`): the system-ssh probe needs no
|
||||
credential callbacks, and GSSAPI itself is non-interactive once a ticket
|
||||
exists.
|
||||
@@ -1,115 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,393 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,132 +0,0 @@
|
||||
# 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 cold−warm 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 ~160–190 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 ≥ ~4–5 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 ~4–5 MB renderer floor per hidden worktree that is ~16–20 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).
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,218 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,347 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,580 +0,0 @@
|
||||
# Terminal Session Ownership and Teardown
|
||||
|
||||
**Status:** Incident fix and pane-authority transfer implemented
|
||||
**Date:** 2026-07-13
|
||||
**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md)
|
||||
**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md),
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Terminal process lifetime must be owned by explicit product intent, never by
|
||||
whether a React view happens to be mounted.
|
||||
|
||||
Orca currently preserves a PTY when a terminal view detaches, which is correct
|
||||
for view parking, tab moves, renderer reload, and warm reattach. It also relies
|
||||
on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view
|
||||
parking invalidated that implicit invariant: a parked tab has no mounted pane,
|
||||
so closing it removes renderer state and observers without terminating the PTY.
|
||||
Agent resume records survive the same close and later interpret the missing pane
|
||||
as failed recovery, launching the deliberately closed session again.
|
||||
|
||||
This design establishes one authoritative lifecycle contract:
|
||||
|
||||
- **Close tab or pane:** permanently retire the surface, terminate every PTY it
|
||||
owns, and revoke every resume record for its panes.
|
||||
- **Detach or park:** remove only the view connection; preserve the PTY and its
|
||||
durable identity.
|
||||
- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and
|
||||
preserve only the checkpoint needed for wake.
|
||||
- **Quit or reload:** follow the configured persistence policy; warm detach is
|
||||
allowed, but it is not a tab close.
|
||||
|
||||
The immediate implementation makes the existing terminal-tab state close the
|
||||
authoritative compatibility boundary for explicit retirement while adding an
|
||||
explicit natural-exit reason for callers that are only reconciling an already
|
||||
dead PTY. A follow-up separates the command and reducer APIs once all direct
|
||||
store callers have migrated.
|
||||
|
||||
## Implementation status
|
||||
|
||||
The incident patch ships the retirement planner, provider-aware close routing,
|
||||
unified-only retirement, parked watcher/candidate disposal, resume-authority
|
||||
revocation, explicit natural-exit handling, late-binding rejection, and direct
|
||||
background-launch ownership checks. It also adds exact pane retirement and a
|
||||
persisted physical-to-owner pane-key alias for detach, plus a live Electron
|
||||
parked-close gate. Focused tests cover local, SSH, split, parked, shared,
|
||||
paired-host, ordinary runtime, pane-transfer, and late-spawn paths.
|
||||
|
||||
`orca terminal close --tab` extends that same ownership boundary to automation.
|
||||
With an attached renderer, main sends a request/reply close, the renderer runs
|
||||
the canonical retirement path, persists a fresh host-partitioned session, and
|
||||
forces the main store to disk before acknowledging success. Without a renderer,
|
||||
the runtime applies the equivalent immutable projection to the persisted
|
||||
aggregate tab, removes every split binding and resume record, safely selects a
|
||||
surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs,
|
||||
and flushes before returning. The ordinary `terminal close` pane/session
|
||||
contract is unchanged.
|
||||
|
||||
This durability boundary does not use replay fences or tombstones. Renderer
|
||||
session writes are ordered through the same IPC sender, and a pending debounced
|
||||
write rebuilds from the latest store state, so the forced fresh snapshot is not
|
||||
followed by a captured pre-close topology patch. Headless closes update the
|
||||
single main-owned session projection directly.
|
||||
|
||||
Origin-aware defensive expiry remains a follow-up. Immediate deletion on
|
||||
explicit close fixes the incident without applying a wall-clock policy that
|
||||
could invalidate intentional long-lived `worktree-sleep` checkpoints.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Closing a terminal tab submits retirement for all exclusively owned local,
|
||||
daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its
|
||||
views are mounted or parked.
|
||||
2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to
|
||||
another tab preserves its PTY, hook identity, and resume authority under the
|
||||
new owning pane.
|
||||
3. Closing a tab permanently removes its agent resume authority. App restart,
|
||||
worktree activation, mobile wake, and periodic capture must not resurrect it.
|
||||
4. Parking, tab-group moves, renderer reload, and warm reattach keep their
|
||||
existing process-preservation behavior.
|
||||
5. Remote-runtime and web-session surfaces are terminated by their owning host,
|
||||
not by sending a host-scoped ID to the local PTY provider.
|
||||
6. Teardown is idempotent and safe under duplicate close requests, late PTY
|
||||
exits, late agent-hook events, in-flight spawns, and provider disconnects.
|
||||
7. A future bookkeeping regression cannot grow without bound: unowned defensive
|
||||
resume records have an origin-aware expiry, and provider-owned detached
|
||||
sessions gain a bounded orphan policy in the daemon-lease phase.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing terminal output, snapshot, replay, query-response, or hidden-delivery
|
||||
behavior.
|
||||
- Killing processes that deliberately daemonize away from a plain user
|
||||
terminal's process group (nohup-style survivors remain user intent there).
|
||||
For **agent sessions**, close/kill additionally terminates the snapshotted
|
||||
descendant tree — including detached-pgid children the PTY's SIGHUP cannot
|
||||
reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with
|
||||
same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL).
|
||||
Completed process tables are never reused as signal targets, and identity
|
||||
checks use C-locale timestamps from the source scan. Later requests start a
|
||||
fresh same-turn-coalesced successor inside their own deadline instead of
|
||||
waiting behind older scans. A session is marked as terminating before capture
|
||||
and keeps request ownership through natural exit, so reattach, duplicate kill,
|
||||
and graceful-to-immediate upgrade cannot race the snapshot; descendant signals
|
||||
still require the exact root session/handle to be live. Windows and SSH-hosted
|
||||
PTYs keep the previous foreground-tree contract for now.
|
||||
- Changing agent-provider resume commands or permission flags.
|
||||
- Making a UI close wait for a remote process to exit before the tab disappears.
|
||||
- Replacing worktree sleep with tab close. Sleep remains resumable by design.
|
||||
- Claiming that a disconnected SSH relay process is already dead when main can
|
||||
only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a
|
||||
follow-up.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Surface:** a terminal tab or a pane within a split terminal tab.
|
||||
- **View:** a renderer xterm or pane-less parked watcher observing a surface.
|
||||
- **Session:** the provider-owned PTY identified by a PTY ID.
|
||||
- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits
|
||||
Orca to launch an agent-provider resume command.
|
||||
- **Retire:** permanently close a surface and revoke its process and resume
|
||||
ownership.
|
||||
- **Detach:** disconnect a view while preserving the session for reattachment.
|
||||
|
||||
## Open-source prior art
|
||||
|
||||
The lifecycle distinction is established in mature terminal implementations:
|
||||
|
||||
- VS Code's terminal instance disposal calls process-manager disposal, and the
|
||||
process manager marks the process `KilledByUser` before shutdown. Its separate
|
||||
`detachFromProcess` path deliberately clears the client reference without
|
||||
shutting down the process. It also shuts down a process whose asynchronous
|
||||
creation completes after the manager was disposed. See
|
||||
[`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325)
|
||||
and
|
||||
[`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239).
|
||||
- tmux exposes the same contract as separate commands: `detach-client` removes
|
||||
a client while leaving the session intact, whereas `kill-session` destroys
|
||||
the session and its windows. See
|
||||
[`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218).
|
||||
|
||||
Orca needs the same semantic split, extended across local, WSL, SSH, persistent
|
||||
daemon, and remote-runtime ownership. The important precedent is the explicit
|
||||
intent boundary, not a particular framework or process API.
|
||||
|
||||
## Non-negotiable invariants
|
||||
|
||||
1. View lifetime and process lifetime are independent. Mount and unmount may
|
||||
attach or detach observers; they do not decide whether a session should live.
|
||||
2. Every live PTY has an owning surface or an explicit bounded detached state.
|
||||
3. A user close is terminal: once accepted, no status event, session snapshot,
|
||||
or runtime replay may recreate the closed surface or resume its agent.
|
||||
4. A tab's retirement candidates are the union of its live index, tab row,
|
||||
persisted split layout, last-known relay ID, deferred SSH ID, and pending
|
||||
reconnect ID. A candidate is killable only when no other live surface claims
|
||||
it.
|
||||
5. Provider routing follows the execution owner:
|
||||
- local/daemon/WSL and app-scoped SSH IDs use `pty.kill`;
|
||||
- `remote:` runtime mirror IDs are never passed to the local PTY provider;
|
||||
- paired web tabs are closed through the host session RPC.
|
||||
6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and
|
||||
close-after-exit must converge on the same empty state.
|
||||
7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records
|
||||
are not expired by agent-status freshness; unowned defensive `live` and
|
||||
`quit` records use a documented decision-time horizon.
|
||||
8. Natural process exit and explicit user retirement are distinct intents.
|
||||
Reconciliation after an exit must not silently adopt destructive user-close
|
||||
semantics.
|
||||
|
||||
## Lifecycle matrix
|
||||
|
||||
| Intent | View | PTY | Resume record | Persistent tab state |
|
||||
| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- |
|
||||
| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep |
|
||||
| Move pane/tab group | detach, then remount | keep | keep | move |
|
||||
| Renderer reload | detach | keep when warm persistence applies | keep | keep |
|
||||
| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep |
|
||||
| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers |
|
||||
| Close split pane | destroy | terminate that pane | delete that pane record | remove pane |
|
||||
| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab |
|
||||
| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree |
|
||||
| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface |
|
||||
|
||||
## Current failure
|
||||
|
||||
The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then
|
||||
relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A
|
||||
parked tab has already run the detach branch and has no mounted cleanup left.
|
||||
`disposeParkedTabWatchers` only unregisters observers. The provider session is
|
||||
therefore alive after Orca discards its last renderer-side owner.
|
||||
|
||||
The same state close drops live agent status but not
|
||||
`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes
|
||||
records that no preserved pane can own. A deliberately closed pane therefore
|
||||
looks like a failed restore. The existing age check compares `capturedAt` with
|
||||
`updatedAt`; that detects a status that was stale at capture time but never
|
||||
expires a record as wall time advances.
|
||||
|
||||
## Proposed architecture
|
||||
|
||||
### 1. Authoritative tab retirement plan
|
||||
|
||||
Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from
|
||||
one store snapshot:
|
||||
|
||||
```ts
|
||||
type TerminalTabRetirementPlan = {
|
||||
tabId: string
|
||||
worktreeId: string | null
|
||||
ptyIds: string[]
|
||||
localOrSshPtyIds: string[]
|
||||
runtimeTerminals: Array<{ environmentId: string; handle: string }>
|
||||
sharedPtyIds: string[]
|
||||
paneKeys: string[]
|
||||
}
|
||||
```
|
||||
|
||||
`ptyIds` is the deduplicated union of:
|
||||
|
||||
- `ptyIdsByTabId[tabId]`;
|
||||
- the tab row's legacy `ptyId`;
|
||||
- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`;
|
||||
- `lastKnownRelayPtyIdByTabId[tabId]`;
|
||||
- `deferredSshSessionIdsByTabId[tabId]`;
|
||||
- `pendingReconnectPtyIdByTabId[tabId]`.
|
||||
|
||||
This snapshot must be built before any of those maps are pruned. PTY IDs are
|
||||
classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host
|
||||
or platform from the current active workspace. Before shutdown, the planner
|
||||
subtracts IDs referenced by another tab row, live index, split layout, or relay
|
||||
and reconnect map. A partially completed pane move must not let closing the
|
||||
source tab kill the target tab's session.
|
||||
|
||||
The immediate compatibility boundary is `closeTab`, because several production
|
||||
paths still call the store action directly. Its options carry an explicit
|
||||
`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while
|
||||
`pty-exit` reconciles an already-dead session without killing siblings or
|
||||
revoking crash-recovery policy by accident. Making only the rendered tab-bar
|
||||
action destructive would leave background launch cleanup, floating terminal,
|
||||
onboarding, runtime notifications, and future direct callers vulnerable.
|
||||
|
||||
### 2. Teardown ordering
|
||||
|
||||
A local tab retirement executes in this order:
|
||||
|
||||
1. Snapshot the retirement plan and its execution-owner classifications.
|
||||
2. Mark the tab recently closed so late agent-hook events cannot reintroduce
|
||||
status or resume authority.
|
||||
3. Dispose the whole tab's parked registry entry and captured candidates, and
|
||||
silence teardown side-effect handlers while preserving deterministic exit
|
||||
observation.
|
||||
4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for
|
||||
exclusive ordinary runtime handles. A paired host-session close remains
|
||||
owned by `session.tabs.close` and is not duplicated locally.
|
||||
5. Atomically remove the tab, layout/binding maps, agent status, and resume
|
||||
records from renderer state.
|
||||
6. Let subsequent React unmount cleanup run idempotently. It is no longer the
|
||||
process-lifetime authority.
|
||||
|
||||
Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation
|
||||
remains synchronous and does not await provider exit. Completion is observed
|
||||
with `Promise.allSettled` so rejections cannot become unhandled promises. The
|
||||
current Phase 1 fallback for a rejected retirement is provider `listSessions`
|
||||
plus Resource Manager orphan cleanup; durable retry inventory belongs to the
|
||||
main-owned retirement phase.
|
||||
|
||||
Remote-runtime mirrors are different: their `remote:` IDs are excluded from
|
||||
local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`.
|
||||
Paired host-session tabs retire through `session.tabs.close`, which owns the
|
||||
entire host tab graph. The local mirror may be pruned optimistically only after
|
||||
the host close intent is recorded, as it is today.
|
||||
|
||||
### 3. Resume-authority revocation
|
||||
|
||||
Tab retirement removes every sleeping record whose:
|
||||
|
||||
- map key begins with `${tabId}:`; or
|
||||
- record has `tabId === tabId`.
|
||||
|
||||
The explicit `record.tabId` check covers migrated or legacy keys whose key no
|
||||
longer encodes the current tab identity. Sibling tabs and other worktrees retain
|
||||
their records by reference when unchanged.
|
||||
|
||||
The existing recently-closed tab registry remains the short-lived race guard
|
||||
for hook events already in flight. It must prevent those events from creating a
|
||||
new `origin: 'live'` resume record after retirement. Tests must cover both event
|
||||
orders: close then late status, and status queued in the same turn as close.
|
||||
|
||||
Periodic capture iterates only current live status entries. Once close removes
|
||||
the live entry and resume record, the periodic pass has nothing to persist.
|
||||
|
||||
### 4. Origin-aware defensive record expiry
|
||||
|
||||
An unowned defensive `live` or `quit` record may expire when:
|
||||
|
||||
```ts
|
||||
Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS
|
||||
```
|
||||
|
||||
Intentional `worktree-sleep` records are exempt from the agent-status freshness
|
||||
window. Originless legacy behavior remains unchanged until its migration policy
|
||||
is explicitly chosen. The same validator must run before desktop activation and
|
||||
mobile/background wake so one entry point cannot bypass it.
|
||||
|
||||
Expiry is defense in depth, not the primary close mechanism. A correctly closed
|
||||
record is deleted immediately; it is not retained for 30 minutes.
|
||||
|
||||
### 5. Pane close remains pane-scoped
|
||||
|
||||
`PaneManager.closePane` already distinguishes `reason: 'close'` from
|
||||
`reason: 'detach'`. Preserve that distinction with explicit pane authority:
|
||||
|
||||
- `close` must clear the exact pane's resume authority, add a pane-scoped late
|
||||
event tombstone, and destroy the pane transport;
|
||||
- `detach` must atomically transfer or alias resume ownership because the agent
|
||||
process retains its immutable source `ORCA_PANE_KEY`.
|
||||
|
||||
The last-pane path routes to terminal-tab retirement so it receives the same
|
||||
parked, split, SSH, and resume cleanup behavior as a tab-bar close.
|
||||
|
||||
### 6. Provider-side safety net
|
||||
|
||||
Client correctness is necessary but not sufficient for a daemon deliberately
|
||||
designed to outlive renderer and app processes. Add a follow-up provider-owned
|
||||
orphan policy:
|
||||
|
||||
- track attachments/ownership leases rather than treating `detach` as a no-op;
|
||||
- distinguish warm-reattach grace from ownerless retirement;
|
||||
- reap sessions that have no owner after a bounded grace period;
|
||||
- never idle-reap a session merely because it produces no output;
|
||||
- scope leases to native, WSL, SSH provider, relay connection, or runtime host;
|
||||
- expose orphan count and oldest orphan age in Resource Manager diagnostics.
|
||||
|
||||
This hardening is not required to land the immediate close fix, because a lease
|
||||
protocol changes daemon compatibility and needs its own migration. It is the
|
||||
defense against a future client bookkeeping regression.
|
||||
|
||||
## Provider behavior
|
||||
|
||||
| Provider/session kind | Close operation | Notes |
|
||||
| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. |
|
||||
| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. |
|
||||
| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. |
|
||||
| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. |
|
||||
| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. |
|
||||
| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. |
|
||||
| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. |
|
||||
| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. |
|
||||
| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. |
|
||||
|
||||
## Failure and race handling
|
||||
|
||||
### Duplicate close
|
||||
|
||||
The first close snapshots and retires the tab. Later closes find no target and
|
||||
no-op. Provider shutdown is idempotent for an already exited session.
|
||||
|
||||
### PTY exit races close
|
||||
|
||||
Observers are unregistered before shutdown. If an exit event was already
|
||||
queued, the recently-closed tab guard and missing tab make it a no-op. It must
|
||||
not recreate a tab, completion row, resume record, or notification.
|
||||
|
||||
### Spawn completes after close
|
||||
|
||||
The transport already kills a PTY when its spawn resolves after `destroyed` is
|
||||
set, but the two direct background launchers do not use that transport. Each
|
||||
direct launcher must revalidate tab ownership immediately after `pty.spawn` or
|
||||
`terminal.create` resolves and before writing any binding, layout, eager buffer,
|
||||
subscription, or mount request. A retired local or SSH result is killed; a
|
||||
retired runtime result is closed through `terminal.close`.
|
||||
|
||||
### Provider shutdown fails
|
||||
|
||||
The tab remains closed; reappearing would violate user intent. Report the
|
||||
provider class through structured diagnostics and leave the session visible in
|
||||
provider inventory so existing Resource Manager orphan cleanup can retry. A
|
||||
durable pending-retirement inventory is a later main-process API and is not
|
||||
claimed by Phase 1. Do not restore resume authority after a failed terminal
|
||||
kill.
|
||||
|
||||
### Remote host is unavailable
|
||||
|
||||
Keep the local close intent until either a host snapshot confirms removal or the
|
||||
existing intent TTL expires. If it expires, the host remains authoritative and
|
||||
may republish the tab; log the failed close. Never pretend a local mirror prune
|
||||
terminated the host PTY.
|
||||
|
||||
### App exits during close
|
||||
|
||||
Submit shutdown IPC before discarding the only local PTY identifiers. The main
|
||||
process/provider owns completion after accepting the request. A future batched
|
||||
shutdown IPC may return per-ID acceptance, but UI close does not wait for it.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Phase 1: correctness patch (implemented)
|
||||
|
||||
1. Add a small store-independent terminal-tab retirement planner that collects
|
||||
all six ID sources, classifies execution owners, and excludes IDs shared by
|
||||
another live surface.
|
||||
2. Add an explicit close reason, update natural-exit callers, and make explicit
|
||||
`closeTab` retirement dispose the whole parked-tab registry, submit local,
|
||||
SSH, or runtime shutdown, atomically clear all reconnect maps, and remove
|
||||
tab-owned resume records.
|
||||
3. Add post-await owner checks to both direct background launchers so a late
|
||||
local, SSH, or runtime spawn is terminated before any renderer binding.
|
||||
4. Defer decision-time expiry until unowned defensive `live` or `quit` records
|
||||
can be distinguished without changing intentional `worktree-sleep` or legacy
|
||||
recovery behavior.
|
||||
5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one
|
||||
local Electron parked-close proof as an RC release gate.
|
||||
|
||||
### Phase 2: remaining API cleanup
|
||||
|
||||
1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean
|
||||
either UI cleanup or lifecycle teardown.
|
||||
2. Route all user and runtime close call sites through one domain command.
|
||||
3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options.
|
||||
4. Add a development assertion when tab state is removed with provider PTY IDs
|
||||
but no retirement plan.
|
||||
|
||||
Exact pane retirement, persisted detach authority transfer, and unified-only
|
||||
terminal routing landed with Phase 1 because exact-head review found they were
|
||||
required to avoid introducing adjacent close regressions.
|
||||
|
||||
### Phase 3: daemon ownership leases
|
||||
|
||||
1. Define versioned attach/detach/retire lease messages.
|
||||
2. Preserve warm reattach across renderer/app restarts within a bounded grace.
|
||||
3. Reap ownerless sessions and expose pressure telemetry.
|
||||
4. Add compatibility tests across the supported daemon protocol versions and
|
||||
native/WSL/SSH host isolation.
|
||||
|
||||
## Test plan
|
||||
|
||||
### Unit/store tests
|
||||
|
||||
- Closing an active single-pane tab submits exactly its PTY for shutdown and
|
||||
removes all tab state.
|
||||
- Closing a parked tab with no mounted transport still submits its PTY.
|
||||
- Closing a split tab submits every unique PTY found across tab row, index, and
|
||||
layout plus relay/deferred/reconnect maps, including IDs present in only one
|
||||
source.
|
||||
- Duplicate IDs are submitted once by the authoritative retirement plan.
|
||||
- An ID referenced by another live tab is not killed when the source tab closes.
|
||||
- `remote:` IDs are excluded from local PTY shutdown.
|
||||
- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs.
|
||||
- All sleeping records for the tab are removed by key prefix or `record.tabId`;
|
||||
sibling records retain identity.
|
||||
- Periodic capture after close does not recreate the record.
|
||||
- Late agent status after close cannot recreate live status or resume authority.
|
||||
- Closing twice and close-after-exit are no-ops after the first retirement.
|
||||
- A spawn resolving after close kills the newly returned PTY.
|
||||
- A defensive live/quit record at the expiry boundary is retained; one
|
||||
millisecond past it is cleared and never launched, while an older intentional
|
||||
worktree-sleep record still resumes.
|
||||
|
||||
### Renderer integration tests
|
||||
|
||||
- Mounted tab close still destroys its xterm and terminates the PTY.
|
||||
- Parked watcher disposal occurs before or with PTY shutdown and emits no final
|
||||
completion/bell notification.
|
||||
- Closing one split pane preserves the sibling; closing the last pane retires
|
||||
the tab.
|
||||
- Pane-to-tab detach preserves the PTY and resume record.
|
||||
- Pinned-tab cancellation performs no shutdown; confirmed close does.
|
||||
- Bulk close (`close others`, `close right`, kill-all) uses the same semantics.
|
||||
|
||||
### Provider/main tests
|
||||
|
||||
- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the
|
||||
correct provider.
|
||||
- Disconnected SSH close tombstones the app-scoped ID and never falls through to
|
||||
a local provider.
|
||||
- Repeated `pty.kill` for the same ID is benign.
|
||||
- Remote-runtime IDs cannot reach the local kill handler from renderer close.
|
||||
|
||||
### Electron end-to-end regression
|
||||
|
||||
1. Create a worktree terminal and start a deterministic long-lived child.
|
||||
2. Switch worktrees and wait until the source worktree is cold-parked.
|
||||
3. Close the parked tab.
|
||||
4. Assert its exact PTY disappears from `pty:listSessions` and the child exits.
|
||||
5. Restart/reload Orca and activate the worktree.
|
||||
6. Assert no terminal or agent resume command is recreated for the closed pane.
|
||||
7. Repeat with two split panes and with an SSH fixture where CI supports it.
|
||||
|
||||
### Soak and pressure gate
|
||||
|
||||
Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count
|
||||
must return to baseline after each cycle and remain bounded over the run. Record
|
||||
the peak and final provider session count in CI artifacts.
|
||||
|
||||
## Observability
|
||||
|
||||
Emit a synchronous scheduled summary and an asynchronous completion summary:
|
||||
|
||||
- tab/worktree identifiers in hashed or existing diagnostic form;
|
||||
- number of PTY IDs discovered by each source;
|
||||
- provider classification counts;
|
||||
- submitted count, then fulfilled/rejected counts after `Promise.allSettled`;
|
||||
- number of resume records removed;
|
||||
- whether the tab was parked or mounted when retired.
|
||||
|
||||
After the daemon-lease phase, Resource Manager should distinguish:
|
||||
|
||||
- attached sessions;
|
||||
- warm-detached sessions eligible for reattach;
|
||||
- ownerless/orphan sessions;
|
||||
- pending/failed explicit retirement.
|
||||
|
||||
Alert locally before app-owned PTYs approach platform pressure. The warning is a
|
||||
defense, not a substitute for lifecycle correctness.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Land the correctness patch without a feature flag. User close semantics are
|
||||
restorative behavior, not an experiment.
|
||||
2. Keep hidden-view parking enabled; disabling it masks the ownership bug and
|
||||
forfeits its memory benefit.
|
||||
3. Run focused unit/integration tests plus the live parked-close Electron gate.
|
||||
4. Cut an RC and soak create/close/restart cycles while monitoring provider and
|
||||
OS PTY counts.
|
||||
5. Ship daemon leases separately behind protocol compatibility and telemetry.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Disable hidden-view parking
|
||||
|
||||
This restores the old accidental unmount behavior at significant memory cost
|
||||
and leaves every other state-only close path fragile. It does not repair resume
|
||||
records or daemon ownership.
|
||||
|
||||
### Kill only from `TerminalPane` cleanup
|
||||
|
||||
There is intentionally no pane during parking and some headless/runtime flows.
|
||||
View cleanup cannot be process authority.
|
||||
|
||||
### Clear only resume records
|
||||
|
||||
This prevents resurrection but still leaks the live PTY until app/daemon exit.
|
||||
|
||||
### Kill only the tab row's `ptyId`
|
||||
|
||||
Split panes and partially reconciled layouts can own multiple IDs. The full
|
||||
union is required.
|
||||
|
||||
### Rely only on a daemon idle timeout
|
||||
|
||||
Interactive agents can be legitimately idle for hours. Reaping by output
|
||||
idleness loses user work; ownership, not activity, is the safe signal.
|
||||
|
||||
### Await every provider exit before hiding the tab
|
||||
|
||||
Remote and disconnected providers can take seconds or fail. UI retirement must
|
||||
be immediate once the user confirms; provider completion is asynchronous and
|
||||
observable.
|
||||
|
||||
## Phase 1 acceptance criteria
|
||||
|
||||
- A confirmed terminal-tab close submits retirement for every exclusively owned
|
||||
session Orca can currently reach and reports rejected requests without an
|
||||
unhandled promise.
|
||||
- A closed agent session is never resumed by restart, activation, or mobile wake.
|
||||
- Parking, move/detach, sleep/wake, and warm app reattach retain their documented
|
||||
behavior.
|
||||
- Split, local, SSH, and remote-runtime ownership are covered by deterministic
|
||||
tests; Windows/WSL process-tree behavior remains a live-platform follow-up.
|
||||
- PTY counts remain bounded across the soak scenario.
|
||||
- No process-lifetime decision depends on React component mount state.
|
||||
|
||||
## Full-contract follow-ups
|
||||
|
||||
- Durable main-owned retirement intent and bounded retry inventory.
|
||||
- Disconnected SSH relay kill-on-reconnect.
|
||||
- Serializable daemon attachment leases with protocol-version fallback.
|
||||
- Positive ownership validation for late hook events beyond the bounded recent-
|
||||
close registry.
|
||||
@@ -1,252 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,362 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,153 @@
|
||||
# WSL OSC 7 Sleep/Wake CWD
|
||||
|
||||
## Problem
|
||||
|
||||
On native Windows, a WSL terminal can persist a fake UNC current working directory and then fail to wake. The deterministic trigger is a WSL shell emitting `file://<machine-hostname>/home/...` before sleep:
|
||||
|
||||
- `osc7-file-uri.ts` treats every non-local authority as a UNC server when parsing Win32 paths.
|
||||
- `Session` constructs its daemon `HeadlessEmulator` without the WSL execution context, so checkpoints can store `\\<machine-hostname>\home\...`.
|
||||
- `OrcaRuntimeService` likewise gives every local Windows PTY Win32 OSC 7 semantics; `remotePosixAuthority` only protects POSIX SSH PTYs.
|
||||
- `HistoryReader.restoreFromIncrementalLog` creates a third context-free emulator. A log containing the OSC 7 can therefore re-create the bad CWD even if the base checkpoint is valid.
|
||||
- `DaemonPtyAdapter.doSpawn` prefers `restoreInfo.cwd` over the requested worktree CWD. `pty-subprocess.ts` later validates the fake UNC and rejects it before spawning WSL.
|
||||
|
||||
Reproduction on Windows 11 build 26200, WSL 2.3.26.0, Ubuntu 24.04.1 LTS, and Orca 1.4.144-rc.4:
|
||||
|
||||
1. Open a terminal in `\\wsl.localhost\Ubuntu\home\<user>\...`.
|
||||
2. Emit `OSC 7;file://<machine-hostname>/home/<user>/...`.
|
||||
3. Sleep for 38-69 seconds, then wake.
|
||||
|
||||
Both triggered attempts failed. The saved CWD became `\\<machine-hostname>\home\<user>\...`; the original WSL UNC remained valid. A 60-second control sleep without the hostname OSC 7 passed.
|
||||
|
||||
## Root cause and invariant
|
||||
|
||||
OSC 7 authority semantics follow the PTY's execution environment, not Electron's host OS. A native Windows shell may legitimately mean `\\server\share` by `file://server/share`; a WSL shell means a POSIX pathname inside its already-known distro. Orca currently classifies local WSL PTYs as generic Win32 PTYs.
|
||||
|
||||
The fix must establish one immutable `wslDistro: string | null` per PTY incarnation before any OSC 7 bytes or recovered history are parsed. The same value must drive daemon live parsing, incremental-history replay, runtime parsing, and legacy CWD recovery. URI authority must never select a distro.
|
||||
|
||||
Resolve that value with the same precedence already used to launch WSL:
|
||||
|
||||
1. distro in an explicit WSL UNC `cwd`;
|
||||
2. distro in the worktree encoded by the daemon session ID;
|
||||
3. trimmed `terminalWindowsWslDistro` selected for the spawn.
|
||||
|
||||
Centralize this resolution with the existing WSL session-context code so the parser and subprocess cannot disagree. Do not infer local WSL context for `connectionId`/SSH PTYs.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change native Windows UNC, POSIX SSH, or remote Windows drive-path semantics.
|
||||
- Do not redesign terminal history, sleep, renderer link routing, or PTY identity.
|
||||
- Do not probe WSL, DNS, the filesystem, or the network on the output path.
|
||||
- Do not infer a distro from an OSC hostname or mutable global/default-distro state.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Pure conversion and parser context
|
||||
|
||||
Move `toWindowsWslPath` to `src/shared/wsl-paths.ts` and re-export it from `src/main/wsl.ts` for existing callers. Preserve its current rules: lowercase `/mnt/<ascii-letter>` maps to a native drive; every other absolute Linux path maps under `\\wsl.localhost\<distro>`. `/MNT` and `/mnt/C` are case-sensitive Linux paths, not drvfs aliases.
|
||||
|
||||
Add `wslDistro?: string` to `ParseFileUriPathOptions`. When present, `parseFileUriPathParts` must:
|
||||
|
||||
- decode `url.pathname` exactly once;
|
||||
- construct the path with `toWindowsWslPath(decodedPath, wslDistro)`, regardless of URI authority;
|
||||
- continue returning the normalized URI hostname as metadata;
|
||||
- reject malformed URLs/percent encoding as today and leave the prior CWD unchanged.
|
||||
|
||||
Without `wslDistro`, retain every existing `pathFlavor` and `remotePosixAuthority` branch.
|
||||
|
||||
Thread the option through `TerminalOscCwdTitleScanner` and `HeadlessEmulator`. Scanner options are constructor-scoped, so split sequences retain the same context without global state.
|
||||
|
||||
### 2. Daemon and history ownership
|
||||
|
||||
`TerminalHost` resolves the immutable distro before spawning. Pass it both to `createPtySubprocess` and to a new `SessionOptions.wslDistro`; `Session` passes it to its `HeadlessEmulator`. Store the resolved value on `Session` and return it on create/attach so a later window observes the live session's context instead of reinterpreting it from current settings. Make the response field additive/optional for compatibility with an older preserved daemon.
|
||||
|
||||
Extend `HistoryReader.detectColdRestore` with optional parser context and use it for the scratch `HeadlessEmulator` in `restoreFromIncrementalLog`. Every detect path in `DaemonPtyAdapter`--initial detection, probe/create race recovery, and failed history seeding--must pass the same resolved distro. This is required; fixing only the live `Session` leaves incremental restore able to reproduce the bug.
|
||||
|
||||
### 3. Narrow legacy checkpoint recovery
|
||||
|
||||
Immediately after each cold-restore detection, normalize `restoreInfo.cwd` before it becomes `effectiveCwd` or `coldRestore.cwd`. Recovery runs only on native Windows with a resolved local WSL distro:
|
||||
|
||||
1. Preserve an absolute drive path.
|
||||
2. Preserve a `\\wsl.localhost\...` or `\\wsl$\...` path only when its distro matches the resolved distro case-insensitively.
|
||||
3. Convert an absolute POSIX path through the resolved distro.
|
||||
4. Repair the known legacy shape only when the UNC server equals `os.hostname()` case-insensitively: strip the server, interpret the share and tail as the Linux absolute path, then convert it. Thus `\\HOST\mnt\c\x` becomes `C:\x`.
|
||||
5. For a mismatched-distro WSL UNC, another UNC server, a relative path, or malformed input, discard the recovered CWD and fall back to the current requested CWD (or the normal spawn default if none was supplied). Do not guess.
|
||||
|
||||
Apply the corrected/fallback value to both subprocess creation and the returned cold-restore payload. This keeps runtime seeding, the sticky restore cache, `initialCwds`, and the next checkpoint consistent. The repair is idempotent and never mutates history files in place.
|
||||
|
||||
### 4. Runtime ownership and races
|
||||
|
||||
Add `wslDistro: string | null` to `RuntimePtyWorktreeRecord`; a boolean `isWsl` is insufficient for multi-distro parsing. Pass the resolved distro from the spawn result into runtime registration. For reconstructed local records, a WSL UNC worktree may supply a fallback distro via `parseWslUncPath`; never do this for SSH records.
|
||||
|
||||
Daemon PTYs can emit output before `provider.spawn` resolves, and cold-restore seeding currently runs before `registerPty`. Register the expected daemon session's execution context before spawn, then replace it with the daemon-returned immutable value before snapshot/cold-restore seeding. If late discovery changes a context after a runtime emulator was created, discard that emulator and re-seed it from the authoritative provider snapshot; never keep a buffer whose CWD was parsed under mixed contexts.
|
||||
|
||||
Clear execution context with the other per-PTY parser maps on exit, pruning, failed spawn, and provider-generation reset. Reusing a PTY ID must not inherit a prior distro.
|
||||
|
||||
## Data flow
|
||||
|
||||
```text
|
||||
spawn cwd/session/preference
|
||||
-> resolve one immutable local WSL distro
|
||||
-> daemon Session scanner + HistoryReader replay scanner
|
||||
-> decoded OSC 7 pathname (authority retained only as metadata)
|
||||
-> toWindowsWslPath(pathname, distro)
|
||||
-> daemon checkpoint + runtime live/headless CWD
|
||||
-> sleep/wake with a valid WSL UNC or drive CWD
|
||||
```
|
||||
|
||||
Legacy recovery is a boundary repair:
|
||||
|
||||
```text
|
||||
WSL cold restore + old checkpoint CWD
|
||||
-> exact allowlist normalization
|
||||
-> corrected spawn and coldRestore payload
|
||||
-> next checkpoint naturally persists the corrected live CWD
|
||||
```
|
||||
|
||||
## Consistency and failure modes
|
||||
|
||||
- Context is per PTY incarnation, not global, so simultaneous Ubuntu and Debian panes cannot contaminate each other.
|
||||
- The first creator owns a daemon session's immutable context. Concurrent/multi-window attaches consume the stored value; changed settings do not mutate a live session.
|
||||
- A distro mismatch in externally changed history falls back to the requested worktree CWD instead of launching the wrong distro.
|
||||
- Atomic checkpoint replacement remains unchanged. Concurrent restore callers may read different complete generations, but each normalizes before spawn and daemon create/attach still selects one live session.
|
||||
- Missing context deliberately preserves current behavior. Tests must cover every local WSL construction path so this fallback cannot silently remain on the affected path.
|
||||
- Native UNC and SSH behavior remain isolated because neither receives local WSL context.
|
||||
- No filesystem/network work is added per output chunk; parsing remains bounded string work on completed OSC sequences.
|
||||
|
||||
## Test plan
|
||||
|
||||
- `osc7-file-uri.test.ts` and shared WSL-path tests: hostname/localhost/empty WSL authorities; hostname metadata; `/home`, `/`, lowercase `/mnt/c`, `/MNT`, `/mnt/C`; spaces/percent decoding; invalid encoding; native UNC; POSIX SSH; Windows SSH drive paths.
|
||||
- `headless-emulator.test.ts`: WSL context propagation across ordinary and split OSC sequences; two emulators with different distros.
|
||||
- `terminal-host.test.ts`/`session.test.ts`: resolved context reaches the daemon emulator; attach returns the session's stored context and does not adopt a conflicting later preference.
|
||||
- `history-reader.test.ts`: incremental-log replay containing the exact hostname OSC 7 yields the correct WSL CWD.
|
||||
- `daemon-pty-adapter.test.ts`: cover every `detectColdRestore` branch plus sticky-cache output. Repair hostname UNC and POSIX CWDs; preserve matching WSL UNC and drive paths; reject mismatched distro, other UNC, relative, native, and SSH/non-WSL cases. Assert both create/attach CWD and `coldRestore.cwd`.
|
||||
- `orca-runtime.test.ts` and IPC PTY tests: context exists before early daemon output and before headless seeding; Windows-host worktree with a selected WSL distro; WSL UNC fallback after reconstruction; attach correction; PTY-ID reuse; simultaneous distros; local native and SSH isolation.
|
||||
- Run focused tests, then `pnpm typecheck` and `pnpm lint`.
|
||||
- Electron on native Windows: in a real Ubuntu WSL pane, emit the exact hostname OSC 7, sleep/wake twice, run `pwd` after each wake, and verify no `DaemonProtocolError`. Native UNC semantics are covered by deterministic unit tests; a screenshot of an arbitrary UNC string is not meaningful validation.
|
||||
|
||||
## UI quality and review evidence
|
||||
|
||||
There is no UI, layout, copy, or interaction change, so the Stage 5 visual-quality loop is skipped. Terminal content, CWD, and adjacent native/SSH behavior must remain unchanged.
|
||||
|
||||
Electron validation still requires three evidence screenshots for user review:
|
||||
|
||||
1. Before sleep: the WSL terminal shows the printed hostname/emission command and `pwd`.
|
||||
2. After the first wake: the same pane shows the same `pwd` and no wake error.
|
||||
3. After the second wake: the same pane again shows the same `pwd` and no wake error.
|
||||
|
||||
## Lightweight engineering review
|
||||
|
||||
- **Scope:** Parser context, immutable context propagation, history replay, and narrow legacy recovery. Runtime/IPC fields are necessary because parsing begins outside the daemon and may precede spawn completion.
|
||||
- **Architecture/data flow:** One resolved distro drives every parser for one PTY incarnation. The URI hostname remains metadata; it never selects path namespace or distro.
|
||||
- **Failure modes:** Covers missing/stale context, incremental replay, probe/create races, sticky restore, mismatched external history, split chunks, concurrent attaches, multi-window reuse, multi-distro isolation, PTY-ID reuse, and SSH/native boundaries.
|
||||
- **Tests:** Requires parser tables, propagation tests at every emulator construction site, all adapter restore branches, runtime early-byte/seed races, and the deterministic Windows Electron reproduction twice.
|
||||
- **Performance/blast radius:** One optional string per PTY/session and bounded conversion per OSC 7; no probes or new hot-path I/O. Non-WSL behavior is unchanged when the option is absent.
|
||||
- **UI/screenshots:** No design-review loop. Three Electron screenshots are required as functional evidence; native UNC stays an automated regression test.
|
||||
- **Residual risk:** Legacy checkpoints created from a non-machine hostname cannot be distinguished safely from a real UNC and therefore fall back to the requested CWD. A renamed/uninstalled distro still fails normally; this change does not guess a replacement.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Centralize distro resolution and WSL path conversion.
|
||||
2. Make the parser, daemon Session, and history replay distro-aware.
|
||||
3. Add allowlisted legacy recovery and keep spawn/cold-restore metadata consistent.
|
||||
4. Make runtime context available before output/seeding and reset it per incarnation.
|
||||
5. Add regressions, run static checks, then validate two Windows sleep/wake cycles with review screenshots.
|
||||
+36
-102
@@ -110,14 +110,9 @@ function isErrorVerdict(v: ConnectionVerdict): boolean {
|
||||
const REPO_METADATA_REFRESH_MS = 60_000
|
||||
|
||||
type HostScreenProps = {
|
||||
// Why: when true, this worktree list is rendered as the persistent tablet
|
||||
// sidebar by the host layout rather than as its own routed screen. That
|
||||
// swaps the back button for a hide-sidebar control, drives data fetching
|
||||
// from a plain mount effect (the sidebar is never the "focused" route), and
|
||||
// opens sessions into the detail pane instead of pushing a new full screen.
|
||||
// When true, rendered as the persistent tablet sidebar by the host layout, not as its own routed screen.
|
||||
embedded?: boolean
|
||||
// Route params aren't in scope when rendered from the layout, so the caller
|
||||
// passes hostId/action explicitly; falls back to the local route params.
|
||||
// Route params aren't in scope when rendered from the layout, so the caller passes these explicitly.
|
||||
hostId?: string
|
||||
action?: string
|
||||
onHideSidebar?: () => void
|
||||
@@ -135,16 +130,12 @@ export function HostScreen({
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: cap and center the worktree list on wide/tablet canvases; on phones
|
||||
// isWideLayout is false so the list stays edge-to-edge as before. When
|
||||
// embedded as the sidebar the list already lives in a narrow pane, so the
|
||||
// cap is skipped (see the SectionList contentContainerStyle below).
|
||||
// Why: cap and center the list on wide/tablet canvases; on phones isWideLayout is false so it stays edge-to-edge.
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
const [initialCache] = useState(() =>
|
||||
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
)
|
||||
// Why: shared client per host owned by RpcClientProvider. See
|
||||
// docs/mobile-shared-client-per-host.md.
|
||||
// Shared client per host owned by RpcClientProvider. See docs/mobile-shared-client-per-host.md.
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
@@ -158,9 +149,7 @@ export function HostScreen({
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
|
||||
// Why: opening a worktree activates it on the host, but the active-row
|
||||
// highlight otherwise waits for the next worktree.ps poll to reflect it.
|
||||
// Track the locally-opened worktree so the highlight moves instantly.
|
||||
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
|
||||
const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState<string | null>(null)
|
||||
// One tick drives every visible agent row's relative timestamp.
|
||||
const now = useNow(30_000)
|
||||
@@ -181,9 +170,7 @@ export function HostScreen({
|
||||
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
|
||||
DEFAULT_MOBILE_WORKSPACE_STATUSES
|
||||
)
|
||||
// displayName → repo id, populated from repo.list. The filter model keys on
|
||||
// repo ids (desktop's PersistedUIState), but the section headers/rows key on
|
||||
// displayName, so we bridge the two here.
|
||||
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
|
||||
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
|
||||
const [showSortPicker, setShowSortPicker] = useState(false)
|
||||
const [showGroupPicker, setShowGroupPicker] = useState(false)
|
||||
@@ -206,9 +193,7 @@ export function HostScreen({
|
||||
}, [router])
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
// Why: snapshot of the synced view settings so the focus-effect ui.get merge
|
||||
// and the optimistic ui.set writes read the latest values without forcing the
|
||||
// callbacks to re-create on every state change.
|
||||
// Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change.
|
||||
const viewStateRef = useRef<MobileViewState>({
|
||||
groupMode: 'repo',
|
||||
sortMode: 'recent',
|
||||
@@ -231,8 +216,7 @@ export function HostScreen({
|
||||
}
|
||||
}, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
|
||||
|
||||
// Apply a MobileViewState (e.g. from a desktop ui.get) onto the individual
|
||||
// states and the snapshot ref in one shot.
|
||||
// Apply a MobileViewState onto the individual states and the snapshot ref in one shot.
|
||||
const applyViewState = useCallback((next: MobileViewState) => {
|
||||
viewStateRef.current = next
|
||||
setGroupMode(next.groupMode)
|
||||
@@ -246,8 +230,7 @@ export function HostScreen({
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Optimistically apply a partial change locally, then push the full mapped
|
||||
// settings to the desktop's shared store via ui.set so both apps stay in sync.
|
||||
// Apply the change locally, then push full settings to the desktop's shared store (ui.set) so both apps stay in sync.
|
||||
const persistViewSettings = useCallback(
|
||||
(patch: Partial<MobileViewState>) => {
|
||||
const next: MobileViewState = { ...viewStateRef.current, ...patch }
|
||||
@@ -280,8 +263,7 @@ export function HostScreen({
|
||||
}, [])
|
||||
|
||||
const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action)
|
||||
// Why: `action=newWorktree` is a route-derived open edge. Resolve it before
|
||||
// commit, but don't reopen after the user closes while the same URL remains.
|
||||
// Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists.
|
||||
if (resolvedRouteActionState !== routeActionState) {
|
||||
setRouteActionState(resolvedRouteActionState)
|
||||
}
|
||||
@@ -290,8 +272,7 @@ export function HostScreen({
|
||||
setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible))
|
||||
}, [])
|
||||
|
||||
// Load persisted pins from the local cache. View settings are no longer
|
||||
// stored locally — they sync from the desktop's shared store via ui.get.
|
||||
// Load persisted pins from local cache; view settings are no longer local (they sync via ui.get).
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
return
|
||||
@@ -309,9 +290,7 @@ export function HostScreen({
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
// Read the desktop's shared view settings (PersistedUIState) and merge them
|
||||
// onto local state. Runs on connect and on screen focus so changes made on
|
||||
// desktop appear on the phone.
|
||||
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
|
||||
const syncViewSettingsFromDesktop = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
@@ -333,9 +312,7 @@ export function HostScreen({
|
||||
}
|
||||
}, [client, connState, hostId, applyViewState])
|
||||
|
||||
// Why: keep clientRef in sync so existing imperative call sites work
|
||||
// unchanged. Also re-seed the cached worktree list on hostId change
|
||||
// since the useState initializer only runs on first mount.
|
||||
// Why: mirror client into a ref so imperative call sites read it without re-subscribing.
|
||||
useEffect(() => {
|
||||
clientRef.current = client
|
||||
}, [client])
|
||||
@@ -346,9 +323,7 @@ export function HostScreen({
|
||||
setRepoColorsByName(new Map())
|
||||
setRepoIconsByName(new Map())
|
||||
repoMetadataFetchedAtRef.current = 0
|
||||
// Why: re-seed from the current host's cache on every hostId change.
|
||||
// The useState initializer only runs on first mount, so if Expo Router
|
||||
// reuses this screen with a different hostId, we must reset here.
|
||||
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
|
||||
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
if (freshCache) {
|
||||
setWorktrees(freshCache)
|
||||
@@ -436,8 +411,7 @@ export function HostScreen({
|
||||
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
|
||||
return
|
||||
}
|
||||
// The embedded sidebar polls for the whole split-view session; keep slow
|
||||
// remote hosts from stacking overlapping expensive list requests.
|
||||
// Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling.
|
||||
if (fetchWorktreesInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -446,8 +420,7 @@ export function HostScreen({
|
||||
const requestHostId = hostId
|
||||
|
||||
try {
|
||||
// Why: worktree.ps defaults to 200 and silently truncates; match the
|
||||
// desktop's high cap so large hosts don't drop workspaces on mobile.
|
||||
// Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces.
|
||||
const response = await requestClient.sendRequest('worktree.ps', { limit: 10000 })
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) {
|
||||
return
|
||||
@@ -457,9 +430,7 @@ export function HostScreen({
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktrees: Worktree[] }
|
||||
// Why: large hosts can return identical worktree.ps snapshots every
|
||||
// poll. Preserving the existing array keeps SectionList/sort rebuilds
|
||||
// off the JS tap path unless something actually changed.
|
||||
// Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
|
||||
setWorktrees((current) =>
|
||||
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
|
||||
)
|
||||
@@ -467,25 +438,18 @@ export function HostScreen({
|
||||
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
|
||||
)
|
||||
setWorktreesLoaded(true)
|
||||
// Why (#8498): the host detail screen seeds its list from the
|
||||
// home-written cache, so a partial home fetch could poison it until a
|
||||
// focus poll corrected it. Write the confirmed snapshot back through
|
||||
// the same cache so a reconnect refetch (or a remount) can't serve a
|
||||
// stale worktree list.
|
||||
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
|
||||
if (hostId) {
|
||||
setCachedWorktrees(hostId, result.worktrees)
|
||||
}
|
||||
// Drop the optimistic active override once the host confirms it (the
|
||||
// activate RPC has landed and worktree.ps now reports it active), so we
|
||||
// stop overriding and respect any later desktop-driven change.
|
||||
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
|
||||
setOptimisticActiveWorktreeId((pending) =>
|
||||
pending && result.worktrees.some((w) => w.worktreeId === pending && w.isActive)
|
||||
? null
|
||||
: pending
|
||||
)
|
||||
|
||||
// Clear optimistic sleep overrides once the server confirms the
|
||||
// worktree is actually inactive (liveTerminalCount dropped to 0).
|
||||
// Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
|
||||
setSleptIds((prev) => {
|
||||
if (prev.size === 0) {
|
||||
return prev
|
||||
@@ -500,8 +464,7 @@ export function HostScreen({
|
||||
return still.size === prev.size ? prev : still
|
||||
})
|
||||
|
||||
// Sync local pin state from server so desktop-initiated pins/unpins
|
||||
// are reflected without relying on stale AsyncStorage.
|
||||
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
|
||||
const serverPinned = new Set(
|
||||
result.worktrees.filter((w) => w.isPinned).map((w) => w.worktreeId)
|
||||
)
|
||||
@@ -526,29 +489,22 @@ export function HostScreen({
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Why: opening the host is a strong user signal — reset a backed-off or
|
||||
// trickling reconnect loop (and probe a possibly half-open socket)
|
||||
// immediately instead of waiting out its timer. Deps stay empty so this
|
||||
// fires per focus transition, not per connection-state change; nudging
|
||||
// on every reconnecting↔connecting flip would defeat the backoff.
|
||||
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
|
||||
clientRef.current?.notifyForeground()
|
||||
}, [])
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// The embedded sidebar drives its own polling below; focus never fires
|
||||
// for it since it isn't a routed screen.
|
||||
// The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below.
|
||||
if (embedded || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
// Pull desktop's shared view settings on focus so desktop-side changes
|
||||
// show up here without a manual refresh.
|
||||
// Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh.
|
||||
void syncViewSettingsFromDesktop()
|
||||
// Why: React Navigation keeps previous stack screens mounted; only
|
||||
// poll the host list while this route is visible.
|
||||
// Why: React Navigation keeps prior screens mounted; only poll while this route is visible.
|
||||
const interval = setInterval(() => {
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
@@ -557,9 +513,7 @@ export function HostScreen({
|
||||
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
)
|
||||
|
||||
// Why: as the persistent tablet sidebar this list is never the focused
|
||||
// route, so useFocusEffect won't fetch/poll. Mirror that behavior from a
|
||||
// plain mount effect while connected instead.
|
||||
// Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect.
|
||||
useEffect(() => {
|
||||
if (!embedded || connState !== 'connected') {
|
||||
return
|
||||
@@ -574,10 +528,7 @@ export function HostScreen({
|
||||
return () => clearInterval(interval)
|
||||
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
|
||||
// Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to
|
||||
// useWorktreeResync so this screen stays under its max-lines budget. The
|
||||
// steady-state focus/embedded polls don't cover the transition INTO
|
||||
// 'connected' after a background/sleep, which is when the cache is stalest.
|
||||
// Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
|
||||
const { refreshing, onRefresh } = useWorktreeResync({
|
||||
client,
|
||||
connState,
|
||||
@@ -669,8 +620,7 @@ export function HostScreen({
|
||||
await removeHostAndCloseClient(hostId, closeHostClient)
|
||||
leaveHost()
|
||||
} catch {
|
||||
// Why: metadata commit can fail while the host is still paired; keep the
|
||||
// screen mounted and re-open confirm (ConfirmModal closes on confirm).
|
||||
// Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
|
||||
setConfirmRemoveHost(true)
|
||||
Alert.alert('Could not remove host', 'Please try again.')
|
||||
}
|
||||
@@ -697,11 +647,9 @@ export function HostScreen({
|
||||
|
||||
const openWorktreeSession = useCallback(
|
||||
(item: Worktree) => {
|
||||
// Highlight the row immediately; the next worktree.ps poll confirms it.
|
||||
setOptimisticActiveWorktreeId(item.worktreeId)
|
||||
if (client && connState === 'connected') {
|
||||
// Why: opening a mobile session should hydrate host-owned tabs without
|
||||
// pulling other paired clients, especially desktop, into this worktree.
|
||||
// Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree.
|
||||
void client
|
||||
.sendRequest('worktree.activate', {
|
||||
worktree: `id:${item.worktreeId}`,
|
||||
@@ -786,8 +734,7 @@ export function HostScreen({
|
||||
const slept = sleptIds.has(w.worktreeId)
|
||||
? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
|
||||
: null
|
||||
// Force the just-opened worktree active (and the rest inactive) until the
|
||||
// next poll confirms it, so the highlight doesn't lag the navigation.
|
||||
// Force the just-opened worktree active until the next poll confirms it, so the highlight doesn't lag.
|
||||
const active =
|
||||
optimisticActiveWorktreeId !== null
|
||||
? { isActive: w.worktreeId === optimisticActiveWorktreeId }
|
||||
@@ -867,12 +814,7 @@ export function HostScreen({
|
||||
</View>
|
||||
{connState !== 'connected' &&
|
||||
(() => {
|
||||
// Why: status label removed in favor of just the dot +
|
||||
// Reconnect button — the home screen already surfaces the
|
||||
// verdict text per host, and the dot color already
|
||||
// signals severity here. Auth-failed routes through its
|
||||
// dedicated banner so we still want to suppress the
|
||||
// Reconnect button for that case.
|
||||
// Why: auth-failed has its own banner, so suppress the Reconnect button for that verdict.
|
||||
const verdict = headerVerdict
|
||||
const isError = isErrorVerdict(verdict)
|
||||
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
|
||||
@@ -1154,8 +1096,7 @@ export function HostScreen({
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search worktrees…"
|
||||
autoFocus
|
||||
// Why: new key each open remounts focus effect if the field stays mounted
|
||||
// across rapid toggles; pairs with delayed focus so the keyboard appears.
|
||||
// Why: new key per open remounts the focus effect across rapid toggles so the keyboard reappears.
|
||||
focusKey={showSearch}
|
||||
accessibilityLabel="Search worktrees"
|
||||
/>
|
||||
@@ -1194,13 +1135,10 @@ export function HostScreen({
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
// Why: edge-to-edge — the list scrolls under the system nav bar
|
||||
// while reserving insets.bottom keeps the last worktree row reachable
|
||||
// above the Samsung 3-button nav / iOS home indicator.
|
||||
// Why: edge-to-edge under the system nav bar; insets.bottom keeps the last row above it.
|
||||
contentContainerStyle={[
|
||||
styles.list,
|
||||
// Phone shows a floating "+" button bottom-right; reserve room so the
|
||||
// last row stays tappable above it. Embedded sidebars keep the toolbar +.
|
||||
// Reserve room so the last row stays tappable above the phone's floating "+" (embedded uses the toolbar +).
|
||||
{ paddingBottom: (embedded ? spacing.lg : FAB_SIZE + spacing.xl) + insets.bottom },
|
||||
isWideLayout &&
|
||||
!embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
|
||||
@@ -1240,8 +1178,7 @@ export function HostScreen({
|
||||
)
|
||||
}}
|
||||
ItemSeparatorComponent={ListSeparator}
|
||||
// Why (#8498): manual pull-to-refresh forces a fresh worktree
|
||||
// snapshot after a reconnect or whenever the cache looks stale.
|
||||
// Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect.
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
@@ -1466,10 +1403,7 @@ export function HostScreen({
|
||||
)
|
||||
}
|
||||
|
||||
// Default route export. On wide tablet/foldable canvases the worktree list is
|
||||
// rendered as a persistent sidebar by the host layout, so the route itself
|
||||
// becomes the empty detail pane until a workspace is opened. On phones it is
|
||||
// the full-screen worktree list as before.
|
||||
// On wide layouts the sidebar hosts the list, so this route is just the empty detail pane.
|
||||
export default function HostWorktreeRoute() {
|
||||
const { isWideLayout } = useResponsiveLayout()
|
||||
if (isWideLayout) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Pressable, View } from 'react-native'
|
||||
import { SquareChevronRight } from 'lucide-react-native'
|
||||
|
||||
import { colors } from '../../../../src/theme/mobile-theme'
|
||||
import { styles } from './mobile-session-styles'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
export function QuickCommandsTabButton({ disabled, onPress }: Props) {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.tabActionDivider} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.newTerminalButton,
|
||||
pressed && styles.newTerminalButtonPressed,
|
||||
disabled && styles.newTerminalButtonDisabled
|
||||
]}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
accessibilityLabel="Quick commands"
|
||||
>
|
||||
<SquareChevronRight size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -135,6 +135,13 @@ export const mobileSessionFrameStyles = StyleSheet.create({
|
||||
newTerminalButtonDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
// Divider between the + new-terminal button and the Quick Commands launcher,
|
||||
// matching the tab strip's borderSubtle separators.
|
||||
tabActionDivider: {
|
||||
width: StyleSheet.hairlineWidth,
|
||||
height: 18,
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
terminalFrame: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
+24
-86
@@ -123,10 +123,7 @@ function formatDuration(ms: number): string {
|
||||
return `${totalMinutes}m`
|
||||
}
|
||||
|
||||
// Why: derive a stable per-instance identity for RpcClient so the wireUp
|
||||
// effect's dep key changes when forceReconnect swaps the underlying client
|
||||
// for a host (without this, listeners stay attached to the closed client
|
||||
// and notifications/accounts subs never re-attach).
|
||||
// Why: stable per-instance RpcClient identity so wireUp's dep key changes when forceReconnect swaps the client, re-attaching listeners.
|
||||
const clientIdentities = new WeakMap<RpcClient, number>()
|
||||
let nextClientIdentity = 1
|
||||
function clientKey(client: RpcClient): number {
|
||||
@@ -164,11 +161,7 @@ function fetchWorktreeInfo(
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
// Why: only seed an empty zeroed entry when this host has no prior info
|
||||
// at all (e.g., first ever load before any cache hydration). On a
|
||||
// transient failure for a host that already has cached data, leave the
|
||||
// cached entry alone so the Resume card and host-meta line don't
|
||||
// momentarily flip to "0 worktrees" / disappear during reconnects.
|
||||
// Why: only seed a zeroed entry when the host has no prior info; keep cached data on transient failure so counts don't flip to 0 during reconnects.
|
||||
const markLoadedIfMissing = () => {
|
||||
setInfo((prev) => {
|
||||
if (prev[hostId]) {
|
||||
@@ -187,8 +180,7 @@ function fetchWorktreeInfo(
|
||||
}
|
||||
|
||||
client
|
||||
// Why: worktree.ps defaults to 200 and silently truncates; request the full
|
||||
// set so the host worktree count and active count are accurate.
|
||||
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
|
||||
.sendRequest('worktree.ps', { limit: 10000 })
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
@@ -286,8 +278,7 @@ function fetchTaskProviders(
|
||||
})
|
||||
}
|
||||
|
||||
// Why: repo names get a stable color derived from hashing, matching the
|
||||
// host detail page's colored dots for visual consistency.
|
||||
// Why: hash repo name to a stable color, matching the host detail page's dots.
|
||||
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
||||
function repoColor(name: string): string {
|
||||
let hash = 0
|
||||
@@ -300,8 +291,7 @@ function repoColor(name: string): string {
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: cap and center content on wide/tablet canvases so cards don't stretch
|
||||
// edge-to-edge on iPad; on phones isWideLayout is false and layout is unchanged.
|
||||
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
||||
@@ -318,9 +308,7 @@ export default function HomeScreen() {
|
||||
)
|
||||
const notificationOptInCheckedRef = useRef(false)
|
||||
|
||||
// Why: read shared clients from the per-host store. Replaces the prior
|
||||
// pattern of opening N independent WebSockets here. See
|
||||
// docs/mobile-shared-client-per-host.md.
|
||||
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
const allClients = useAllHostClients(hostIds)
|
||||
const hostPaths = useMemo(
|
||||
@@ -330,27 +318,20 @@ export default function HomeScreen() {
|
||||
const closeHostClient = useCloseHost()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const primeHosts = usePrimeHosts()
|
||||
// Why: feed the loaded HostProfiles into the provider's prime cache as
|
||||
// soon as we have them. This avoids a second Keychain pass inside
|
||||
// openEntry on cold start (which serialised behind the first one and
|
||||
// showed up as multi-second connect latency).
|
||||
// Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start.
|
||||
useEffect(() => {
|
||||
if (hosts.length > 0) {
|
||||
primeHosts(hosts)
|
||||
}
|
||||
}, [hosts, primeHosts])
|
||||
const allClientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
|
||||
// Why: the focus callback stays stable to avoid refetching on every
|
||||
// client-store render, but it still needs the latest host clients.
|
||||
// Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients.
|
||||
allClientsRef.current = allClients.map((entry) => ({
|
||||
hostId: entry.hostId,
|
||||
client: entry.client
|
||||
}))
|
||||
|
||||
// Why: hydrate the home page from a persisted snapshot on cold-start so
|
||||
// Resume + Account-usage cards paint immediately with last-known data
|
||||
// instead of flashing empty for ~1s while the WebSocket reconnects.
|
||||
// Stream/list responses overwrite this seed in place when they arrive.
|
||||
// Why: hydrate from a persisted snapshot on cold-start so Resume + Account cards paint immediately instead of flashing empty.
|
||||
const hydratedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (hydratedRef.current) {
|
||||
@@ -367,8 +348,7 @@ export default function HomeScreen() {
|
||||
for (const [hostId, info] of Object.entries(snap.worktreeInfo)) {
|
||||
const wt = info.lastActiveWorktree
|
||||
if (wt) {
|
||||
// Why: also seed the in-memory worktree cache so resumeWorktree's
|
||||
// lastVisited fast-path can find the cached worktree object.
|
||||
// Why: seed the in-memory cache so resumeWorktree's lastVisited fast-path finds the worktree object.
|
||||
setCachedWorktrees(hostId, [wt])
|
||||
}
|
||||
}
|
||||
@@ -378,9 +358,7 @@ export default function HomeScreen() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: persist the merged snapshot whenever either piece updates so the
|
||||
// next cold-start has fresh seed data. The cache module debounces writes
|
||||
// internally so a flurry of streamed updates doesn't hammer disk.
|
||||
// Why: persist the merged snapshot on each update so the next cold-start has fresh seed data (cache debounces writes).
|
||||
useEffect(() => {
|
||||
if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) {
|
||||
return
|
||||
@@ -436,8 +414,7 @@ export default function HomeScreen() {
|
||||
[hosts]
|
||||
)
|
||||
|
||||
// Why: mirror per-host connection state into hostStates so existing
|
||||
// render code (status dots, connecting indicators) keeps working.
|
||||
// Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
|
||||
useEffect(() => {
|
||||
setHostAttempts((prev) => {
|
||||
const next: Record<string, number> = { ...prev }
|
||||
@@ -473,11 +450,7 @@ export default function HomeScreen() {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// Why: when a paired host disappears from allClients (because the
|
||||
// user tapped Disconnect, or the host record was invalid) the card
|
||||
// must reflect that. We only force-update hosts whose state was
|
||||
// already tracked — otherwise the initial-acquire frame (entry not
|
||||
// yet materialised) would briefly flip every host to 'disconnected'.
|
||||
// Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'.
|
||||
for (const host of hosts) {
|
||||
if (liveIds.has(host.id)) {
|
||||
continue
|
||||
@@ -506,11 +479,7 @@ export default function HomeScreen() {
|
||||
})
|
||||
}, [allClients, hosts])
|
||||
|
||||
// Why: per-host streaming subscriptions (notifications + accounts) and
|
||||
// one-shot stats fetches when each host transitions to 'connected'.
|
||||
// Runs once per (hostId, client) pair and tears down when that pair
|
||||
// changes. The provider keeps the underlying socket open across
|
||||
// resubscription cycles so this is cheap.
|
||||
// Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap.
|
||||
useEffect(() => {
|
||||
const cleanups: Array<() => void> = []
|
||||
for (const entry of allClients) {
|
||||
@@ -563,12 +532,7 @@ export default function HomeScreen() {
|
||||
c()
|
||||
}
|
||||
}
|
||||
// Why: depend on the host-id set AND each entry's client identity, so
|
||||
// resubscriptions don't fire on every render that produces a new
|
||||
// array reference, but DO fire when forceReconnect swaps the
|
||||
// underlying client for a host (otherwise wireUp would keep firing
|
||||
// on a closed client and never re-attach to the fresh one, leaving
|
||||
// notifications/accounts subs broken until the user navigates).
|
||||
// Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
allClients
|
||||
@@ -577,22 +541,10 @@ export default function HomeScreen() {
|
||||
.join(',')
|
||||
])
|
||||
|
||||
// Why: prefer the worktree the user last opened on this device so the
|
||||
// "Resume" card reflects their mobile session history, not just the
|
||||
// desktop's most-recently-outputting worktree.
|
||||
// Why: rendering used to be gated on hostStates === 'connected', which
|
||||
// caused the Resume card to vanish for ~1s on every cold-start /
|
||||
// resume-from-background while the WebSocket reconnected, even though we
|
||||
// had perfectly good cached worktree data. Now the card stays visible as
|
||||
// long as we have a cached lastActiveWorktree for any known host; the
|
||||
// tap target is still the same and a fresher snapshot from the live RPC
|
||||
// overwrites the card's contents in place when it lands.
|
||||
// Why: prefer the worktree last opened on this device so Resume reflects mobile session history.
|
||||
// Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC lands.
|
||||
const resumeWorktree = useMemo(() => {
|
||||
// Why: only surface Resume for hosts that are currently connected.
|
||||
// Showing a stale cached worktree for a disconnected host is
|
||||
// misleading — the user would tap into a session route that can't
|
||||
// load anything until the host reconnects. Once the host reconnects,
|
||||
// the card reappears with fresh data.
|
||||
// Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load.
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
@@ -612,9 +564,7 @@ export default function HomeScreen() {
|
||||
return null
|
||||
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
||||
|
||||
// Why: only show the Account usage section for hosts that are currently
|
||||
// connected. Showing stale cached usage for a disconnected host implies
|
||||
// live data; better to hide until the host reconnects and we can refresh.
|
||||
// Why: only show Account usage for connected hosts; stale cached usage would imply live data.
|
||||
const accountsHosts = useMemo(() => {
|
||||
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
|
||||
for (const host of sortedHosts) {
|
||||
@@ -625,9 +575,7 @@ export default function HomeScreen() {
|
||||
if (!snap) {
|
||||
continue
|
||||
}
|
||||
// Why: also show hosts whose only usage is the system-default login
|
||||
// (no Orca-managed accounts but live rate-limit data for the active
|
||||
// target), otherwise system-default users see no usage section at all.
|
||||
// Why: also show hosts whose only usage is the system-default login, else those users see no usage section.
|
||||
if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) {
|
||||
items.push({ host, snapshot: snap })
|
||||
}
|
||||
@@ -716,8 +664,7 @@ export default function HomeScreen() {
|
||||
setConfirmRemove(null)
|
||||
setHosts(await loadHosts())
|
||||
} catch {
|
||||
// Why: ConfirmModal closes on confirm; re-open for retry and surface the
|
||||
// failure instead of silently leaving the host listed.
|
||||
// Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent.
|
||||
setConfirmRemove(hostToRemove)
|
||||
Alert.alert('Could not remove host', 'Please try again.')
|
||||
}
|
||||
@@ -782,9 +729,7 @@ export default function HomeScreen() {
|
||||
<FlatList
|
||||
data={sortedHosts}
|
||||
keyExtractor={(h) => h.id}
|
||||
// Why: edge-to-edge — let the list scroll under the system nav bar
|
||||
// but reserve insets.bottom so the last row stays reachable above
|
||||
// the Samsung 3-button nav / iOS home indicator.
|
||||
// Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator.
|
||||
contentContainerStyle={[
|
||||
styles.list,
|
||||
{ paddingBottom: spacing.xl + insets.bottom },
|
||||
@@ -961,10 +906,7 @@ export default function HomeScreen() {
|
||||
? snapshot.claude.accounts
|
||||
: snapshot.codex.accounts
|
||||
const limits = getActiveProviderRateLimits(snapshot, provider)
|
||||
// Why: with no managed accounts, still render a
|
||||
// "System default" row when the active target has
|
||||
// live usage data; the row label already falls back
|
||||
// to "System default" below.
|
||||
// Why: with no managed accounts, still render the row when the active target has live usage data.
|
||||
if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
|
||||
return null
|
||||
}
|
||||
@@ -1027,11 +969,7 @@ export default function HomeScreen() {
|
||||
state === 'connecting' ||
|
||||
state === 'handshaking' ||
|
||||
state === 'reconnecting'
|
||||
// Why: "Reconnect" implies "you were connected, try again". If
|
||||
// the client has never reached 'connected' this session (cold
|
||||
// start, unreachable host, or after Disconnect) the action is
|
||||
// functionally a fresh Connect — using the right verb makes
|
||||
// the affordance match what tapping it actually does.
|
||||
// Why: label "Connect" (not "Reconnect") when never connected this session, so the verb matches the action.
|
||||
const hasEverConnected = (hostLastConnected[host.id] ?? null) != null
|
||||
const items: ActionSheetAction[] = []
|
||||
items.push({
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
DESKTOP_PROTOCOL_VERSION,
|
||||
MIN_COMPATIBLE_MOBILE_VERSION
|
||||
} from '../../src/shared/protocol-version'
|
||||
import {
|
||||
applyTerminalQuickCommandMutation,
|
||||
type TerminalQuickCommandMutation
|
||||
} from '../../src/shared/terminal-quick-commands'
|
||||
import type { TerminalQuickCommand } from '../../src/shared/types'
|
||||
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
|
||||
import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { FAKE_SCROLLBACK, STREAMING_CHUNKS } from './mock-server-terminal-fixtures'
|
||||
@@ -15,6 +20,27 @@ const MOCK_RPC_DELAY_MS = readScenarioNumber('MOCK_RPC_DELAY_MS', 0)
|
||||
const FAKE_REPOS = createMockRepos(MOCK_REPO_COUNT)
|
||||
let fakeWorktrees = createMockWorktrees(FAKE_REPOS, MOCK_WORKTREE_COUNT)
|
||||
|
||||
// Mutable quick-command list so the mobile Quick Commands sheet can add/edit/
|
||||
// delete against the mock the same way it does a paired desktop.
|
||||
let fakeQuickCommands: TerminalQuickCommand[] = [
|
||||
{
|
||||
id: 'qc-codex-review',
|
||||
label: 'codex review',
|
||||
action: 'agent-prompt',
|
||||
agent: 'codex',
|
||||
prompt: 'please review this diff for correctness and edge cases.',
|
||||
scope: { type: 'global' }
|
||||
},
|
||||
{
|
||||
id: 'qc-dev-server',
|
||||
label: 'dev server',
|
||||
action: 'terminal-command',
|
||||
command: 'pnpm dev',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
}
|
||||
]
|
||||
|
||||
const FAKE_TERMINALS = [
|
||||
{
|
||||
handle: 'term-1',
|
||||
@@ -145,6 +171,19 @@ export function handleRequest(
|
||||
)
|
||||
break
|
||||
|
||||
case 'settings.getTerminalQuickCommands':
|
||||
respond(success(request.id, { terminalQuickCommands: fakeQuickCommands }))
|
||||
break
|
||||
|
||||
case 'settings.updateTerminalQuickCommands': {
|
||||
const updates = (request.params ?? {}) as { mutation?: TerminalQuickCommandMutation }
|
||||
if (updates.mutation) {
|
||||
fakeQuickCommands = applyTerminalQuickCommandMutation(fakeQuickCommands, updates.mutation)
|
||||
}
|
||||
respond(success(request.id, { terminalQuickCommands: fakeQuickCommands }))
|
||||
break
|
||||
}
|
||||
|
||||
case 'ui.get':
|
||||
respond(
|
||||
success(request.id, {
|
||||
|
||||
@@ -17,9 +17,7 @@ type NotificationEvent = {
|
||||
body: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
// Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used
|
||||
// for reconnect catch-up (#8129). Optional because older runtimes / non-
|
||||
// replay events may omit it.
|
||||
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
@@ -42,12 +40,7 @@ type ScheduledNotificationState = {
|
||||
|
||||
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
|
||||
|
||||
// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId),
|
||||
// so every agent-task-complete inserts a new, never-reused key. Entries are only
|
||||
// removed when the desktop sends a matching dismiss — which a remote mobile user
|
||||
// (not at the desktop) frequently never gets — so the map grew for the app's whole
|
||||
// life. Bound it; a settled entry only retains a small identifier used for later
|
||||
// programmatic dismissal, unnecessary for long-past completions.
|
||||
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
|
||||
const MAX_SCHEDULED_NOTIFICATIONS = 256
|
||||
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
|
||||
|
||||
@@ -55,8 +48,7 @@ function getStoredNotificationKey(hostId: string, notificationId: string): strin
|
||||
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
|
||||
}
|
||||
|
||||
// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap.
|
||||
// Map iteration is insertion order, so the first match is the oldest.
|
||||
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
|
||||
function boundScheduledNotifications(): void {
|
||||
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
|
||||
let evicted = false
|
||||
@@ -91,16 +83,13 @@ export async function getNotificationPermissionState(): Promise<NotificationPerm
|
||||
granted: status === 'granted',
|
||||
status,
|
||||
canAskAgain,
|
||||
// Why: Android before API 33 has no runtime notification permission, so
|
||||
// Expo's default "granted" state is capability evidence, not user consent.
|
||||
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
|
||||
authorizationReflectsUserChoice:
|
||||
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: permissions must be requested before scheduling any local notification.
|
||||
// Read the OS state every time because users can change it in Settings while
|
||||
// Orca remains alive in the background.
|
||||
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
|
||||
export async function ensureNotificationPermissions(): Promise<boolean> {
|
||||
const existing = await getNotificationPermissionState()
|
||||
if (existing.granted) {
|
||||
@@ -225,8 +214,7 @@ async function dismissLocalNotification(
|
||||
return
|
||||
}
|
||||
if (state.pending) {
|
||||
// Why: desktop can send dismiss while iOS/Android is still scheduling the
|
||||
// matching local notification. Remember it so no stale banner survives.
|
||||
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
|
||||
state.dismissAfterSchedule = true
|
||||
return
|
||||
}
|
||||
@@ -237,29 +225,15 @@ async function dismissLocalNotification(
|
||||
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
|
||||
}
|
||||
|
||||
// Why: each host connection gets its own notification subscription. When the
|
||||
// connection drops, the unsubscribe function cleans up the streaming RPC.
|
||||
// On reconnect the same subscribe stream is re-established by the RPC client;
|
||||
// we use its `ready` event to trigger catch-up (#8129): fetch notifications
|
||||
// dispatched while the socket was reaped, watermarked by the last seq we
|
||||
// already delivered so the desktop never re-sends an already-pushed one.
|
||||
// Returns an unsubscribe function.
|
||||
// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
|
||||
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
|
||||
configureNotificationChannel()
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
let disposed = false
|
||||
// Highest seq delivered on the live stream or replay for this connection.
|
||||
// Persisted per-host so a cold app start still resumes from the right cut.
|
||||
// Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut.
|
||||
let lastDeliveredSeq = 0
|
||||
// Why: per-connection dedup guard applied ONLY to the replay path
|
||||
// (fetchMissed), never the live stream. The desktop already guarantees the
|
||||
// replay cannot contain an event with seq <= lastDeliveredSeq (both live and
|
||||
// replay advance the same watermark), so live + replay never overlap there.
|
||||
// This set is defense-in-depth: if the desktop's bounded buffer evicted an
|
||||
// old entry and a reconnect re-fetches across a boundary, an id delivered in
|
||||
// the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a
|
||||
// long-lived session can't grow without limit.
|
||||
// Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary.
|
||||
const seenReplay = createSeenNotificationGuard()
|
||||
|
||||
function deliverLive(
|
||||
@@ -270,10 +244,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
lastDeliveredSeq = event.notificationSeq
|
||||
void saveLastSeenSeq(hostId, lastDeliveredSeq)
|
||||
}
|
||||
// Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND
|
||||
// replay) so a replay that re-includes an id already pushed live in this
|
||||
// connection is dropped instead of double-pushed. fetchMissed also
|
||||
// pre-checks seenReplay, but without this the live path never populated it.
|
||||
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
@@ -284,12 +255,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
}
|
||||
|
||||
// Why: on a reconnect `ready` the desktop has already dispatched whatever we
|
||||
// missed; ask for it from our persisted watermark. Because the desktop cuts
|
||||
// by seq > lastSeenSeq this is idempotent — we only ever get events we have
|
||||
// not delivered before. The seenReplay guard is a second layer so a replay
|
||||
// that somehow re-includes an id already delivered this connection is
|
||||
// dropped instead of double-pushed.
|
||||
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap).
|
||||
async function fetchMissed(): Promise<void> {
|
||||
if (disposed) {
|
||||
return
|
||||
@@ -321,10 +287,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
}
|
||||
}
|
||||
|
||||
// Why: lazily seed the watermark from durable storage on first use so we
|
||||
// don't block subscribe() on an AsyncStorage read. The first `ready` (cold
|
||||
// open) does NOT need catch-up — the live stream starts fresh; only
|
||||
// subsequent reconnect `ready` events fetch missed notifications.
|
||||
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
|
||||
let watermarkLoaded = false
|
||||
void loadLastSeenSeq(hostId).then((seq) => {
|
||||
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
|
||||
@@ -352,10 +315,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
unsubscribeStream()
|
||||
return
|
||||
}
|
||||
// Why: first ready is the cold-open live stream — no catch-up needed.
|
||||
// Every later ready is a reconnect; fetch what we missed from the
|
||||
// watermark. Guard on watermarkLoaded so a fast reconnect doesn't
|
||||
// fetch from a stale 0 watermark (which would re-push everything).
|
||||
// Why: only reconnects fetch missed; watermarkLoaded guards against fetching from a stale 0 (which re-pushes everything).
|
||||
if (reconnectReadyCount > 1 && watermarkLoaded) {
|
||||
void fetchMissed()
|
||||
}
|
||||
@@ -379,12 +339,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
// Why: the client may already be closed when this cleanup runs (component
|
||||
// unmount races with disconnect). sendRequest rejects immediately on a
|
||||
// closed client — swallow it since server-side cleanup happens via
|
||||
// connection-close anyway.
|
||||
// Always drop the local stream first; readiness can race unmount and we
|
||||
// must not retain the callback while waiting for a subscription id.
|
||||
// Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending.
|
||||
unsubscribeStream()
|
||||
if (subscriptionId) {
|
||||
unsubscribeServer(subscriptionId)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useState } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import {
|
||||
getQuickCommandAgentLabel,
|
||||
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
|
||||
MAX_QUICK_COMMAND_LABEL_LENGTH,
|
||||
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH
|
||||
} from '../terminal/quick-commands'
|
||||
import type { QuickCommandDraft } from './quick-command-draft'
|
||||
import { isQuickCommandDraftValid } from './quick-command-draft'
|
||||
|
||||
type Props = {
|
||||
draft: QuickCommandDraft
|
||||
mode: 'add' | 'edit'
|
||||
saving: boolean
|
||||
error: string | null
|
||||
// A mobile session lives in one repo, so "Project" scope means the current
|
||||
// worktree's repo — no cross-repo picker like desktop.
|
||||
repoId: string | null
|
||||
repoName: string | null
|
||||
onChange: (patch: Partial<QuickCommandDraft>) => void
|
||||
onOpenAgentPicker: () => void
|
||||
onCancel: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function ActionToggle({
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
value: QuickCommandDraft['action']
|
||||
onChange: (action: QuickCommandDraft['action']) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.toggleGroup}>
|
||||
{(['terminal-command', 'agent-prompt'] as const).map((action) => {
|
||||
const selected = value === action
|
||||
return (
|
||||
<Pressable
|
||||
key={action}
|
||||
style={({ pressed }) => [
|
||||
styles.toggleItem,
|
||||
selected && styles.toggleItemSelected,
|
||||
pressed && !selected && styles.pressed
|
||||
]}
|
||||
onPress={() => onChange(action)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<Text style={[styles.toggleText, selected && styles.toggleTextSelected]}>
|
||||
{action === 'terminal-command' ? 'Terminal Command' : 'Agent Prompt'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuickCommandEditorForm({
|
||||
draft,
|
||||
mode,
|
||||
saving,
|
||||
error,
|
||||
repoId,
|
||||
repoName,
|
||||
onChange,
|
||||
onOpenAgentPicker,
|
||||
onCancel,
|
||||
onSave
|
||||
}: Props) {
|
||||
const hasRepoScope = repoId !== null
|
||||
const [advancedOpen, setAdvancedOpen] = useState(draft.scope.type === 'repo')
|
||||
const isAgent = draft.action === 'agent-prompt'
|
||||
const canSave = isQuickCommandDraftValid(draft) && !saving
|
||||
|
||||
return (
|
||||
<View style={styles.form}>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Label</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={draft.label}
|
||||
onChangeText={(label) => onChange({ label })}
|
||||
placeholder="Start dev server"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={MAX_QUICK_COMMAND_LABEL_LENGTH}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Action</Text>
|
||||
<ActionToggle value={draft.action} onChange={(action) => onChange({ action })} />
|
||||
</View>
|
||||
|
||||
{isAgent ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Agent</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.select, pressed && styles.pressed]}
|
||||
onPress={onOpenAgentPicker}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{draft.agent ? (
|
||||
<View style={styles.selectValue}>
|
||||
<MobileAgentIcon agentId={draft.agent} size={16} />
|
||||
<Text style={styles.selectValueText}>{getQuickCommandAgentLabel(draft.agent)}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.selectPlaceholder}>Choose agent</Text>
|
||||
)}
|
||||
<ChevronDown size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{isAgent ? 'Prompt' : 'Command Text'}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textarea, !isAgent && styles.mono]}
|
||||
value={isAgent ? draft.prompt : draft.command}
|
||||
onChangeText={(text) => onChange(isAgent ? { prompt: text } : { command: text })}
|
||||
placeholder={isAgent ? 'Ask the agent to investigate this workspace' : 'npm run dev'}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
multiline
|
||||
maxLength={
|
||||
isAgent ? MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH : MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH
|
||||
}
|
||||
/>
|
||||
{isAgent ? (
|
||||
<Text style={styles.hint}>Supports skills, file paths, and built-in commands.</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.advancedToggle, pressed && styles.pressed]}
|
||||
onPress={() => setAdvancedOpen((open) => !open)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: advancedOpen }}
|
||||
>
|
||||
{advancedOpen ? (
|
||||
<ChevronDown size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
</Pressable>
|
||||
|
||||
{advancedOpen ? (
|
||||
<View style={styles.advancedBody}>
|
||||
{!isAgent ? (
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchText}>
|
||||
<Text style={styles.switchTitle}>Append Enter</Text>
|
||||
<Text style={styles.switchDesc}>
|
||||
Submit immediately instead of only inserting text.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={draft.appendEnter}
|
||||
onValueChange={(appendEnter) => onChange({ appendEnter })}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.accentBlue }}
|
||||
thumbColor={colors.surfaceBright}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Scope</Text>
|
||||
<View style={styles.toggleGroup}>
|
||||
{(['global', 'repo'] as const).map((scopeType) => {
|
||||
const selected = draft.scope.type === scopeType
|
||||
const disabled = scopeType === 'repo' && !hasRepoScope
|
||||
return (
|
||||
<Pressable
|
||||
key={scopeType}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.toggleItem,
|
||||
selected && styles.toggleItemSelected,
|
||||
disabled && styles.toggleItemDisabled,
|
||||
pressed && !selected && !disabled && styles.pressed
|
||||
]}
|
||||
onPress={() =>
|
||||
onChange({
|
||||
scope:
|
||||
scopeType === 'repo' && repoId
|
||||
? { type: 'repo', repoId }
|
||||
: { type: 'global' }
|
||||
})
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected, disabled }}
|
||||
>
|
||||
<Text style={[styles.toggleText, selected && styles.toggleTextSelected]}>
|
||||
{scopeType === 'global' ? 'Global' : 'Project'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
{draft.scope.type === 'repo' && repoName ? (
|
||||
<Text style={styles.scopeRepoName}>{repoName}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
|
||||
onPress={onCancel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.saveButton, !canSave && styles.saveButtonDisabled]}
|
||||
disabled={!canSave}
|
||||
onPress={onSave}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={[styles.saveText, !canSave && styles.saveTextDisabled]}>
|
||||
{mode === 'edit' ? 'Save' : 'Add Quick Command'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
form: { gap: spacing.md, paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
field: { gap: spacing.sm },
|
||||
label: { fontSize: 12, fontWeight: '600', color: colors.textSecondary },
|
||||
input: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
fontSize: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
textarea: { minHeight: 92, textAlignVertical: 'top' },
|
||||
mono: { fontFamily: typography.monoFamily },
|
||||
hint: { fontSize: 12, color: colors.textMuted },
|
||||
error: { fontSize: 13, color: colors.statusRed, marginTop: spacing.xs },
|
||||
pressed: { backgroundColor: colors.bgRaised },
|
||||
toggleGroup: { flexDirection: 'row', gap: spacing.sm },
|
||||
toggleItem: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
toggleItemSelected: { backgroundColor: colors.bgRaised, borderColor: colors.textMuted },
|
||||
toggleItemDisabled: { opacity: 0.4 },
|
||||
toggleText: { fontSize: 13, fontWeight: '500', color: colors.textSecondary },
|
||||
toggleTextSelected: { color: colors.textPrimary },
|
||||
select: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2
|
||||
},
|
||||
selectValue: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
selectValueText: { fontSize: 14, color: colors.textPrimary },
|
||||
selectPlaceholder: { fontSize: 14, color: colors.textMuted },
|
||||
scopeRepoName: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
advancedText: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
|
||||
advancedBody: { gap: spacing.md, paddingTop: spacing.xs },
|
||||
switchRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||
switchText: { flex: 1 },
|
||||
switchTitle: { fontSize: 14, color: colors.textPrimary },
|
||||
switchDesc: { fontSize: 12, color: colors.textMuted, marginTop: 1 },
|
||||
footer: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
|
||||
button: { flex: 1, borderRadius: 8, paddingVertical: spacing.md, alignItems: 'center' },
|
||||
cancelButton: { borderWidth: 1, borderColor: colors.borderSubtle },
|
||||
cancelText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
|
||||
saveButton: { backgroundColor: colors.textPrimary },
|
||||
saveButtonDisabled: { backgroundColor: colors.bgRaised },
|
||||
saveText: { fontSize: 14, fontWeight: '700', color: colors.bgBase },
|
||||
saveTextDisabled: { color: colors.textMuted }
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH, QuickCommandsList } from './QuickCommandsList'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: {
|
||||
create: <T>(styles: T) => styles,
|
||||
hairlineWidth: 1
|
||||
},
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Check: 'Check',
|
||||
Pencil: 'Pencil',
|
||||
Play: 'Play',
|
||||
Plus: 'Plus',
|
||||
Search: 'Search',
|
||||
Trash2: 'Trash2'
|
||||
}))
|
||||
|
||||
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
|
||||
|
||||
describe('QuickCommandsList search', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('keeps an active filter clearable when only one command remains', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(QuickCommandsList, {
|
||||
repoCommands: [],
|
||||
globalCommands: [],
|
||||
totalCount: 1,
|
||||
query: 'missing',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
canAdd: true,
|
||||
error: null,
|
||||
onQueryChange: vi.fn(),
|
||||
onLaunch: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onAdd: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const search = renderer!.root.findByType('TextInput')
|
||||
expect(search.props.value).toBe('missing')
|
||||
expect(search.props.maxLength).toBe(QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH)
|
||||
})
|
||||
|
||||
it('omits idle search chrome for a single command', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(QuickCommandsList, {
|
||||
repoCommands: [],
|
||||
globalCommands: [],
|
||||
totalCount: 1,
|
||||
query: '',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
canAdd: true,
|
||||
error: null,
|
||||
onQueryChange: vi.fn(),
|
||||
onLaunch: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onAdd: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(renderer!.root.findAllByType('TextInput')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native'
|
||||
import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
|
||||
import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
|
||||
import {
|
||||
getQuickCommandDisplayPreview,
|
||||
isAgentQuickCommand,
|
||||
supportsTerminalAgentQuickCommand
|
||||
} from '../terminal/quick-commands'
|
||||
|
||||
export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) =>
|
||||
supportsTerminalAgentQuickCommand(entry.id)
|
||||
)
|
||||
|
||||
export const QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH = 2048
|
||||
|
||||
type ListProps = {
|
||||
repoCommands: TerminalQuickCommand[]
|
||||
globalCommands: TerminalQuickCommand[]
|
||||
totalCount: number
|
||||
query: string
|
||||
loading: boolean
|
||||
disabled: boolean
|
||||
canAdd: boolean
|
||||
error: string | null
|
||||
onQueryChange: (value: string) => void
|
||||
onLaunch: (command: TerminalQuickCommand) => void
|
||||
onEdit: (command: TerminalQuickCommand) => void
|
||||
onDelete: (command: TerminalQuickCommand) => void
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
export function QuickCommandsList({
|
||||
repoCommands,
|
||||
globalCommands,
|
||||
totalCount,
|
||||
query,
|
||||
loading,
|
||||
disabled,
|
||||
canAdd,
|
||||
error,
|
||||
onQueryChange,
|
||||
onLaunch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAdd
|
||||
}: ListProps) {
|
||||
const hasVisible = repoCommands.length + globalCommands.length > 0
|
||||
const addDisabled = disabled || !canAdd
|
||||
// Why: keep an active filter clearable if a delete or paired desktop edit
|
||||
// leaves only one command while the sheet is open.
|
||||
const showSearch = totalCount > 1 || query.length > 0
|
||||
return (
|
||||
<View style={styles.listBody}>
|
||||
{showSearch ? (
|
||||
<View style={styles.search}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={query}
|
||||
onChangeText={onQueryChange}
|
||||
placeholder="Search quick commands..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
// Why: bound pasted text before it reaches the per-keystroke JS
|
||||
// search path; useful queries are far smaller than this budget.
|
||||
maxLength={QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
{loading && !hasVisible ? (
|
||||
<ActivityIndicator style={styles.loading} color={colors.textSecondary} />
|
||||
) : null}
|
||||
|
||||
{!loading && totalCount === 0 ? (
|
||||
<Text style={styles.empty}>No quick commands yet.</Text>
|
||||
) : null}
|
||||
|
||||
{!loading && totalCount > 0 && !hasVisible ? (
|
||||
<Text style={styles.empty}>No matching quick commands.</Text>
|
||||
) : null}
|
||||
|
||||
{repoCommands.length > 0 ? (
|
||||
<QuickCommandGroup
|
||||
label="This project"
|
||||
commands={repoCommands}
|
||||
onLaunch={onLaunch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{globalCommands.length > 0 ? (
|
||||
<QuickCommandGroup
|
||||
label="Global"
|
||||
commands={globalCommands}
|
||||
onLaunch={onLaunch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.addRow,
|
||||
addDisabled && styles.disabled,
|
||||
pressed && !addDisabled && styles.pressed
|
||||
]}
|
||||
disabled={addDisabled}
|
||||
onPress={onAdd}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Plus size={18} color={colors.textSecondary} />
|
||||
<Text style={styles.addText}>
|
||||
{canAdd ? 'New quick command' : 'Quick command limit reached'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickCommandGroup({
|
||||
label,
|
||||
commands,
|
||||
onLaunch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled
|
||||
}: {
|
||||
label: string
|
||||
commands: TerminalQuickCommand[]
|
||||
onLaunch: (command: TerminalQuickCommand) => void
|
||||
onEdit: (command: TerminalQuickCommand) => void
|
||||
onDelete: (command: TerminalQuickCommand) => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.groupLabel}>{label}</Text>
|
||||
<View style={styles.group}>
|
||||
{commands.map((command, index) => (
|
||||
<QuickCommandRow
|
||||
key={command.id}
|
||||
command={command}
|
||||
first={index === 0}
|
||||
onLaunch={onLaunch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickCommandRow({
|
||||
command,
|
||||
first,
|
||||
onLaunch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled
|
||||
}: {
|
||||
command: TerminalQuickCommand
|
||||
first: boolean
|
||||
onLaunch: (command: TerminalQuickCommand) => void
|
||||
onEdit: (command: TerminalQuickCommand) => void
|
||||
onDelete: (command: TerminalQuickCommand) => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
const isAgent = isAgentQuickCommand(command)
|
||||
return (
|
||||
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onLaunch(command)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Run ${command.label}`}
|
||||
>
|
||||
<View style={styles.rowIcon}>
|
||||
{isAgent ? (
|
||||
<MobileAgentIcon agentId={command.agent} size={16} />
|
||||
) : (
|
||||
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{command.label}
|
||||
</Text>
|
||||
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
|
||||
{getQuickCommandDisplayPreview(command)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onEdit(command)}
|
||||
accessibilityLabel={`Edit ${command.label}`}
|
||||
>
|
||||
<Pencil size={15} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onDelete(command)}
|
||||
accessibilityLabel={`Delete ${command.label}`}
|
||||
>
|
||||
<Trash2 size={15} color={colors.statusRed} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuickCommandAgentPicker({
|
||||
selected,
|
||||
onSelect
|
||||
}: {
|
||||
selected: TuiAgent | null
|
||||
onSelect: (agent: TuiAgent) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.group}>
|
||||
{QUICK_COMMAND_SUPPORTED_AGENTS.map((agent, index) => (
|
||||
<Pressable
|
||||
key={agent.id}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
index > 0 && styles.rowBorder,
|
||||
pressed && styles.pressed
|
||||
]}
|
||||
onPress={() => onSelect(agent.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: selected === agent.id }}
|
||||
>
|
||||
<View style={styles.rowIcon}>
|
||||
<MobileAgentIcon agentId={agent.id} size={16} />
|
||||
</View>
|
||||
<Text style={styles.agentLabel}>{agent.label}</Text>
|
||||
{selected === agent.id ? <Check size={16} color={colors.textPrimary} /> : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressed: { backgroundColor: colors.bgRaised },
|
||||
disabled: { opacity: 0.45 },
|
||||
listBody: { gap: spacing.sm, paddingBottom: spacing.sm },
|
||||
search: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
searchInput: { flex: 1, color: colors.textPrimary, fontSize: 14, padding: 0 },
|
||||
error: { color: colors.statusRed, fontSize: 13, paddingHorizontal: spacing.xs },
|
||||
loading: { paddingVertical: spacing.lg },
|
||||
empty: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.lg
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' },
|
||||
row: { flexDirection: 'row', alignItems: 'center' },
|
||||
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingLeft: spacing.md,
|
||||
minWidth: 0
|
||||
},
|
||||
rowIcon: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
rowText: { flex: 1, minWidth: 0 },
|
||||
rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
|
||||
rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
|
||||
mono: { fontFamily: typography.monoFamily },
|
||||
rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' },
|
||||
agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary },
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
addText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands'
|
||||
import { QuickCommandsSheet } from './QuickCommandsSheet'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
alert: vi.fn(),
|
||||
commands: [] as TerminalQuickCommand[],
|
||||
persist: vi.fn()
|
||||
}))
|
||||
const quickCommandEditorForm = 'QuickCommandEditorForm'
|
||||
const quickCommandsList = 'QuickCommandsList'
|
||||
const command: TerminalQuickCommand = {
|
||||
id: 'command',
|
||||
label: 'Test',
|
||||
action: 'terminal-command',
|
||||
command: 'pnpm test',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
}
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Alert: { alert: mocks.alert },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'ChevronLeft' }))
|
||||
|
||||
vi.mock('../components/BottomDrawer', () => ({
|
||||
BottomDrawer: ({ children }: { children: ReactNode }) => children
|
||||
}))
|
||||
|
||||
vi.mock('./QuickCommandEditorForm', () => ({
|
||||
QuickCommandEditorForm: 'QuickCommandEditorForm'
|
||||
}))
|
||||
|
||||
vi.mock('./QuickCommandsList', () => ({
|
||||
QuickCommandAgentPicker: 'QuickCommandAgentPicker',
|
||||
QuickCommandsList: 'QuickCommandsList'
|
||||
}))
|
||||
|
||||
vi.mock('./use-quick-commands', () => ({
|
||||
useQuickCommands: () => ({
|
||||
commands: mocks.commands,
|
||||
loading: false,
|
||||
ready: true,
|
||||
error: null,
|
||||
persist: mocks.persist
|
||||
})
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('QuickCommandsSheet', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let consoleSpy: MockInstance
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
mocks.alert.mockReset()
|
||||
mocks.commands = []
|
||||
mocks.persist.mockReset()
|
||||
const originalConsoleError = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the sheet open when a launch is rejected', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onLaunch = vi.fn(() => false)
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(QuickCommandsSheet, {
|
||||
visible: true,
|
||||
onClose,
|
||||
client: {} as RpcClient,
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
onLaunch
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
act(() => renderer!.root.findByType(quickCommandsList).props.onLaunch(command))
|
||||
|
||||
expect(onLaunch).toHaveBeenCalledWith(command)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits one full-list mutation for a same-frame double tap', async () => {
|
||||
const save = deferred<boolean>()
|
||||
mocks.persist.mockReturnValue(save.promise)
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(QuickCommandsSheet, {
|
||||
visible: true,
|
||||
onClose: vi.fn(),
|
||||
client: {} as RpcClient,
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
onLaunch: () => true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
act(() => renderer!.root.findByType(quickCommandsList).props.onAdd())
|
||||
const editor = renderer!.root.findByType(quickCommandEditorForm)
|
||||
act(() => {
|
||||
editor.props.onChange({ label: 'Test' })
|
||||
editor.props.onChange({ command: 'pnpm test' })
|
||||
})
|
||||
const readyEditor = renderer!.root.findByType(quickCommandEditorForm)
|
||||
act(() => {
|
||||
readyEditor.props.onSave()
|
||||
readyEditor.props.onSave()
|
||||
})
|
||||
|
||||
expect(mocks.persist).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
save.resolve(true)
|
||||
await save.promise
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps creation closed when the host command limit is reached', async () => {
|
||||
mocks.commands = Array.from({ length: MAX_QUICK_COMMANDS }, (_, index) => ({
|
||||
...command,
|
||||
id: `command-${index}`
|
||||
}))
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(QuickCommandsSheet, {
|
||||
visible: true,
|
||||
onClose: vi.fn(),
|
||||
client: {} as RpcClient,
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
onLaunch: () => true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const list = renderer!.root.findByType(quickCommandsList)
|
||||
expect(list.props.canAdd).toBe(false)
|
||||
act(() => list.props.onAdd())
|
||||
expect(renderer!.root.findAllByType(quickCommandEditorForm)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows only global commands and defaults new commands to global without a repo', async () => {
|
||||
const repoCommand: TerminalQuickCommand = {
|
||||
...command,
|
||||
id: 'repo-command',
|
||||
scope: { type: 'repo', repoId: 'repo-1' }
|
||||
}
|
||||
mocks.commands = [command, repoCommand]
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(QuickCommandsSheet, {
|
||||
visible: true,
|
||||
onClose: vi.fn(),
|
||||
client: {} as RpcClient,
|
||||
repoId: null,
|
||||
repoName: 'Folder workspace',
|
||||
onLaunch: () => true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const list = renderer!.root.findByType(quickCommandsList)
|
||||
expect(list.props.globalCommands).toEqual([command])
|
||||
expect(list.props.repoCommands).toEqual([])
|
||||
act(() => list.props.onAdd())
|
||||
expect(renderer!.root.findByType(quickCommandEditorForm).props.draft.scope).toEqual({
|
||||
type: 'global'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not delete a shared command until the destructive action is confirmed', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(QuickCommandsSheet, {
|
||||
visible: true,
|
||||
onClose: vi.fn(),
|
||||
client: {} as RpcClient,
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
onLaunch: () => true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
act(() => renderer!.root.findByType(quickCommandsList).props.onDelete(command))
|
||||
expect(mocks.persist).not.toHaveBeenCalled()
|
||||
|
||||
const actions = mocks.alert.mock.calls[0]?.[2] as
|
||||
| Array<{ style?: string; onPress?: () => void }>
|
||||
| undefined
|
||||
act(() => actions?.find((action) => action.style === 'destructive')?.onPress?.())
|
||||
|
||||
expect(mocks.persist).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.persist).toHaveBeenCalledWith({ type: 'delete', id: command.id })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Alert, View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import {
|
||||
getQuickCommandPreview,
|
||||
MAX_QUICK_COMMANDS,
|
||||
quickCommandMatchesRepo
|
||||
} from '../terminal/quick-commands'
|
||||
import { useQuickCommands } from './use-quick-commands'
|
||||
import { QuickCommandEditorForm } from './QuickCommandEditorForm'
|
||||
import { QuickCommandAgentPicker, QuickCommandsList } from './QuickCommandsList'
|
||||
import {
|
||||
createEmptyQuickCommandDraft,
|
||||
draftToQuickCommand,
|
||||
quickCommandToDraft,
|
||||
type QuickCommandDraft
|
||||
} from './quick-command-draft'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
client: RpcClient | null
|
||||
repoId: string | null
|
||||
repoName: string | null
|
||||
onLaunch: (command: TerminalQuickCommand) => boolean
|
||||
}
|
||||
|
||||
type SheetView = 'list' | 'editor' | 'agent'
|
||||
|
||||
export function QuickCommandsSheet({
|
||||
visible,
|
||||
onClose,
|
||||
client,
|
||||
repoId,
|
||||
repoName,
|
||||
onLaunch
|
||||
}: Props) {
|
||||
const { commands, loading, ready, error, persist } = useQuickCommands({
|
||||
client,
|
||||
enabled: visible
|
||||
})
|
||||
const [view, setView] = useState<SheetView>('list')
|
||||
const [query, setQuery] = useState('')
|
||||
const [draft, setDraft] = useState<QuickCommandDraft | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const savingRef = useRef(false)
|
||||
|
||||
const [wasVisible, setWasVisible] = useState(visible)
|
||||
if (visible !== wasVisible) {
|
||||
setWasVisible(visible)
|
||||
if (visible) {
|
||||
setView('list')
|
||||
setQuery('')
|
||||
setDraft(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: prompt bodies can total ~240 KB. Lowercase them once per settings
|
||||
// update instead of allocating the same search text on every keystroke.
|
||||
const searchableCommands = useMemo(() => {
|
||||
return commands
|
||||
.filter((command) => quickCommandMatchesRepo(command, repoId))
|
||||
.map((command) => ({
|
||||
command,
|
||||
searchText: `${command.label} ${getQuickCommandPreview(command)}`.toLowerCase()
|
||||
}))
|
||||
}, [commands, repoId])
|
||||
|
||||
const visibleCommands = useMemo(() => {
|
||||
const trimmed = query.trim().toLowerCase()
|
||||
return searchableCommands
|
||||
.filter((entry) => !trimmed || entry.searchText.includes(trimmed))
|
||||
.map((entry) => entry.command)
|
||||
}, [query, searchableCommands])
|
||||
|
||||
const repoCommands = visibleCommands.filter((command) => command.scope?.type === 'repo')
|
||||
const globalCommands = visibleCommands.filter((command) => command.scope?.type !== 'repo')
|
||||
|
||||
const openEditor = (command?: TerminalQuickCommand) => {
|
||||
// Why: the host rejects full-list updates above this cap; existing rows
|
||||
// must remain editable/deletable when creation is no longer possible.
|
||||
if (!command && commands.length >= MAX_QUICK_COMMANDS) {
|
||||
return
|
||||
}
|
||||
setDraft(
|
||||
command
|
||||
? quickCommandToDraft(command)
|
||||
: createEmptyQuickCommandDraft(repoId ? { type: 'repo', repoId } : { type: 'global' })
|
||||
)
|
||||
setView('editor')
|
||||
}
|
||||
|
||||
const handleLaunch = (command: TerminalQuickCommand) => {
|
||||
if (onLaunch(command)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (command: TerminalQuickCommand) => {
|
||||
// Why: quick commands sync with desktop, so an accidental one-tap delete
|
||||
// removes shared data rather than only dismissing a local row.
|
||||
Alert.alert(
|
||||
`Delete "${command.label || 'Untitled'}"?`,
|
||||
'This quick command will be removed from your saved list.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void persist({ type: 'delete', id: command.id })
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draft || savingRef.current) {
|
||||
return
|
||||
}
|
||||
const built = draftToQuickCommand(draft)
|
||||
if (!built) {
|
||||
return
|
||||
}
|
||||
// Why: state cannot lock out a second tap until React commits the disabled UI.
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
try {
|
||||
const ok = await persist({ type: 'upsert', command: built })
|
||||
if (ok) {
|
||||
setView('list')
|
||||
setDraft(null)
|
||||
}
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title =
|
||||
view === 'editor'
|
||||
? draft?.id
|
||||
? 'Edit Quick Command'
|
||||
: 'Add Quick Command'
|
||||
: view === 'agent'
|
||||
? 'Choose Agent'
|
||||
: 'Quick Commands'
|
||||
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
{view === 'list' ? (
|
||||
<View style={styles.backSpacer} />
|
||||
) : (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
|
||||
onPress={() => setView(view === 'agent' ? 'editor' : 'list')}
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ChevronLeft size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.backSpacer} />
|
||||
</View>
|
||||
|
||||
{view === 'editor' && draft ? (
|
||||
<View style={styles.editorDesc}>
|
||||
<Text style={styles.descText}>
|
||||
Save terminal commands or agent prompts for quick access.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{view === 'list' ? (
|
||||
<QuickCommandsList
|
||||
repoCommands={repoCommands}
|
||||
globalCommands={globalCommands}
|
||||
totalCount={searchableCommands.length}
|
||||
query={query}
|
||||
loading={loading}
|
||||
disabled={!ready}
|
||||
canAdd={commands.length < MAX_QUICK_COMMANDS}
|
||||
error={error}
|
||||
onQueryChange={setQuery}
|
||||
onLaunch={handleLaunch}
|
||||
onEdit={openEditor}
|
||||
onDelete={handleDelete}
|
||||
onAdd={() => openEditor()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === 'editor' && draft ? (
|
||||
<QuickCommandEditorForm
|
||||
draft={draft}
|
||||
mode={draft.id ? 'edit' : 'add'}
|
||||
saving={saving || !ready}
|
||||
error={error}
|
||||
repoId={repoId}
|
||||
repoName={repoName}
|
||||
onChange={(patch) =>
|
||||
setDraft((current) => (current ? { ...current, ...patch } : current))
|
||||
}
|
||||
onOpenAgentPicker={() => setView('agent')}
|
||||
onCancel={() => {
|
||||
setView('list')
|
||||
setDraft(null)
|
||||
}}
|
||||
onSave={() => void handleSave()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === 'agent' && draft ? (
|
||||
<QuickCommandAgentPicker
|
||||
selected={draft.agent}
|
||||
onSelect={(agent) => {
|
||||
setDraft((current) => (current ? { ...current, agent } : current))
|
||||
setView('editor')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingBottom: spacing.sm },
|
||||
backButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
backSpacer: { width: 30 },
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
pressed: { backgroundColor: colors.bgRaised },
|
||||
editorDesc: { paddingHorizontal: spacing.xs, paddingBottom: spacing.sm },
|
||||
descText: { fontSize: 12, color: colors.textMuted }
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
// Mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts — the
|
||||
// desktop Floating Workspace's synthetic worktree id (no backing repo or
|
||||
// worktree record; terminals always run on the host's local runtime).
|
||||
// Mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts — the desktop
|
||||
// Floating Workspace's synthetic id (no backing repo/worktree; always local runtime).
|
||||
export const FLOATING_WORKSPACE_WORKTREE_ID = 'global-floating-terminal'
|
||||
|
||||
export const FLOATING_WORKSPACE_TITLE = 'Floating Workspace'
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
TerminalQuickCommand,
|
||||
TerminalQuickCommandAction,
|
||||
TerminalQuickCommandScope,
|
||||
TuiAgent
|
||||
} from '../../../src/shared/types'
|
||||
import {
|
||||
isAgentQuickCommand,
|
||||
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
|
||||
MAX_QUICK_COMMAND_LABEL_LENGTH,
|
||||
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
|
||||
supportsTerminalAgentQuickCommand
|
||||
} from '../terminal/quick-commands'
|
||||
|
||||
// A single mutable draft covering both quick-command actions. Both the command
|
||||
// text and the agent/prompt are kept while editing so toggling Action back and
|
||||
// forth doesn't discard what the user already typed (mirrors desktop's draft).
|
||||
export type QuickCommandDraft = {
|
||||
id: string | null
|
||||
label: string
|
||||
action: TerminalQuickCommandAction
|
||||
command: string
|
||||
appendEnter: boolean
|
||||
agent: TuiAgent | null
|
||||
prompt: string
|
||||
scope: TerminalQuickCommandScope
|
||||
}
|
||||
|
||||
export function createEmptyQuickCommandDraft(scope: TerminalQuickCommandScope): QuickCommandDraft {
|
||||
return {
|
||||
id: null,
|
||||
label: '',
|
||||
action: 'terminal-command',
|
||||
command: '',
|
||||
appendEnter: true,
|
||||
agent: null,
|
||||
prompt: '',
|
||||
scope
|
||||
}
|
||||
}
|
||||
|
||||
export function quickCommandToDraft(command: TerminalQuickCommand): QuickCommandDraft {
|
||||
const scope: TerminalQuickCommandScope =
|
||||
command.scope?.type === 'repo' && command.scope.repoId
|
||||
? { type: 'repo', repoId: command.scope.repoId }
|
||||
: { type: 'global' }
|
||||
if (isAgentQuickCommand(command)) {
|
||||
return {
|
||||
id: command.id,
|
||||
label: command.label,
|
||||
action: 'agent-prompt',
|
||||
command: '',
|
||||
appendEnter: true,
|
||||
agent: command.agent,
|
||||
prompt: command.prompt,
|
||||
scope
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: command.id,
|
||||
label: command.label,
|
||||
action: 'terminal-command',
|
||||
command: command.command,
|
||||
appendEnter: command.appendEnter !== false,
|
||||
agent: null,
|
||||
prompt: '',
|
||||
scope
|
||||
}
|
||||
}
|
||||
|
||||
export function isQuickCommandDraftValid(draft: QuickCommandDraft): boolean {
|
||||
if (!draft.label.trim()) {
|
||||
return false
|
||||
}
|
||||
if (draft.action === 'agent-prompt') {
|
||||
return Boolean(
|
||||
draft.agent &&
|
||||
supportsTerminalAgentQuickCommand(draft.agent) &&
|
||||
draft.prompt.trim().length > 0
|
||||
)
|
||||
}
|
||||
return draft.command.trim().length > 0
|
||||
}
|
||||
|
||||
// Build the persisted command from a draft. Returns null when incomplete so the
|
||||
// caller can keep the editor open. The server re-normalizes, but we trim/cap
|
||||
// here so optimistic local state matches what will be saved.
|
||||
export function draftToQuickCommand(draft: QuickCommandDraft): TerminalQuickCommand | null {
|
||||
if (!isQuickCommandDraftValid(draft)) {
|
||||
return null
|
||||
}
|
||||
// Why: timestamps alone collide when desktop and mobile add commands in the
|
||||
// same millisecond, which would turn an atomic upsert into an overwrite.
|
||||
const id =
|
||||
draft.id ??
|
||||
`quick-command-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
const label = draft.label.trim().slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH)
|
||||
if (draft.action === 'agent-prompt' && draft.agent) {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
action: 'agent-prompt',
|
||||
agent: draft.agent,
|
||||
prompt: draft.prompt.trimEnd().slice(0, MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH),
|
||||
scope: draft.scope
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
action: 'terminal-command',
|
||||
command: draft.command.trimEnd().slice(0, MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH),
|
||||
appendEnter: draft.appendEnter,
|
||||
scope: draft.scope
|
||||
}
|
||||
}
|
||||
@@ -24,21 +24,17 @@ type PrSidebarControllerInput = {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
worktreeId: string
|
||||
// Head branch + SHA come from git.status (`branch`/`head`) via the review screen,
|
||||
// not the branchCompare base ref nor worktree metadata (which carries no branch).
|
||||
// branch/headSha come from git.status (not the branchCompare base ref nor worktree metadata, which carries no branch).
|
||||
branch: string | null
|
||||
headSha: string | null
|
||||
}
|
||||
|
||||
// Load options for the shared PR controller. The Source Control hub chip only needs
|
||||
// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the
|
||||
// Pull Request segment is actually open.
|
||||
// Load options: the hub chip needs only phase 1 (PR + checks); phase 2 (comments/body) is heavy and waits until the PR segment opens.
|
||||
export type PrSidebarLoadOptions = {
|
||||
includeDetails?: boolean
|
||||
}
|
||||
|
||||
// Identity is worktree + branch only. Head SHA advances on every commit and must not
|
||||
// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead.
|
||||
// Identity is worktree + branch only: headSha advances every commit and must not wipe a ready chip to "loading" (soft-refresh handles it).
|
||||
export function buildMobilePrSidebarIdentity(args: {
|
||||
worktreeId: string
|
||||
branch: string | null
|
||||
@@ -48,33 +44,23 @@ export function buildMobilePrSidebarIdentity(args: {
|
||||
|
||||
export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
const { client, connState, worktreeId, branch, headSha } = input
|
||||
// The dedicated PR icon is available whenever the repo has a GitHub remote —
|
||||
// independent of whether the branch has an open PR (a no-PR branch shows an
|
||||
// empty state rather than hiding the icon).
|
||||
// PR icon shows for any GitHub remote, regardless of an open PR — a no-PR branch shows an empty state rather than hiding the icon.
|
||||
const [isGithubRepo, setIsGithubRepo] = useState(false)
|
||||
// False until the probe resolves for this worktree. Consumers gate "unavailable
|
||||
// for this provider" copy on it — isGithubRepo=false is meaningless mid-probe.
|
||||
// False until the probe resolves — isGithubRepo=false is meaningless mid-probe, so consumers gate "unavailable" copy on this.
|
||||
const [repoProbeLoaded, setRepoProbeLoaded] = useState(false)
|
||||
const [state, setState] = useState<PrSidebarState>({ kind: 'hidden' })
|
||||
const [showPRSidebar, setShowPRSidebar] = useState(false)
|
||||
const loadSeqRef = useRef(0)
|
||||
// Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent
|
||||
// phase-1 soft refresh (and vice versa) when chip bootstrap left details null.
|
||||
// Separate seq for phase-2 fetches so they can't cancel a concurrent phase-1 soft refresh (and vice versa).
|
||||
const detailsSeqRef = useRef(0)
|
||||
// The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's
|
||||
// fill-in effect fires as soon as phase 1 renders ready with null details —
|
||||
// exactly when load()'s own phase 2 just started. Without this claim, every
|
||||
// cold PR-segment open fetched the heavy details payload twice.
|
||||
// (seq, prNumber) of the in-flight phase-2 fetch; without this claim every cold PR-segment open fetched the heavy details twice.
|
||||
const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null)
|
||||
const stateIdentityRef = useRef<string | null>(null)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
const headShaRef = useRef(headSha)
|
||||
|
||||
// Repo eligibility (a GitHub remote) is independent of the branch, so the probe
|
||||
// must not require one: a detached HEAD / mid-rebase worktree (branch === null)
|
||||
// would otherwise never set repoProbeLoaded, stranding the PR segment on a
|
||||
// forever spinner instead of the "Current branch unavailable" state.
|
||||
// Probe is branch-independent (repo eligibility is): requiring a branch would strand a detached-HEAD worktree on a forever spinner.
|
||||
const probeReady = client !== null && connState === 'connected'
|
||||
const identity = buildMobilePrSidebarIdentity({ worktreeId, branch })
|
||||
|
||||
@@ -91,9 +77,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
}
|
||||
}, [client])
|
||||
|
||||
// Probe whether this is a GitHub repo to decide icon availability (GitHub-only).
|
||||
// Worktree change must reset eligibility; a brief disconnect must not — otherwise
|
||||
// the hub shows "unavailable for this provider" and hides the chip mid-session.
|
||||
// Probe GitHub-repo eligibility for the icon; a worktree change resets it, a brief disconnect must not (else the chip hides mid-session).
|
||||
useEffect(() => {
|
||||
setIsGithubRepo(false)
|
||||
setRepoProbeLoaded(false)
|
||||
@@ -112,8 +96,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Why: sendGithubPrRead already normalizes throws, but a cancelled
|
||||
// unmount + any unexpected rejection must not surface as LogBox.
|
||||
// Why: sendGithubPrRead normalizes throws, but a stray rejection on unmount must not surface as LogBox.
|
||||
if (!cancelled) {
|
||||
setIsGithubRepo(false)
|
||||
setRepoProbeLoaded(true)
|
||||
@@ -133,8 +116,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
return
|
||||
}
|
||||
if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) {
|
||||
// Why: ready/loading data is scoped to branch. A branch switch must not let
|
||||
// the open panel keep rendering the previous PR as "fresh."
|
||||
// Why: data is scoped to branch; a branch switch must not keep rendering the previous PR as "fresh."
|
||||
loadSeqRef.current += 1
|
||||
detailsSeqRef.current += 1
|
||||
stateIdentityRef.current = null
|
||||
@@ -152,16 +134,10 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
}
|
||||
const seq = loadSeqRef.current + 1
|
||||
loadSeqRef.current = seq
|
||||
// In-flight phase-2 work is NOT invalidated here: this load only takes phase-2
|
||||
// ownership when its own phase 2 actually starts (the bump below), so a load
|
||||
// superseded at the phase-1 guard can never orphan a detailsSeq and silently
|
||||
// discard the only details fetch. A stale ensure applying mid-phase-1 is safe —
|
||||
// its identity/number/kind guards only let matching details through.
|
||||
// Don't invalidate in-flight phase 2 here: it's only claimed when this load's own phase 2 starts, so a superseded phase-1 load can't orphan the details fetch.
|
||||
const previousIdentity = stateIdentityRef.current
|
||||
stateIdentityRef.current = loadIdentity
|
||||
// Soft refresh: same branch already showing ready/none stays visible while
|
||||
// checks re-fetch (head advanced after commit). Hard loading only on first load
|
||||
// or after a real identity wipe.
|
||||
// Soft refresh: same-branch ready/none stays visible while checks re-fetch; hard "loading" only on first load or identity wipe.
|
||||
const keepVisible =
|
||||
previousIdentity === loadIdentity &&
|
||||
(stateRef.current.kind === 'ready' ||
|
||||
@@ -170,8 +146,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
if (!keepVisible) {
|
||||
setState({ kind: 'loading' })
|
||||
}
|
||||
// Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
|
||||
// forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
|
||||
// Phase 1: PR + checks (fast); linkedPR read runs in parallel with forBranch so a closed/merged linked PR still resolves.
|
||||
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
|
||||
if (
|
||||
!shouldApplyResult(seq, loadSeqRef.current) ||
|
||||
@@ -181,9 +156,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
}
|
||||
stateIdentityRef.current = loadIdentity
|
||||
|
||||
// Keep prior comments/body visible across phase 1 when the same PR is still open.
|
||||
// loadPrSidebarData always returns details:null; without this, soft refresh and
|
||||
// PR-tab refresh blank the comment tree until phase 2 finishes.
|
||||
// Preserve prior details across phase 1 (loadPrSidebarData returns details:null) so soft/PR-tab refresh doesn't blank the comment tree.
|
||||
const priorDetails =
|
||||
next.kind === 'ready' &&
|
||||
stateRef.current.kind === 'ready' &&
|
||||
@@ -213,10 +186,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
if (detailsInFlightRef.current?.seq === detailsSeq) {
|
||||
detailsInFlightRef.current = null
|
||||
}
|
||||
// Phase-2 ownership is encoded by detailsSeq + identity + PR number alone —
|
||||
// deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without
|
||||
// bumping detailsSeq, and must not discard the in-flight details it preserved
|
||||
// (ensure dedupes against this claim, so nothing would re-fetch them).
|
||||
// Ownership keyed on detailsSeq (not loadSeq): a chip-only soft refresh bumps loadSeq without detailsSeq and must not discard these details.
|
||||
if (
|
||||
detailsSeq !== detailsSeqRef.current ||
|
||||
stateIdentityRef.current !== loadIdentity ||
|
||||
@@ -236,11 +206,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
[buildDeps, branch, headSha, identity, worktreeId]
|
||||
)
|
||||
|
||||
// Phase-2 only — used when the hub opens the PR segment after a chip-only load.
|
||||
// Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
|
||||
// Retries synthetic placeholders too: a failed phase-2 installs non-null empty
|
||||
// details so Description/Comments leave the spinner, and without this ensure
|
||||
// would never re-fetch on tab re-open.
|
||||
// Phase-2-only fill-in; uses detailsSeqRef so it can't cancel a concurrent phase-1, and re-fetches non-null placeholders too.
|
||||
const ensurePrSidebarDetails = useCallback(async () => {
|
||||
const current = stateRef.current
|
||||
if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) {
|
||||
@@ -252,8 +218,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
return
|
||||
}
|
||||
const prNumber = current.data.pr.number
|
||||
// A live phase-2 fetch for this PR is already in flight (its claim still owns
|
||||
// the latest details seq) — do not start a duplicate.
|
||||
// Skip if a live phase-2 fetch for this PR already owns the latest details seq (dedupe).
|
||||
const inFlight = detailsInFlightRef.current
|
||||
if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) {
|
||||
return
|
||||
@@ -284,18 +249,13 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
})
|
||||
}, [buildDeps, identity, worktreeId])
|
||||
|
||||
// Soft-refresh checks when HEAD advances on the same branch (post-commit).
|
||||
// Also restarts an in-flight phase-1 load so a mid-flight head advance is not
|
||||
// applied with a stale SHA (headShaRef would otherwise advance with no reload).
|
||||
// Soft-refresh on same-branch HEAD advance; restart in-flight load so the advance isn't applied with a stale SHA.
|
||||
useEffect(() => {
|
||||
if (headShaRef.current === headSha) {
|
||||
return
|
||||
}
|
||||
headShaRef.current = headSha
|
||||
// Identity was just wiped (branch/worktree switch): stateRef still holds the
|
||||
// pre-wipe state in this effect flush, which would start a load flavored for
|
||||
// the OLD surface (e.g. heavy details on the Changes tab). Let the owning
|
||||
// surface's hidden-state effects drive the first load for the new identity.
|
||||
// Identity just wiped: stateRef still holds stale pre-wipe state, so let the surface's hidden-state effects drive the new load.
|
||||
if (stateIdentityRef.current === null) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useQuickCommands } from './use-quick-commands'
|
||||
|
||||
const FIRST: TerminalQuickCommand = {
|
||||
id: 'first',
|
||||
label: 'First',
|
||||
action: 'terminal-command',
|
||||
command: 'echo first',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
}
|
||||
const SECOND: TerminalQuickCommand = {
|
||||
id: 'second',
|
||||
label: 'Second',
|
||||
action: 'terminal-command',
|
||||
command: 'echo second',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
}
|
||||
|
||||
function success(commands: TerminalQuickCommand[]): RpcResponse {
|
||||
return {
|
||||
ok: true,
|
||||
result: { terminalQuickCommands: commands }
|
||||
} as RpcResponse
|
||||
}
|
||||
|
||||
function failure(message: string): RpcResponse {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'internal_error', message }
|
||||
} as RpcResponse
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useQuickCommands', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let state: ReturnType<typeof useQuickCommands> | null = null
|
||||
let consoleSpy: MockInstance
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const original = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
state = null
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
async function mount(client: RpcClient, enabled = true): Promise<void> {
|
||||
function Harness(): null {
|
||||
state = useQuickCommands({ client, enabled })
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
it('does not fetch settings while the sheet is closed', async () => {
|
||||
const client = { sendRequest: vi.fn() } as unknown as RpcClient
|
||||
|
||||
await mount(client, false)
|
||||
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
expect(state?.ready).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps mutations disabled when the remote list could not be loaded', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockResolvedValue(failure('load failed'))
|
||||
} as unknown as RpcClient
|
||||
await mount(client)
|
||||
|
||||
await act(async () => {
|
||||
const persisted = await state!.persist({ type: 'delete', id: FIRST.id })
|
||||
expect(persisted).toBe(false)
|
||||
})
|
||||
|
||||
expect(state?.ready).toBe(false)
|
||||
expect(state?.error).toBe('load failed')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps mutations disabled when a successful load has a malformed list', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockResolvedValue({ ok: true, result: {} } as RpcResponse)
|
||||
} as unknown as RpcClient
|
||||
await mount(client)
|
||||
|
||||
await act(async () => {
|
||||
const persisted = await state!.persist({ type: 'delete', id: FIRST.id })
|
||||
expect(persisted).toBe(false)
|
||||
})
|
||||
|
||||
expect(state?.ready).toBe(false)
|
||||
expect(state?.error).toBe('Failed to load quick commands')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps a later queued mutation after an earlier mutation fails', async () => {
|
||||
const firstUpdate = deferred<RpcResponse>()
|
||||
const secondUpdate = deferred<RpcResponse>()
|
||||
const updateParams: unknown[] = []
|
||||
const client = {
|
||||
sendRequest: vi.fn((method: string, params?: unknown) => {
|
||||
if (method === 'settings.getTerminalQuickCommands') {
|
||||
return Promise.resolve(success([FIRST, SECOND]))
|
||||
}
|
||||
updateParams.push(params)
|
||||
return updateParams.length === 1 ? firstUpdate.promise : secondUpdate.promise
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
await mount(client)
|
||||
expect(state?.commands).toEqual([FIRST, SECOND])
|
||||
|
||||
let firstPersist: Promise<boolean> = Promise.resolve(false)
|
||||
let secondPersist: Promise<boolean> = Promise.resolve(false)
|
||||
await act(async () => {
|
||||
firstPersist = state!.persist({ type: 'delete', id: FIRST.id })
|
||||
secondPersist = state!.persist({ type: 'delete', id: SECOND.id })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(state?.commands).toEqual([])
|
||||
expect(updateParams).toHaveLength(1)
|
||||
expect(updateParams[0]).toEqual({ mutation: { type: 'delete', id: FIRST.id } })
|
||||
|
||||
await act(async () => {
|
||||
firstUpdate.resolve(failure('first failed'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(updateParams).toEqual([
|
||||
{ mutation: { type: 'delete', id: FIRST.id } },
|
||||
{ mutation: { type: 'delete', id: SECOND.id } }
|
||||
])
|
||||
expect(state?.commands).toEqual([FIRST])
|
||||
|
||||
await act(async () => {
|
||||
secondUpdate.resolve(success([FIRST]))
|
||||
await Promise.all([firstPersist, secondPersist])
|
||||
})
|
||||
expect(await firstPersist).toBe(false)
|
||||
expect(await secondPersist).toBe(true)
|
||||
expect(state?.commands).toEqual([FIRST])
|
||||
expect(state?.error).toBeNull()
|
||||
})
|
||||
|
||||
it('adopts the latest server-normalized list between queued mutations', async () => {
|
||||
const normalizedFirst = { ...FIRST, label: 'Server normalized' }
|
||||
const firstUpdate = deferred<RpcResponse>()
|
||||
const secondUpdate = deferred<RpcResponse>()
|
||||
const updateParams: unknown[] = []
|
||||
const client = {
|
||||
sendRequest: vi.fn((method: string, params?: unknown) => {
|
||||
if (method === 'settings.getTerminalQuickCommands') {
|
||||
return Promise.resolve(success([FIRST, SECOND]))
|
||||
}
|
||||
updateParams.push(params)
|
||||
return updateParams.length === 1 ? firstUpdate.promise : secondUpdate.promise
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
await mount(client)
|
||||
|
||||
let firstPersist: Promise<boolean> = Promise.resolve(false)
|
||||
let secondPersist: Promise<boolean> = Promise.resolve(false)
|
||||
await act(async () => {
|
||||
firstPersist = state!.persist({
|
||||
type: 'upsert',
|
||||
command: { ...FIRST, label: 'Local label' }
|
||||
})
|
||||
secondPersist = state!.persist({ type: 'delete', id: SECOND.id })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(updateParams[0]).toEqual({
|
||||
mutation: { type: 'upsert', command: { ...FIRST, label: 'Local label' } }
|
||||
})
|
||||
await act(async () => {
|
||||
firstUpdate.resolve(success([normalizedFirst, SECOND]))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(updateParams[1]).toEqual({ mutation: { type: 'delete', id: SECOND.id } })
|
||||
|
||||
await act(async () => {
|
||||
secondUpdate.resolve(success([normalizedFirst]))
|
||||
await Promise.all([firstPersist, secondPersist])
|
||||
})
|
||||
expect(state?.commands).toEqual([normalizedFirst])
|
||||
})
|
||||
|
||||
it('preserves unrelated commands added by another client while the sheet is open', async () => {
|
||||
const edited = { ...FIRST, label: 'Edited on mobile' }
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(success([FIRST]))
|
||||
// The host applies the targeted upsert to a list that desktop changed.
|
||||
.mockResolvedValueOnce(success([edited, SECOND]))
|
||||
} as unknown as RpcClient
|
||||
await mount(client)
|
||||
|
||||
await act(async () => {
|
||||
await state!.persist({ type: 'upsert', command: edited })
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenLastCalledWith('settings.updateTerminalQuickCommands', {
|
||||
mutation: { type: 'upsert', command: edited }
|
||||
})
|
||||
expect(state?.commands).toEqual([edited, SECOND])
|
||||
})
|
||||
|
||||
it('isolates an old client mutation from a replacement client', async () => {
|
||||
const oldUpdate = deferred<RpcResponse>()
|
||||
const oldClient = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(success([FIRST]))
|
||||
.mockReturnValueOnce(oldUpdate.promise)
|
||||
} as unknown as RpcClient
|
||||
const newClient = {
|
||||
sendRequest: vi.fn().mockResolvedValue(success([SECOND]))
|
||||
} as unknown as RpcClient
|
||||
|
||||
function Harness({ client }: { client: RpcClient }): null {
|
||||
state = useQuickCommands({ client, enabled: true })
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { client: oldClient }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
let persisted: Promise<boolean> = Promise.resolve(false)
|
||||
await act(async () => {
|
||||
persisted = state!.persist({ type: 'delete', id: FIRST.id })
|
||||
await Promise.resolve()
|
||||
renderer!.update(createElement(Harness, { client: newClient }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(newClient.sendRequest).toHaveBeenCalledWith('settings.getTerminalQuickCommands')
|
||||
expect(state?.commands).toEqual([SECOND])
|
||||
|
||||
await act(async () => {
|
||||
oldUpdate.resolve(success([]))
|
||||
await persisted
|
||||
})
|
||||
expect(state?.commands).toEqual([SECOND])
|
||||
})
|
||||
|
||||
it('rolls back the latest optimistic mutation when persistence fails', async () => {
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(success([FIRST]))
|
||||
.mockResolvedValueOnce(failure('save failed'))
|
||||
} as unknown as RpcClient
|
||||
await mount(client)
|
||||
|
||||
await act(async () => {
|
||||
await state!.persist({ type: 'delete', id: FIRST.id })
|
||||
})
|
||||
|
||||
expect(state?.commands).toEqual([FIRST])
|
||||
expect(state?.error).toBe('save failed')
|
||||
})
|
||||
|
||||
it('rolls back when a successful update has a malformed authoritative list', async () => {
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(success([FIRST]))
|
||||
.mockResolvedValueOnce({ ok: true, result: {} } as RpcResponse)
|
||||
} as unknown as RpcClient
|
||||
await mount(client)
|
||||
|
||||
let persisted = true
|
||||
await act(async () => {
|
||||
persisted = await state!.persist({ type: 'delete', id: FIRST.id })
|
||||
})
|
||||
|
||||
expect(persisted).toBe(false)
|
||||
expect(state?.commands).toEqual([FIRST])
|
||||
expect(state?.error).toBe('Failed to save quick command')
|
||||
})
|
||||
|
||||
it('waits for an in-flight save before reloading after reopen', async () => {
|
||||
const update = deferred<RpcResponse>()
|
||||
let loadCount = 0
|
||||
const client = {
|
||||
sendRequest: vi.fn((method: string) => {
|
||||
if (method === 'settings.updateTerminalQuickCommands') {
|
||||
return update.promise
|
||||
}
|
||||
loadCount += 1
|
||||
return Promise.resolve(success(loadCount === 1 ? [FIRST] : []))
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
|
||||
function Harness({ enabled }: { enabled: boolean }): null {
|
||||
state = useQuickCommands({ client, enabled })
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { enabled: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
let persisted: Promise<boolean> = Promise.resolve(false)
|
||||
await act(async () => {
|
||||
persisted = state!.persist({ type: 'delete', id: FIRST.id })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
renderer!.update(createElement(Harness, { enabled: false }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
renderer!.update(createElement(Harness, { enabled: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(loadCount).toBe(1)
|
||||
|
||||
await act(async () => {
|
||||
update.resolve(success([]))
|
||||
await persisted
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(loadCount).toBe(2)
|
||||
expect(state?.commands).toEqual([])
|
||||
expect(state?.ready).toBe(true)
|
||||
})
|
||||
|
||||
it('rolls back to the confirmed list when consecutive queued mutations fail', async () => {
|
||||
const firstUpdate = deferred<RpcResponse>()
|
||||
const secondUpdate = deferred<RpcResponse>()
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(success([FIRST, SECOND]))
|
||||
.mockReturnValueOnce(firstUpdate.promise)
|
||||
.mockReturnValueOnce(secondUpdate.promise)
|
||||
} as unknown as RpcClient
|
||||
await mount(client)
|
||||
|
||||
let firstPersist: Promise<boolean> = Promise.resolve(false)
|
||||
let secondPersist: Promise<boolean> = Promise.resolve(false)
|
||||
await act(async () => {
|
||||
firstPersist = state!.persist({ type: 'delete', id: FIRST.id })
|
||||
secondPersist = state!.persist({ type: 'delete', id: SECOND.id })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
firstUpdate.resolve(failure('first failed'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
secondUpdate.resolve(failure('second failed'))
|
||||
await Promise.all([firstPersist, secondPersist])
|
||||
})
|
||||
|
||||
expect(state?.commands).toEqual([FIRST, SECOND])
|
||||
expect(state?.error).toBe('second failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import {
|
||||
applyTerminalQuickCommandMutation,
|
||||
parseNormalizedTerminalQuickCommands,
|
||||
type TerminalQuickCommandMutation
|
||||
} from '../terminal/quick-commands'
|
||||
|
||||
type Args = {
|
||||
client: RpcClient | null
|
||||
// Fetch only while the sheet is open — quick commands are settings data we
|
||||
// don't need to keep hydrated for every session screen.
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
type QuickCommandsState = {
|
||||
commands: TerminalQuickCommand[]
|
||||
loading: boolean
|
||||
ready: boolean
|
||||
error: string | null
|
||||
// Optimistically apply against the latest local list, then serialize writes.
|
||||
// The server re-normalizes and returns the canonical list, which we adopt.
|
||||
persist: (mutation: TerminalQuickCommandMutation) => Promise<boolean>
|
||||
}
|
||||
|
||||
type PendingMutation = {
|
||||
id: number
|
||||
mutation: TerminalQuickCommandMutation
|
||||
}
|
||||
|
||||
type MutationContext = {
|
||||
client: RpcClient
|
||||
confirmed: TerminalQuickCommand[]
|
||||
pending: PendingMutation[]
|
||||
queue: Promise<void>
|
||||
nextMutationId: number
|
||||
}
|
||||
|
||||
function readQuickCommands(result: unknown): TerminalQuickCommand[] | null {
|
||||
const list = (result as { terminalQuickCommands?: unknown } | null)?.terminalQuickCommands
|
||||
return parseNormalizedTerminalQuickCommands(list)
|
||||
}
|
||||
|
||||
export function useQuickCommands({ client, enabled }: Args): QuickCommandsState {
|
||||
const [commands, setCommands] = useState<TerminalQuickCommand[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const commandsRef = useRef<TerminalQuickCommand[]>([])
|
||||
const operationIdRef = useRef(0)
|
||||
const mutationContextRef = useRef<MutationContext | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !client) {
|
||||
setReady(false)
|
||||
return
|
||||
}
|
||||
let mutationContext = mutationContextRef.current
|
||||
if (mutationContext?.client !== client) {
|
||||
// A request for an old host must not delay or update mutations on a new one.
|
||||
mutationContext = {
|
||||
client,
|
||||
confirmed: [],
|
||||
pending: [],
|
||||
queue: Promise.resolve(),
|
||||
nextMutationId: 0
|
||||
}
|
||||
mutationContextRef.current = mutationContext
|
||||
commandsRef.current = []
|
||||
setCommands([])
|
||||
}
|
||||
let stale = false
|
||||
const operationId = operationIdRef.current + 1
|
||||
operationIdRef.current = operationId
|
||||
setLoading(true)
|
||||
setReady(false)
|
||||
setError(null)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// A close/reopen can overlap an in-flight save. Read only after that
|
||||
// save settles so an older snapshot cannot replace its canonical result.
|
||||
await mutationContext.queue
|
||||
if (
|
||||
stale ||
|
||||
operationId !== operationIdRef.current ||
|
||||
mutationContextRef.current !== mutationContext
|
||||
) {
|
||||
return
|
||||
}
|
||||
const response = await client.sendRequest('settings.getTerminalQuickCommands')
|
||||
if (
|
||||
stale ||
|
||||
operationId !== operationIdRef.current ||
|
||||
mutationContextRef.current !== mutationContext
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
setError((response as RpcFailure).error.message || 'Failed to load quick commands')
|
||||
return
|
||||
}
|
||||
const next = readQuickCommands((response as RpcSuccess).result)
|
||||
if (!next) {
|
||||
setError('Failed to load quick commands')
|
||||
return
|
||||
}
|
||||
mutationContext.confirmed = next
|
||||
commandsRef.current = next
|
||||
setCommands(next)
|
||||
setReady(true)
|
||||
} catch (err) {
|
||||
if (
|
||||
!stale &&
|
||||
operationId === operationIdRef.current &&
|
||||
mutationContextRef.current === mutationContext
|
||||
) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load quick commands')
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
!stale &&
|
||||
operationId === operationIdRef.current &&
|
||||
mutationContextRef.current === mutationContext
|
||||
) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, enabled])
|
||||
|
||||
const persist = useCallback(
|
||||
async (commandMutation: TerminalQuickCommandMutation) => {
|
||||
// Why: the loaded list is the optimistic/rollback baseline; mutating
|
||||
// before it arrives would make failure recovery show invented state.
|
||||
const mutationContext = mutationContextRef.current
|
||||
if (!client || loading || !ready || mutationContext?.client !== client) {
|
||||
return false
|
||||
}
|
||||
const mutation: PendingMutation = {
|
||||
id: mutationContext.nextMutationId + 1,
|
||||
mutation: commandMutation
|
||||
}
|
||||
mutationContext.nextMutationId = mutation.id
|
||||
mutationContext.pending.push(mutation)
|
||||
const optimistic = applyTerminalQuickCommandMutation(commandsRef.current, commandMutation)
|
||||
commandsRef.current = optimistic
|
||||
setCommands(optimistic)
|
||||
setError(null)
|
||||
|
||||
const send = async (): Promise<boolean> => {
|
||||
let succeeded = false
|
||||
let failureMessage: string | null = null
|
||||
try {
|
||||
const response = await client.sendRequest('settings.updateTerminalQuickCommands', {
|
||||
mutation: commandMutation
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
(response as RpcFailure).error.message || 'Failed to save quick command'
|
||||
)
|
||||
}
|
||||
const confirmed = readQuickCommands((response as RpcSuccess).result)
|
||||
if (!confirmed) {
|
||||
// Why: treating an invalid success payload as [] would let the next
|
||||
// full-list mutation erase commands that still exist on the host.
|
||||
throw new Error('Failed to save quick command')
|
||||
}
|
||||
mutationContext.confirmed = confirmed
|
||||
succeeded = true
|
||||
return true
|
||||
} catch (err) {
|
||||
failureMessage = err instanceof Error ? err.message : 'Failed to save quick command'
|
||||
return false
|
||||
} finally {
|
||||
mutationContext.pending = mutationContext.pending.filter(
|
||||
(pending) => pending.id !== mutation.id
|
||||
)
|
||||
if (mutationContextRef.current === mutationContext) {
|
||||
const next = mutationContext.pending.reduce(
|
||||
(current, pending) => applyTerminalQuickCommandMutation(current, pending.mutation),
|
||||
mutationContext.confirmed
|
||||
)
|
||||
commandsRef.current = next
|
||||
setCommands(next)
|
||||
const hasNewerMutation = mutationContext.pending.some(
|
||||
(pending) => pending.id > mutation.id
|
||||
)
|
||||
if (!hasNewerMutation) {
|
||||
setError(succeeded ? null : failureMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const request = mutationContext.queue.then(send, send)
|
||||
mutationContext.queue = request.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return await request
|
||||
},
|
||||
[client, loading, ready]
|
||||
)
|
||||
|
||||
return { commands, loading, ready, error, persist }
|
||||
}
|
||||
@@ -46,16 +46,13 @@ export function MobileSourceControlPanel({
|
||||
onOpenedFileDiff
|
||||
}: MobileSourceControlPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
|
||||
// Track first visit so Changes/History keep scroll state across segment switches
|
||||
// without paying the mount cost until the user actually opens them. PR unmounts
|
||||
// when inactive (WebViews / comment tree) but its controller state stays for the chip.
|
||||
// Track first visit so Changes/History stay mounted (keep scroll) after first open; PR still unmounts when inactive.
|
||||
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<SourceControlHubTab>>(
|
||||
() => new Set<SourceControlHubTab>([initialTab])
|
||||
)
|
||||
const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0)
|
||||
|
||||
// Deep-link / push with a different `tab` param should adopt the new segment
|
||||
// (expo-router can reuse the screen instance when only query params change).
|
||||
// expo-router reuses the screen instance when only query params change, so adopt the new tab.
|
||||
useEffect(() => {
|
||||
setActiveTab(initialTab)
|
||||
setVisitedTabs((prev) => {
|
||||
@@ -119,13 +116,7 @@ export function MobileSourceControlPanel({
|
||||
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
|
||||
const ready = screenState.kind === 'ready'
|
||||
|
||||
// One PR controller feeds both the branch-card chip and the Pull Request
|
||||
// segment, so the chip's rollup can never disagree with the checks list it
|
||||
// links to. Branch + head come from the already-loaded git.status — no second
|
||||
// status read. The chip loads independently, so it never blocks the file list.
|
||||
// Keep last-known identity across a transient status unload (disconnect /
|
||||
// failed refresh) so the controller does not wipe ready → hidden → cold start.
|
||||
// Head SHA matches the review path: status.head ?? branchCompare headOid.
|
||||
// Keep last-known branch/head across a transient status unload so the PR controller isn't wiped ready → hidden → cold start.
|
||||
const lastPrBranchRef = useRef<string | null>(null)
|
||||
const lastPrHeadRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
@@ -134,8 +125,7 @@ export function MobileSourceControlPanel({
|
||||
}, [worktreeId])
|
||||
const statusBranch = status?.branch ?? null
|
||||
const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
|
||||
// Write last-known identity in an effect, not the render body: a discarded
|
||||
// concurrent render must not leave the fallback holding a never-committed value.
|
||||
// Write last-known identity in an effect, not render: a discarded concurrent render must not leave the fallback stale.
|
||||
useEffect(() => {
|
||||
if (statusBranch) {
|
||||
lastPrBranchRef.current = statusBranch
|
||||
@@ -163,8 +153,7 @@ export function MobileSourceControlPanel({
|
||||
const ensurePrDetailsRef = useRef(ensurePrDetails)
|
||||
ensurePrDetailsRef.current = ensurePrDetails
|
||||
|
||||
// Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until
|
||||
// the Pull Request segment is open — opening SC to stage must not pull details.
|
||||
// Chip bootstrap: phase-1 only (PR + checks); full payload waits for the PR segment so opening SC to stage doesn't pull details.
|
||||
useEffect(() => {
|
||||
if (activeTab === 'pr') {
|
||||
return
|
||||
@@ -174,22 +163,14 @@ export function MobileSourceControlPanel({
|
||||
}
|
||||
}, [activeTab, prBranch, isHostedRepo, prSidebarKind])
|
||||
|
||||
// Number of the ready PR whose phase-2 details are still missing (null when
|
||||
// none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
|
||||
// chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
|
||||
// comments on a forever-spinner (a stale ensure bails on the number mismatch).
|
||||
// Placeholder details (failed phase 2) also count as missing so reopening the
|
||||
// PR tab retries instead of leaving empty Description/Comments forever.
|
||||
// Keyed by PR number, not a boolean, so a same-branch PR swap re-arms phase 2 for the new PR.
|
||||
const prDetailsMissingFor =
|
||||
prController.prSidebarState.kind === 'ready' &&
|
||||
prSidebarDetailsNeedFetch(prController.prSidebarState.data.details)
|
||||
? prController.prSidebarState.data.pr.number
|
||||
: null
|
||||
|
||||
// PR segment: full load or phase-2 fill-in. Body only mounts while active.
|
||||
// Guarded (and keyed) on prBranch so a branch that arrives while the segment is
|
||||
// already open — e.g. mounted on a detached HEAD, then checkout — still loads;
|
||||
// kind stays 'hidden' through that transition, so kind alone can't re-fire this.
|
||||
// PR segment full load / phase-2 fill-in; key on prBranch so a branch arriving while open still loads (kind stays 'hidden').
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'pr' || !isHostedRepo || !prBranch) {
|
||||
return
|
||||
@@ -204,9 +185,7 @@ export function MobileSourceControlPanel({
|
||||
}, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor])
|
||||
|
||||
const prChip = useMemo(() => {
|
||||
// No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared
|
||||
// state stays 'hidden' — which the chip would render as a forever spinner.
|
||||
// Hide the chip instead; the Pull Request segment shows "branch unavailable".
|
||||
// No branch (detached HEAD / mid-rebase) never loads a PR, so state stays 'hidden'; hide the chip or it spins forever.
|
||||
if (!isHostedRepo || !prBranch) {
|
||||
return null
|
||||
}
|
||||
@@ -217,9 +196,7 @@ export function MobileSourceControlPanel({
|
||||
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
|
||||
}, [isHostedRepo, prBranch, prController.prSidebarState])
|
||||
|
||||
// Design: refresh the active segment's body work, plus git.status for the shared
|
||||
// branch card (counts/sync stay honest even while on History). Preserve ready
|
||||
// status on a failed refresh so PR chip identity is not wiped to hidden.
|
||||
// Refresh the active segment plus git.status (branch card stays honest on History); preserve ready on failure so the PR chip isn't wiped.
|
||||
const onRefresh = useCallback(() => {
|
||||
void loadStatus({ preserveReadyOnFailure: true })
|
||||
if (activeTab === 'history') {
|
||||
@@ -237,11 +214,9 @@ export function MobileSourceControlPanel({
|
||||
void refetchPr({ includeDetails: false })
|
||||
}, [activeTab, isHostedRepo, loadStatus, refetchPr])
|
||||
|
||||
// Embedded mode docks beside the terminal: close the dock instead of popping
|
||||
// a route, and skip the full-screen safe-area chrome (the dock column owns it).
|
||||
// Embedded mode docks beside the terminal: close the dock instead of popping a route; skip safe-area chrome (the dock column owns it).
|
||||
const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back()
|
||||
// Chromeless PR body has no panel header — surface open-on-web on the hub chrome
|
||||
// while the Pull Request segment is active (same affordance as the old /pr route).
|
||||
// Chromeless PR body has no header, so surface open-on-web on the hub chrome while the PR segment is active.
|
||||
const prWebUrl =
|
||||
activeTab === 'pr' &&
|
||||
prController.prSidebarState.kind === 'ready' &&
|
||||
@@ -277,10 +252,7 @@ export function MobileSourceControlPanel({
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
// Why: retrying the request is useless while the transport's
|
||||
// reconnect loop is parked at its give-up cap — revive the
|
||||
// connection instead (issue #5049). loadStatus re-runs via
|
||||
// its connState effect once the new client connects.
|
||||
// Why: a parked reconnect loop makes retry useless — revive the connection instead (issue #5049); loadStatus re-runs on reconnect.
|
||||
if (connState !== 'connected' && hostId) {
|
||||
void forceReconnect(hostId)
|
||||
return
|
||||
@@ -294,13 +266,10 @@ export function MobileSourceControlPanel({
|
||||
</View>
|
||||
) : null
|
||||
|
||||
// History only needs the RPC client — do not block it behind git.status.
|
||||
// Changes/PR need status (branch, file list, head SHA), so they stay gated.
|
||||
// History only needs the RPC client, so it isn't gated on git.status; Changes/PR need status (branch, files, head).
|
||||
const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes'))
|
||||
const showHistory = activeTab === 'history' || visitedTabs.has('history')
|
||||
// Why: only mount the PR body while its segment is active. Keep-mounting retained
|
||||
// Mermaid WebViews and re-rendered the comment tree on every commit keystroke.
|
||||
// Controller + chip state still live for instant re-open without a full cold start.
|
||||
// Only mount PR body while active: keep-mounting retained Mermaid WebViews and re-rendered the comment tree per keystroke.
|
||||
const showPrBody = ready && activeTab === 'pr'
|
||||
const conflictOperation = status?.conflictOperation ?? null
|
||||
// Git status always reports a conflictOperation enum; 'unknown' means none.
|
||||
@@ -319,10 +288,7 @@ export function MobileSourceControlPanel({
|
||||
|
||||
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
|
||||
|
||||
{/* Branch card + PR chip are the Changes/Commits glance layer. On the PR
|
||||
tab they duplicate the ready PR body (#, state, checks rollup, branch
|
||||
trajectory), so hide the whole card there and let the PR panel own it —
|
||||
unless a merge/rebase conflict is active, which only this card can abort. */}
|
||||
{/* On the PR tab the branch card duplicates the ready PR body, so hide it there — unless a conflict is active, which only this card can abort. */}
|
||||
{ready && (activeTab !== 'pr' || hasActiveConflict) ? (
|
||||
<MobileSourceControlBranchCard
|
||||
branchLabel={branchLabel}
|
||||
@@ -357,8 +323,7 @@ export function MobileSourceControlPanel({
|
||||
headSha={prHeadSha}
|
||||
gitStatus={status}
|
||||
isGithubRepo={isHostedRepo}
|
||||
// Gate on the probe too: isGithubRepo=false mid-probe must render as
|
||||
// loading, not flash "unavailable for this provider" (old /pr parity).
|
||||
// Gate on the probe too: isGithubRepo=false mid-probe must render loading, not flash "unavailable".
|
||||
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
|
||||
controller={prController}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,8 @@ function eligibility(overrides: Record<string, unknown>) {
|
||||
defaultBaseRef: 'main',
|
||||
title: 'Ship mobile PR create',
|
||||
body: 'Generated body',
|
||||
// Accepted no-review lookup so Create / Push & Create is allowed unless overridden.
|
||||
reviewLookupOutcome: 'not_found',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
HostedReviewCreationBlockedReason,
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewCreationNextAction,
|
||||
HostedReviewLookupOutcome,
|
||||
HostedReviewProvider
|
||||
} from '../../../src/shared/hosted-review'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -62,6 +63,10 @@ export type MobileHostedReviewPrefill = {
|
||||
canCreate?: boolean
|
||||
blockedReason?: HostedReviewCreationBlockedReason
|
||||
nextAction?: HostedReviewCreationNextAction
|
||||
// Why: mobile lacks the desktop refresh/review-lookup signals, so it fails
|
||||
// closed on ambiguity. When the host could not prove the branch has no review
|
||||
// (`unavailable`), create — including the Push & Create path — stays blocked.
|
||||
reviewLookupOutcome?: HostedReviewLookupOutcome
|
||||
}
|
||||
|
||||
// Resolve the mobile compose prefill from the same hosted-review eligibility
|
||||
@@ -97,7 +102,14 @@ export async function resolveMobileHostedReviewPrefill(
|
||||
behind: args.behind
|
||||
})
|
||||
if (!eligibility) {
|
||||
return { ...fallback, canCreate: false, blockedReason: null, nextAction: null }
|
||||
// Eligibility itself could not be resolved: the review lookup is unproven.
|
||||
return {
|
||||
...fallback,
|
||||
canCreate: false,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: eligibility.provider,
|
||||
@@ -106,10 +118,17 @@ export async function resolveMobileHostedReviewPrefill(
|
||||
body: eligibility.body || '',
|
||||
canCreate: eligibility.canCreate,
|
||||
blockedReason: eligibility.blockedReason,
|
||||
nextAction: eligibility.nextAction
|
||||
nextAction: eligibility.nextAction,
|
||||
reviewLookupOutcome: eligibility.reviewLookupOutcome
|
||||
}
|
||||
} catch {
|
||||
return { ...fallback, canCreate: false, blockedReason: null, nextAction: null }
|
||||
return {
|
||||
...fallback,
|
||||
canCreate: false,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ function eligibility(
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'not_found',
|
||||
defaultBaseRef: 'main',
|
||||
title: 'Add feature',
|
||||
body: '',
|
||||
@@ -118,12 +119,115 @@ describe('mobile create form gating parity', () => {
|
||||
body: desktopEligibility.body ?? '',
|
||||
canCreate: desktopEligibility.canCreate,
|
||||
blockedReason: desktopEligibility.blockedReason,
|
||||
nextAction: desktopEligibility.nextAction
|
||||
nextAction: desktopEligibility.nextAction,
|
||||
// Mobile receives the lookup outcome from eligibility; thread it so the
|
||||
// gate reflects real prefills (current hosts always populate it).
|
||||
reviewLookupOutcome: desktopEligibility.reviewLookupOutcome
|
||||
}) === null
|
||||
|
||||
expect(mobileAllowsComposer).toBe(desktopAllowsComposer)
|
||||
})
|
||||
|
||||
it('fails closed when the review-lookup outcome is missing (older host)', () => {
|
||||
// A host that predates `reviewLookupOutcome` leaves review existence unproven.
|
||||
// Mobile must not open Create / Push & Create on that ambiguity.
|
||||
expect(
|
||||
getMobilePrCreateBlockMessage({
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
title: 'Add feature',
|
||||
body: '',
|
||||
canCreate: true,
|
||||
blockedReason: null
|
||||
})
|
||||
).toBe(
|
||||
'Orca could not confirm whether this branch already has a pull request. Try again in a moment.'
|
||||
)
|
||||
expect(
|
||||
getMobilePrCreateBlockMessage({
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
title: 'Add feature',
|
||||
body: '',
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push'
|
||||
})
|
||||
).toBe(
|
||||
'Orca could not confirm whether this branch already has a pull request. Try again in a moment.'
|
||||
)
|
||||
})
|
||||
|
||||
it('desktop gate hard-blocks on positive unresolved review evidence', () => {
|
||||
// Mobile lacks review-lookup signals, so it fails closed on ambiguity: the
|
||||
// shared desktop gate must return false even when eligibility looks ready.
|
||||
expect(
|
||||
shouldOpenChecksPanelCreateComposer({
|
||||
activeReview: null,
|
||||
isFolder: false,
|
||||
branch: 'feature/x',
|
||||
hostedReviewCreation: eligibility({ canCreate: true }),
|
||||
reviewLookup: 'positive_unresolved'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('desktop gate hard-blocks during a hard refresh error', () => {
|
||||
expect(
|
||||
shouldOpenChecksPanelCreateComposer({
|
||||
activeReview: null,
|
||||
isFolder: false,
|
||||
branch: 'feature/x',
|
||||
hostedReviewCreation: eligibility({ canCreate: true }),
|
||||
hasHardRefreshError: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed on an unavailable review lookup even when eligibility looks ready', () => {
|
||||
// The existing-review lookup could not prove there is no PR; mobile has no
|
||||
// review-lookup signal of its own, so create must be blocked.
|
||||
expect(
|
||||
getMobilePrCreateBlockMessage({
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
title: 'Add feature',
|
||||
body: '',
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
).toBe(
|
||||
'Orca could not confirm whether this branch already has a pull request. Try again in a moment.'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed on unavailable even on the needs_push Push & Create path', () => {
|
||||
// needs_push would normally be allowed (Push & Create); an unavailable lookup
|
||||
// must still block it — this is the fail-open gap the parity gate closes.
|
||||
const mobileBlocked =
|
||||
getMobilePrCreateBlockMessage({
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
title: 'Add feature',
|
||||
body: '',
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
}) !== null
|
||||
const desktopAllowsComposer = shouldOpenChecksPanelCreateComposer({
|
||||
activeReview: null,
|
||||
isFolder: false,
|
||||
branch: 'feature/x',
|
||||
hostedReviewCreation: eligibility({
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
})
|
||||
expect(mobileBlocked).toBe(true)
|
||||
expect(desktopAllowsComposer).toBe(false)
|
||||
})
|
||||
|
||||
it('stays safely blocked for a reason added by a newer desktop contract', () => {
|
||||
expect(
|
||||
getMobilePrCreateBlockMessage({
|
||||
@@ -158,7 +262,8 @@ describe('resolveMobilePrPrefill', () => {
|
||||
nextAction: null,
|
||||
defaultBaseRef: 'develop',
|
||||
title: 'Add feature',
|
||||
body: 'Body'
|
||||
body: 'Body',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
})
|
||||
])
|
||||
await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({
|
||||
@@ -168,7 +273,8 @@ describe('resolveMobilePrPrefill', () => {
|
||||
body: 'Body',
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'not_found'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -182,7 +288,8 @@ describe('resolveMobilePrPrefill', () => {
|
||||
nextAction: 'push',
|
||||
defaultBaseRef: 'main',
|
||||
title: 'Add feature',
|
||||
body: ''
|
||||
body: '',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
})
|
||||
])
|
||||
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
|
||||
@@ -198,7 +305,8 @@ describe('resolveMobilePrPrefill', () => {
|
||||
review: null,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit',
|
||||
defaultBaseRef: 'main'
|
||||
defaultBaseRef: 'main',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
})
|
||||
])
|
||||
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
|
||||
@@ -209,15 +317,39 @@ describe('resolveMobilePrPrefill', () => {
|
||||
|
||||
it('returns a blocked fallback when eligibility is unavailable', async () => {
|
||||
const client = clientWith([fail('nope')])
|
||||
await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({
|
||||
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
|
||||
expect(prefill).toEqual({
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
title: 'feature/x',
|
||||
body: '',
|
||||
canCreate: false,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
nextAction: null,
|
||||
// Eligibility could not be resolved, so the review lookup is unproven.
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
// A prefill Orca could not resolve must not offer create.
|
||||
expect(getMobilePrCreateBlockMessage(prefill)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('threads reviewLookupOutcome from eligibility into the prefill and blocks needs_push', async () => {
|
||||
const client = clientWith([
|
||||
ok({
|
||||
provider: 'github',
|
||||
canCreate: false,
|
||||
review: null,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
defaultBaseRef: 'main',
|
||||
title: 'Add feature',
|
||||
body: '',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
])
|
||||
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
|
||||
expect(prefill.reviewLookupOutcome).toBe('unavailable')
|
||||
expect(getMobilePrCreateBlockMessage(prefill)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('blocks without calling the RPC when there is no branch', async () => {
|
||||
|
||||
@@ -43,10 +43,18 @@ export function getMobilePrCreateSuccessWarning(
|
||||
}
|
||||
|
||||
export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string | null {
|
||||
const copy = hostedReviewCopy(prefill.provider)
|
||||
if (prefill.canCreate !== false || shouldPushBeforeMobileHostedReviewCreate(prefill)) {
|
||||
// Fail closed: only an accepted no-review lookup (`not_found`) may open
|
||||
// Create / Push & Create. `unavailable`, `found`, or a missing outcome (an
|
||||
// older host that predates the field) all leave review existence unproven —
|
||||
// mobile has no refresh/review-lookup signal of its own, so it must not
|
||||
// offer create, or the needs_push Push & Create path would slip through.
|
||||
if (prefill.reviewLookupOutcome !== 'not_found') {
|
||||
return `Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Try again in a moment.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
const copy = hostedReviewCopy(prefill.provider)
|
||||
switch (prefill.blockedReason) {
|
||||
case 'dirty':
|
||||
return `Commit changes before creating a ${copy.reviewLabel}.`
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import {
|
||||
buildMobileQuickCommandLaunch,
|
||||
getQuickCommandDisplayPreview,
|
||||
getQuickCommandPreview,
|
||||
supportsMobileQuickCommands
|
||||
} from './quick-commands'
|
||||
|
||||
function command(overrides: Partial<TerminalQuickCommand> = {}): TerminalQuickCommand {
|
||||
return {
|
||||
id: 'command',
|
||||
label: 'Command',
|
||||
action: 'terminal-command',
|
||||
command: 'pnpm test',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' },
|
||||
...overrides
|
||||
} as TerminalQuickCommand
|
||||
}
|
||||
|
||||
describe('mobile quick-command launch', () => {
|
||||
it('only exposes quick commands when the host advertises the complete contract', () => {
|
||||
expect(supportsMobileQuickCommands(undefined)).toBe(false)
|
||||
expect(supportsMobileQuickCommands([])).toBe(false)
|
||||
expect(supportsMobileQuickCommands(['terminal.binary-stream.v1'])).toBe(false)
|
||||
expect(supportsMobileQuickCommands(['terminal.quick-commands.v1'])).toBe(true)
|
||||
})
|
||||
|
||||
it('joins multiline runnable commands with "; " to match desktop', () => {
|
||||
// Parity with desktop flattenTerminalQuickCommand: the same saved command
|
||||
// must run identically on desktop and mobile.
|
||||
expect(
|
||||
buildMobileQuickCommandLaunch(command({ command: 'cd app\nnpm install\nnpm test' }))
|
||||
).toEqual({
|
||||
options: {
|
||||
startupCommand: 'cd app; npm install; npm test',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('forces shell-ready delivery for commands that resemble bare agent launches', () => {
|
||||
expect(buildMobileQuickCommandLaunch(command({ command: 'codex exec --full-auto' }))).toEqual({
|
||||
options: {
|
||||
startupCommand: 'codex exec --full-auto',
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps append-enter-off commands as unsubmitted terminal input', () => {
|
||||
const multiline = 'printf "first\\nsecond"\n# leave this unsubmitted'
|
||||
expect(
|
||||
buildMobileQuickCommandLaunch(command({ command: multiline, appendEnter: false }))
|
||||
).toEqual({
|
||||
options: { initialPrompt: multiline, enter: false, successToast: 'Command inserted' }
|
||||
})
|
||||
})
|
||||
|
||||
it('injects supported agent prompts into the host-built launch command', () => {
|
||||
expect(
|
||||
buildMobileQuickCommandLaunch(
|
||||
command({
|
||||
action: 'agent-prompt',
|
||||
agent: 'codex',
|
||||
prompt: 'Review this diff'
|
||||
})
|
||||
)
|
||||
).toEqual({ agent: 'codex', options: { agentPrompt: 'Review this diff' } })
|
||||
})
|
||||
|
||||
it('bounds native row text without truncating searchable or executable content', () => {
|
||||
const longPrompt = `Review ${'x'.repeat(5993)}`
|
||||
const agentCommand = command({
|
||||
action: 'agent-prompt',
|
||||
agent: 'codex',
|
||||
prompt: longPrompt
|
||||
})
|
||||
|
||||
expect(getQuickCommandDisplayPreview(agentCommand)).toHaveLength(240)
|
||||
expect(getQuickCommandDisplayPreview(agentCommand)).toMatch(/^Codex: Review .*…$/)
|
||||
expect(getQuickCommandPreview(agentCommand)).toBe(`Codex: ${longPrompt}`)
|
||||
expect(buildMobileQuickCommandLaunch(agentCommand)).toEqual({
|
||||
agent: 'codex',
|
||||
options: { agentPrompt: longPrompt }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
|
||||
import {
|
||||
applyTerminalQuickCommandMutation,
|
||||
flattenTerminalQuickCommand,
|
||||
isTerminalAgentQuickCommand,
|
||||
MAX_QUICK_COMMANDS,
|
||||
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
|
||||
MAX_QUICK_COMMAND_LABEL_LENGTH,
|
||||
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
|
||||
parseNormalizedTerminalQuickCommands,
|
||||
supportsTerminalAgentQuickCommand,
|
||||
terminalQuickCommandMatchesRepo,
|
||||
type TerminalQuickCommandMutation
|
||||
} from '../../../src/shared/terminal-quick-commands'
|
||||
import { TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents'
|
||||
|
||||
// Reuse the canonical desktop quick-command logic (pure, no heavy deps) so
|
||||
// mobile behaves identically to desktop. Only genuinely mobile-specific pieces
|
||||
// — agent-branded labels, native row truncation, and the launch plan — live here.
|
||||
export {
|
||||
isTerminalAgentQuickCommand as isAgentQuickCommand,
|
||||
terminalQuickCommandMatchesRepo as quickCommandMatchesRepo,
|
||||
supportsTerminalAgentQuickCommand,
|
||||
MAX_QUICK_COMMANDS,
|
||||
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
|
||||
MAX_QUICK_COMMAND_LABEL_LENGTH,
|
||||
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
|
||||
parseNormalizedTerminalQuickCommands,
|
||||
applyTerminalQuickCommandMutation,
|
||||
type TerminalQuickCommandMutation
|
||||
}
|
||||
|
||||
const MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH = 240
|
||||
|
||||
export function supportsMobileQuickCommands(capabilities: readonly string[] | undefined): boolean {
|
||||
return capabilities?.includes(TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY) === true
|
||||
}
|
||||
|
||||
export type MobileQuickCommandLaunch = {
|
||||
agent?: TuiAgent
|
||||
options: {
|
||||
agentPrompt?: string
|
||||
startupCommand?: string
|
||||
startupCommandDelivery?: 'shell-ready'
|
||||
initialPrompt?: string
|
||||
enter?: boolean
|
||||
successToast?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMobileQuickCommandLaunch(
|
||||
command: TerminalQuickCommand
|
||||
): MobileQuickCommandLaunch | null {
|
||||
if (isTerminalAgentQuickCommand(command)) {
|
||||
if (!command.prompt.trim() || !supportsTerminalAgentQuickCommand(command.agent)) {
|
||||
return null
|
||||
}
|
||||
return { agent: command.agent, options: { agentPrompt: command.prompt } }
|
||||
}
|
||||
if (!command.command.trim()) {
|
||||
return null
|
||||
}
|
||||
return command.appendEnter === false
|
||||
? {
|
||||
options: {
|
||||
initialPrompt: command.command,
|
||||
enter: false,
|
||||
successToast: `${command.label.trim() || 'Quick command'} inserted`
|
||||
}
|
||||
}
|
||||
: {
|
||||
// Why: raw commands that resemble bare agent launches can otherwise
|
||||
// select the fast path and race slow native, WSL, or SSH shell startup.
|
||||
// flattenTerminalQuickCommand joins multiline bodies exactly as desktop
|
||||
// does so the same saved command runs identically on both.
|
||||
options: {
|
||||
startupCommand: flattenTerminalQuickCommand(command).command,
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getQuickCommandAgentLabel(agent: TuiAgent): string {
|
||||
return MOBILE_TUI_AGENT_LABELS[agent] ?? agent
|
||||
}
|
||||
|
||||
// The subtitle desktop shows under each quick command: agent prompts read
|
||||
// "Codex: <prompt>", terminal commands show the raw command text.
|
||||
export function getQuickCommandPreview(command: TerminalQuickCommand): string {
|
||||
if (isTerminalAgentQuickCommand(command)) {
|
||||
return `${getQuickCommandAgentLabel(command.agent)}: ${command.prompt}`
|
||||
}
|
||||
return command.command
|
||||
}
|
||||
|
||||
export function getQuickCommandDisplayPreview(command: TerminalQuickCommand): string {
|
||||
const preview = getQuickCommandPreview(command)
|
||||
if (preview.length <= MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH) {
|
||||
return preview
|
||||
}
|
||||
// Why: one-line rows should not send up to 6 KB each through native text
|
||||
// layout; full command bodies remain available to search, edit, and launch.
|
||||
return `${preview.slice(0, MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH - 1)}…`
|
||||
}
|
||||
@@ -28,14 +28,9 @@ type TerminalViewportRefitOptions = {
|
||||
initializedHandlesRef: RefObject<Set<string>>
|
||||
connState: ConnectionState
|
||||
tabStripVisible: boolean
|
||||
// Why: terminal text size (font scale) — changing it changes the cell size, so
|
||||
// the PTY must be re-fitted to a new column count and reflowed.
|
||||
// Why: text size (font scale); changing it changes cell size, so the PTY must be re-fitted to a new column count.
|
||||
textScale: number
|
||||
// Why: the terminal's measured frame width changes when a side panel docks/undocks
|
||||
// or EITHER sidebar is drag-resized (the left worktree sidebar shrinks the detail
|
||||
// pane; the right dock takes a slice of the row) — all without any window-dim or
|
||||
// tab-strip change. Carries that measured width so those resizes re-fit the PTY;
|
||||
// the 150ms debounce coalesces the stream of drag widths into one settle-time refit.
|
||||
// Why: measured frame width; panel dock/undock or sidebar resize changes it with no window/tab change, so it re-fits the PTY.
|
||||
terminalFrameWidth: number
|
||||
unsubscribeTerminal: (handle: string) => void
|
||||
subscribeToTerminal: (handle: string) => void
|
||||
@@ -46,12 +41,7 @@ type TerminalViewportRefitNotifications = {
|
||||
notifyKeyboardVisibility: (visible: boolean) => void
|
||||
}
|
||||
|
||||
// Why: re-measure the phone viewport when layout-affecting state changes
|
||||
// outside the subscribe path — the tab strip toggling visibility, and the
|
||||
// window itself resizing (fold/unfold on foldables, orientation rotation,
|
||||
// split-screen). Without the resize trigger, a PTY fitted on the folded
|
||||
// cover screen stays at cover-screen cols after unfolding and the terminal
|
||||
// renders in only part of the display (#4579's "cut in half" symptom).
|
||||
// Why: re-measure on layout changes outside the subscribe path (tab strip, fold/rotate/resize), or a PTY renders "cut in half" (#4579).
|
||||
export function useTerminalViewportRefit(
|
||||
options: TerminalViewportRefitOptions
|
||||
): TerminalViewportRefitNotifications {
|
||||
@@ -82,9 +72,7 @@ export function useTerminalViewportRefit(
|
||||
keyboardVisible: false,
|
||||
pending: false
|
||||
})
|
||||
// Why: marks the currently-armed timer as a height refit so its callback can
|
||||
// re-check the keyboard at fire time. Non-height refits (width/rotation and
|
||||
// the forced reconnect/foreground re-asserts) stay unguarded so they always run.
|
||||
// Why: marks the armed timer as a height refit so its callback re-checks the keyboard; other refits always run unguarded.
|
||||
const heightOriginatedRefitRef = useRef(false)
|
||||
const scheduleViewportRefit = useCallback(
|
||||
(options?: { heightOriginated?: boolean }) => {
|
||||
@@ -94,9 +82,7 @@ export function useTerminalViewportRefit(
|
||||
heightOriginatedRefitRef.current = options?.heightOriginated ?? false
|
||||
refitTimerRef.current = setTimeout(() => {
|
||||
refitTimerRef.current = null
|
||||
// Why: a height refit deferred at keyboard-close can fire after the keyboard
|
||||
// reopened within the 150ms debounce; re-check and re-defer so we never
|
||||
// reflow the PTY mid-keystroke. Scoped via the height-originated flag.
|
||||
// Why: a height refit can fire after the keyboard reopened within the debounce; re-check so we never reflow the PTY mid-keystroke.
|
||||
if (heightOriginatedRefitRef.current) {
|
||||
heightOriginatedRefitRef.current = false
|
||||
const decision = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, {
|
||||
@@ -143,11 +129,7 @@ export function useTerminalViewportRefit(
|
||||
}
|
||||
viewportRef.current = dims
|
||||
viewportMeasuredRef.current = true
|
||||
// Why: prefer the in-place viewport update RPC over the legacy
|
||||
// unsubscribe → subscribe cycle. This keeps the server-side
|
||||
// mobile subscriber record alive (no driver=idle blip on the
|
||||
// desktop banner; no false phone-fit baseline capture on the
|
||||
// re-subscribe). See docs/mobile-presence-lock.md.
|
||||
// Why: prefer in-place updateViewport over resubscribe to keep the mobile subscriber record alive. See docs/mobile-presence-lock.md.
|
||||
const rpc = clientRef.current
|
||||
const deviceToken = deviceTokenRef.current
|
||||
if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') {
|
||||
@@ -165,11 +147,7 @@ export function useTerminalViewportRefit(
|
||||
if (isTerminalUpdateViewportUpdated(response)) {
|
||||
rpc.updateTerminalSubscriptionViewport(handle, dims)
|
||||
if (isTerminalUpdateViewportApplied(response)) {
|
||||
// Why: updateViewport reflows the server PTY and re-streams only
|
||||
// the visible screen, so the WebView's local xterm scrollback
|
||||
// stays wrapped at the old width. Reflow it locally only when
|
||||
// the server actually applied phone-fit; desktop mode records
|
||||
// the viewport but leaves the PTY at desktop dims.
|
||||
// Why: updateViewport re-streams only the visible screen, so local scrollback stays wrapped at the old width — reflow it locally.
|
||||
ref.reflow(dims.cols, dims.rows)
|
||||
}
|
||||
return
|
||||
@@ -205,14 +183,7 @@ export function useTerminalViewportRefit(
|
||||
scheduleViewportRefit()
|
||||
}, [scheduleViewportRefit])
|
||||
|
||||
// Why: the tab strip is hidden when only one terminal exists and shown
|
||||
// once a second is created. Crossing the 1↔2 boundary changes the
|
||||
// visible terminal area by ~40px, so the cached viewport dims in
|
||||
// viewportRef become stale. Mark the viewport as un-measured so the
|
||||
// next subscribe path's self-correcting loop (init → measure →
|
||||
// resubscribe-with-fresh-viewport) re-runs against the new layout.
|
||||
// Also schedule an explicit refit to cover the case where no new
|
||||
// subscribe is happening.
|
||||
// Why: the tab strip toggles at the 1↔2 terminal boundary (~40px area change), so the cached viewport goes stale.
|
||||
const prevTabStripVisibleRef = useRef(tabStripVisible)
|
||||
useEffect(() => {
|
||||
if (prevTabStripVisibleRef.current === tabStripVisible) {
|
||||
@@ -223,10 +194,7 @@ export function useTerminalViewportRefit(
|
||||
scheduleViewportRefit()
|
||||
}, [tabStripVisible, viewportMeasuredRef, scheduleViewportRefit])
|
||||
|
||||
// Why: fold/unfold and rotation change the window dimensions without any
|
||||
// subscribe or tab-strip transition. The PTY must be re-fitted to the new
|
||||
// viewport or the terminal keeps the old grid (fit scale is capped at 1,
|
||||
// so a grown window leaves the surface pinned to a fraction of the screen).
|
||||
// Why: fold/unfold and rotation change window dims with no subscribe/tab change; refit or the grid stays stale (fit capped at 1).
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions()
|
||||
const prevWindowDimsRef = useRef({ width: windowWidth, height: windowHeight })
|
||||
useEffect(() => {
|
||||
@@ -235,8 +203,7 @@ export function useTerminalViewportRefit(
|
||||
return
|
||||
}
|
||||
prevWindowDimsRef.current = { width: windowWidth, height: windowHeight }
|
||||
// Why: adjustResize can change only window height while the IME is open;
|
||||
// the frame-height notifier schedules one correction after it closes.
|
||||
// Why: adjustResize can change only window height while the IME is open; the frame-height notifier corrects once it closes.
|
||||
if (prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible) {
|
||||
return
|
||||
}
|
||||
@@ -244,10 +211,7 @@ export function useTerminalViewportRefit(
|
||||
scheduleViewportRefit()
|
||||
}, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit])
|
||||
|
||||
// Why: the text size changed, so the WebView is re-rendering at a new font/cell
|
||||
// size. Re-measure and resize the PTY so the server reflows to the new column
|
||||
// count. The refit's own 150ms debounce gives the WebView a frame to apply the
|
||||
// new fontSize before we measure the resulting cell metrics.
|
||||
// Why: on text-size change the refit's 150ms debounce lets the WebView apply the new fontSize before we re-measure cell metrics.
|
||||
const prevTextScaleRef = useRef(textScale)
|
||||
useEffect(() => {
|
||||
if (prevTextScaleRef.current === textScale) {
|
||||
@@ -258,10 +222,7 @@ export function useTerminalViewportRefit(
|
||||
scheduleViewportRefit()
|
||||
}, [textScale, viewportMeasuredRef, scheduleViewportRefit])
|
||||
|
||||
// Why: the terminal's measured frame width changes when a panel docks/undocks or
|
||||
// either sidebar is drag-resized — none of which touch the window dims or tab
|
||||
// strip — so the cached viewport goes stale and the PTY keeps the pre-resize
|
||||
// width. Mark un-measured and refit when the measured width changes.
|
||||
// Why: panel dock/undock or sidebar resize changes frame width with no window/tab change, so the cached viewport goes stale.
|
||||
const prevFrameWidthRef = useRef(terminalFrameWidth)
|
||||
useEffect(() => {
|
||||
if (prevFrameWidthRef.current === terminalFrameWidth) {
|
||||
@@ -284,8 +245,7 @@ export function useTerminalViewportRefit(
|
||||
},
|
||||
[viewportMeasuredRef, scheduleViewportRefit]
|
||||
)
|
||||
// Why: notify imperatively so layout churn does not rerender the full session;
|
||||
// a height change during typing is coalesced into one refit after keyboard close.
|
||||
// Why: notify imperatively so layout churn doesn't rerender the full session.
|
||||
const notifyTerminalFrameHeight = useCallback(
|
||||
(height: number) => notifyFrameHeightRefitEvent({ type: 'frame-height', height }),
|
||||
[notifyFrameHeightRefitEvent]
|
||||
@@ -310,8 +270,7 @@ export function useTerminalViewportRefit(
|
||||
if (!shouldRefit) {
|
||||
return
|
||||
}
|
||||
// Why: the cached grid can match while the host PTY changed in background;
|
||||
// reasserting equal dimensions is the convergence signal after iOS resume.
|
||||
// Why: cached grid can match while the host PTY changed in background; reassert equal dims to converge after iOS resume.
|
||||
viewportMeasuredRef.current = false
|
||||
scheduleForcedViewportRefit()
|
||||
})
|
||||
@@ -325,11 +284,9 @@ export function useTerminalViewportRefit(
|
||||
if (previous === 'connected' || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
// Why: an in-place desktop upgrade may add updateViewport; reconnect is the
|
||||
// narrow boundary where an old-host method_not_found cache becomes stale.
|
||||
// Why: an in-place desktop upgrade may add updateViewport; reconnect is where the cached method_not_found goes stale.
|
||||
updateViewportCapabilityRef.current = 'unknown'
|
||||
// Why: reconnect can restore a PTY whose host-side size changed while the
|
||||
// socket was down, so equal cached dimensions still need reassertion.
|
||||
// Why: reconnect can restore a PTY resized while the socket was down, so equal cached dims still need reassertion.
|
||||
viewportMeasuredRef.current = false
|
||||
scheduleForcedViewportRefit()
|
||||
}, [connState, viewportMeasuredRef, scheduleForcedViewportRefit])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// xterm.js WebView document + default Tokyonight theme. Extracted from
|
||||
// TerminalWebView.tsx to keep that file within the max-lines budget.
|
||||
// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
|
||||
@@ -38,15 +37,7 @@ const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
|
||||
brightWhite: '#c0caf5'
|
||||
}
|
||||
|
||||
// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
|
||||
// positioning designed for the desktop's terminal dimensions (~150+ cols).
|
||||
// We initialize xterm at the desktop's exact cols/rows so those escape codes
|
||||
// render correctly, then use a measured CSS transform: scale() to fit the
|
||||
// canvas into the phone viewport. The scale is computed after xterm opens
|
||||
// by measuring the rendered surface width, not hardcoded, so it adapts to
|
||||
// any terminal column count (80, 150, 200+). All touch gestures (scroll,
|
||||
// pinch-to-zoom, pan) are handled by custom JS rather than native WebView
|
||||
// behavior, so they work correctly with the CSS scale transform.
|
||||
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
|
||||
export const XTERM_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -1892,6 +1883,5 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Why: WebView treats source identity as page identity on some platforms; keep
|
||||
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
|
||||
// Why: some WebViews treat source identity as page identity; keep this stable so re-renders don't reload xterm.
|
||||
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
// Why: collapses the per-screen WebSocket connection model into a single
|
||||
// shared RpcClient per host. Implements the design in
|
||||
// docs/mobile-shared-client-per-host.md.
|
||||
//
|
||||
// Lifecycle rules:
|
||||
// - First request for a host opens its client lazily.
|
||||
// - Refcount tracks active subscribers; when it drops to zero we schedule
|
||||
// a 30-second idle close timer. If a new subscriber arrives within that
|
||||
// window we cancel and reuse the same client.
|
||||
// - removeHost() forces an immediate close so re-pairing gets a fresh
|
||||
// transport.
|
||||
// Single shared RpcClient per host, collapsing the old per-screen WebSocket connections.
|
||||
// Design: docs/mobile-shared-client-per-host.md.
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -42,40 +33,28 @@ export type RpcClientContextValue = {
|
||||
closeHost: (hostId: string) => void
|
||||
getState: (hostId: string) => ConnectionState
|
||||
getReconnectAttempt: (hostId: string) => number
|
||||
// Why: timestamp (ms epoch) of the last successful 'connected' state
|
||||
// transition for this host, or null if never connected this session.
|
||||
// Used by the UI to escalate "Reconnecting…" into a "host appears
|
||||
// unreachable, re-pair?" prompt.
|
||||
// Why: ms-epoch of the last 'connected' (null if never this session); UI escalates "Reconnecting…" into a re-pair prompt.
|
||||
getLastConnectedAt: (hostId: string) => number | null
|
||||
getActivePath: (hostId: string) => MobileConnectionPath
|
||||
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
|
||||
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
|
||||
subscribeAllHosts: (listener: () => void) => () => void
|
||||
// Why: lets the home screen feed already-loaded HostProfiles in so we
|
||||
// don't pay loadHosts() latency twice (once in the focus-effect, again
|
||||
// inside openEntry).
|
||||
// Why: lets the home screen feed already-loaded HostProfiles so we don't pay loadHosts() latency twice.
|
||||
primeHosts: (hosts: HostProfile[]) => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<RpcClientContextValue | null>(null)
|
||||
|
||||
export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
// Why: entries live in a ref so updates don't force re-renders of the
|
||||
// entire tree on every connection state change. State propagation goes
|
||||
// through per-host listener Sets instead.
|
||||
// Why: entries in a ref so state changes don't re-render the whole tree; propagation goes through per-host listener Sets.
|
||||
const storeRef = useRef<Map<string, StoreEntry>>(new Map())
|
||||
const stateListenersRef = useRef<Map<string, Set<(state: ConnectionState) => void>>>(new Map())
|
||||
const allHostsListenersRef = useRef<Set<() => void>>(new Set())
|
||||
|
||||
// Pending opens (avoid two acquire() callers in the same render racing the
|
||||
// host lookup). Keyed by hostId, value is a sentinel resolved when the
|
||||
// entry materialises.
|
||||
// Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup.
|
||||
const pendingOpensRef = useRef(new HostClientOpenRegistry())
|
||||
|
||||
// Why: a fast-path cache of already-loaded HostProfiles. Screens that
|
||||
// have run loadHosts() can call primeHosts() to populate this and skip
|
||||
// the second loadHosts() inside openEntry. Without this we'd serialize
|
||||
// two Keychain passes on cold start.
|
||||
// Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start.
|
||||
const primedHostsRef = useRef<Map<string, HostProfile>>(new Map())
|
||||
|
||||
function notifyHostState(hostId: string, state: ConnectionState) {
|
||||
@@ -118,28 +97,20 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
const pendingOpen = pendingOpensRef.current.register(hostId, promise)
|
||||
|
||||
try {
|
||||
// Why: prefer the primed cache (populated by primeHosts when the
|
||||
// screen already ran loadHosts) so we don't serialize a second
|
||||
// Keychain pass behind the first one on cold start.
|
||||
// Why: prefer the primed cache so we don't serialize a second Keychain pass on cold start.
|
||||
let host = primedHostsRef.current.get(hostId)
|
||||
if (!host) {
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
host = hosts.find((h) => h.id === hostId)
|
||||
} catch {
|
||||
// Why: a Keychain failure on cold start (rare but observed —
|
||||
// happens when iOS Keychain is mid-unlock or Android Keystore
|
||||
// races the JS bridge). Surface it as 'disconnected' so the
|
||||
// home card flips off the perma-spinner and the user can hit
|
||||
// Reconnect from the action sheet to retry.
|
||||
// Why: cold-start Keychain failure (iOS mid-unlock / Android Keystore race); surface 'disconnected' so the user can Reconnect.
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
notifyAllHosts()
|
||||
return null
|
||||
}
|
||||
if (!host) {
|
||||
// Why: returning silently leaves mounted screens on a permanent
|
||||
// spinner (STA-1511) — surface 'disconnected' so they can render
|
||||
// their waiting/retry affordance instead.
|
||||
// Why: silent return leaves screens on a permanent spinner (STA-1511); surface 'disconnected' so they show a retry affordance.
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
notifyAllHosts()
|
||||
return null
|
||||
@@ -160,9 +131,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
|
||||
} catch {
|
||||
// Why: connect() can throw synchronously if the public key is
|
||||
// malformed or the endpoint URL is invalid. Notify so the UI
|
||||
// doesn't sit on a stale 'connecting' label forever.
|
||||
// Why: openHostLogicalClient can throw synchronously (bad public key / invalid URL); notify so the UI leaves 'connecting'.
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
notifyAllHosts()
|
||||
return null
|
||||
@@ -191,12 +160,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: `acquire` is the synchronous get-or-open. If the entry already
|
||||
// exists, return its client immediately and bump the refcount. If not,
|
||||
// kick off an async open (the consumer will subscribe via
|
||||
// `subscribeHostState` and re-read once 'connecting' fires). Optionally
|
||||
// accepts the HostProfile so the caller can avoid an extra loadHosts()
|
||||
// pass inside openEntry.
|
||||
// Synchronous get-or-open: returns an existing client immediately, else kicks off an async open and returns null this tick.
|
||||
const acquire = useCallback(
|
||||
(hostId: string, host?: HostProfile): RpcClient | null => {
|
||||
if (host) {
|
||||
@@ -207,9 +171,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
existing.refCount += 1
|
||||
return existing.client
|
||||
}
|
||||
// Trigger async open. The acquire-side will return null this tick and
|
||||
// try again once the state listener fires; consumers are expected to
|
||||
// call acquire() inside an effect that re-runs on state changes.
|
||||
// Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes.
|
||||
void openEntry(hostId).then((entry) => {
|
||||
if (!entry) {
|
||||
return
|
||||
@@ -227,14 +189,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: refcount dropping to 0 no longer triggers an idle-close. The
|
||||
// app deliberately keeps live WebSockets open while the app itself is
|
||||
// foregrounded — closing on transient navigation gaps was producing
|
||||
// false 'disconnected' flashes when the user navigated home → host →
|
||||
// back to home faster than React could re-acquire on the home side.
|
||||
// Connections still close on: explicit user Disconnect, host removal,
|
||||
// app backgrounding (OS-level socket suspension), and provider
|
||||
// unmount (app shutdown).
|
||||
// Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded.
|
||||
const release = useCallback((hostId: string) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
if (!entry) {
|
||||
@@ -246,10 +201,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
const forceReconnect = useCallback(
|
||||
async (hostId: string) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
// Why: preserve refcount across the swap. If the user reaches
|
||||
// forceReconnect via the Disconnect → Reconnect path, the entry
|
||||
// was already closed and refCount=0; fall back to active listener
|
||||
// count as a proxy for "screens still watching this host."
|
||||
// Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count.
|
||||
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
|
||||
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
|
||||
if (entry) {
|
||||
@@ -318,16 +270,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Close all clients on provider unmount (app shutdown).
|
||||
// Why: deps must be empty so this cleanup ONLY runs on real unmount.
|
||||
// Hot-reload re-evaluates this module, which makes closeEntry a new
|
||||
// function identity. With [closeEntry] as deps, every Fast Refresh
|
||||
// would tear down all open WebSockets, leaving screens holding closed
|
||||
// clients and the user staring at a 'Reconnecting…' card. Reading
|
||||
// storeRef.current and the locally-scoped closeEntry inside the
|
||||
// cleanup is safe — the ref is stable across renders, and the
|
||||
// function captured here will be the one defined in the same
|
||||
// closure as this effect.
|
||||
// Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const store = storeRef.current
|
||||
@@ -339,9 +282,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: nudge every live client when the OS signals the link may be back
|
||||
// (foreground, network restored/switched) so sessions recover without an
|
||||
// app restart (issue #5049).
|
||||
// Why: nudge live clients when the OS signals the link may be back so sessions recover without a restart (issue #5049).
|
||||
useEffect(() => {
|
||||
return subscribeConnectionRevivalTriggers(() => {
|
||||
for (const entry of storeRef.current.values()) {
|
||||
@@ -392,9 +333,7 @@ export function useRpcClientContext(): RpcClientContextValue {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Why: the primary hook for screens. Acquires the shared client for a
|
||||
// hostId on mount and releases on unmount. Re-renders when the host's
|
||||
// connection state changes.
|
||||
// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change.
|
||||
export function useHostClient(hostId: string | undefined): {
|
||||
client: RpcClient | null
|
||||
state: ConnectionState
|
||||
@@ -419,17 +358,13 @@ export function useHostClient(hostId: string | undefined): {
|
||||
return
|
||||
}
|
||||
setState(next)
|
||||
// Why: the client materialises after an async open, and forceReconnect
|
||||
// swaps in a fresh client object. Re-read on every state change so a
|
||||
// mounted screen never keeps driving a stale (closed) client.
|
||||
// Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one.
|
||||
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
|
||||
if (found && found.client !== clientRef.current) {
|
||||
clientRef.current = found.client
|
||||
force((n) => n + 1)
|
||||
} else if (!found && clientRef.current) {
|
||||
// Why: closeHost deletes the entry without a replacement; holding the
|
||||
// closed client would let screens keep issuing requests that can never
|
||||
// resolve (STA-1511). Null it so they render disconnected states.
|
||||
// Why: closeHost deletes the entry with no replacement; null it so screens don't drive a dead client (STA-1511).
|
||||
clientRef.current = null
|
||||
force((n) => n + 1)
|
||||
}
|
||||
@@ -450,9 +385,7 @@ export function useHostClient(hostId: string | undefined): {
|
||||
return { client: clientRef.current, state }
|
||||
}
|
||||
|
||||
// Why: home screen renders all paired hosts at once. Acquires each on
|
||||
// mount, releases on unmount. The provider's refcounting ensures we
|
||||
// don't double-open if a host-detail screen is also open.
|
||||
// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts.
|
||||
export function useAllHostClients(hostIds: string[]): Array<{
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
@@ -510,9 +443,7 @@ export function useAllHostClients(hostIds: string[]): Array<{
|
||||
}, [key, tick])
|
||||
}
|
||||
|
||||
// Why: removeHost() in host-store.ts must close the live client, but
|
||||
// host-store has no React-side handle. Expose a hook that lets callers
|
||||
// close a host after removal.
|
||||
// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
|
||||
export function useCloseHost(): (hostId: string) => void {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.closeHost
|
||||
@@ -524,9 +455,7 @@ export function useForceReconnect(): (hostId: string) => Promise<void> {
|
||||
return ctx.forceReconnect
|
||||
}
|
||||
|
||||
// Why: lets the home screen feed already-loaded HostProfiles in so the
|
||||
// provider can skip its own loadHosts() pass when it eventually opens
|
||||
// each host — collapses two serial Keychain reads on cold-start into one.
|
||||
// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start.
|
||||
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.primeHosts
|
||||
|
||||
@@ -10,12 +10,8 @@ export type HostStatusGates = {
|
||||
compatVerdict: CompatVerdict
|
||||
}
|
||||
|
||||
// Why: read desktop's protocol version from status.get on every connect and
|
||||
// re-evaluate compatibility. If the desktop declares this mobile build too old
|
||||
// (or vice versa via the local minimum), the host detail screen swaps to a
|
||||
// hard-block screen instead of the worktree list. Today's compat constants are
|
||||
// wide-open so this never blocks; the wire format is in place to flip a switch
|
||||
// in a future release.
|
||||
// Reads status.get on connect for capabilities, protocol-compat verdict, and the
|
||||
// floating-workspace flag. Compat constants are wide-open today so this never blocks yet.
|
||||
export function useHostStatusGates(args: {
|
||||
hostId: string | undefined
|
||||
client: RpcClient | null
|
||||
@@ -31,17 +27,14 @@ export function useHostStatusGates(args: {
|
||||
clientRef.current = client
|
||||
}, [client])
|
||||
|
||||
// Why: switching hosts must clear the previous host's verdict so a stale
|
||||
// block screen can't linger for the next host.
|
||||
// Why: clear the prior host's verdict on switch so a stale block screen can't linger.
|
||||
useEffect(() => {
|
||||
setCompatVerdict({ kind: 'ok' })
|
||||
}, [hostId])
|
||||
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || !client) {
|
||||
// Why: drop the prior host's capabilities while disconnected/switching so
|
||||
// a capability-gated action (e.g. Agent Session History) can't linger for
|
||||
// a host that doesn't support it.
|
||||
// Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host.
|
||||
setHostCapabilities([])
|
||||
setFloatingWorkspaceEnabled(false)
|
||||
return
|
||||
@@ -70,8 +63,7 @@ export function useHostStatusGates(args: {
|
||||
})
|
||||
setCompatVerdict(verdict)
|
||||
if (verdict.kind === 'blocked') {
|
||||
// Why: deterministic breadcrumb so support can confirm a block
|
||||
// actually fired (vs a render bug). No PII — just version ints.
|
||||
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
|
||||
console.warn('[protocol-compat] blocked', {
|
||||
reason: verdict.reason,
|
||||
desktopVersion: verdict.desktopVersion,
|
||||
@@ -80,8 +72,7 @@ export function useHostStatusGates(args: {
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Why: rare path — sendRequest can throw on transport tear-down.
|
||||
// Treat as transient; verdict stays at previous value.
|
||||
// Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict.
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
|
||||
@@ -23,16 +23,12 @@ import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upg
|
||||
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
|
||||
|
||||
const STORAGE_KEY = 'orca:hosts'
|
||||
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
|
||||
// Use dots as the separator so the key shape stays readable while
|
||||
// satisfying the validator.
|
||||
// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
|
||||
const TOKEN_KEY_PREFIX = 'orca.host-token.'
|
||||
const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:'
|
||||
|
||||
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off
|
||||
// iCloud Keychain and out of iCloud/iTunes backup restores onto a
|
||||
// different physical device. Reads/writes are silent (no biometric
|
||||
// prompt) since we don't request access control flags.
|
||||
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device.
|
||||
// Reads/writes stay silent (no biometric prompt) because we don't request access control flags.
|
||||
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
|
||||
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
|
||||
}
|
||||
@@ -46,8 +42,7 @@ function webTokenKey(hostId: string): string {
|
||||
}
|
||||
|
||||
async function readDeviceToken(hostId: string): Promise<string | null> {
|
||||
// Why: Expo SecureStore has no working web backend; keep this fallback
|
||||
// web-only so native builds still keep pairing tokens in the keychain.
|
||||
// Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
|
||||
if (Platform.OS === 'web') {
|
||||
return AsyncStorage.getItem(webTokenKey(hostId))
|
||||
}
|
||||
@@ -76,18 +71,10 @@ async function deleteHostCredentials(hostId: string): Promise<void> {
|
||||
await deleteMobileRelayDirectUpgradeJournal(hostId)
|
||||
}
|
||||
|
||||
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
|
||||
// loadHosts() is called from every screen mount + every useFocusEffect.
|
||||
// Stack with N hosts and you get N*200ms blocking every navigation, which
|
||||
// triggers connection-churn cycles in the home-screen useEffect. Cache
|
||||
// per-hostId in memory; invalidate only on save/remove. The cache lives
|
||||
// for the JS-runtime lifetime, which matches AsyncStorage semantics
|
||||
// (cleared on app uninstall, persisted across foreground/background).
|
||||
// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
|
||||
const tokenCache = new Map<string, string>()
|
||||
let inflightLoad: Promise<HostProfile[]> | null = null
|
||||
// Why: rename / lastConnected / remove / save all RMW the same hosts JSON.
|
||||
// Without a queue, concurrent writers re-read a stale snapshot and the last
|
||||
// setItem wins — resurrecting a removed host or dropping a rename.
|
||||
// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename).
|
||||
let hostListMutation: Promise<void> = Promise.resolve()
|
||||
|
||||
function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
|
||||
@@ -100,9 +87,7 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
|
||||
return null
|
||||
}
|
||||
return parsed.flatMap((item) => {
|
||||
// Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage.
|
||||
// Drop them silently — the three pre-launch users will re-pair on
|
||||
// first run rather than carry a migration shim through the auth path.
|
||||
// Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) {
|
||||
return []
|
||||
}
|
||||
@@ -115,11 +100,9 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
|
||||
}
|
||||
|
||||
export async function loadHosts(): Promise<HostProfile[]> {
|
||||
// Why: writers hold the mutation chain across their full RMW; wait so a
|
||||
// load right after rename/remove does not race a half-written list.
|
||||
// Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
|
||||
await hostListMutation
|
||||
// Why: deduplicate concurrent loadHosts() calls so multiple screens
|
||||
// mounting simultaneously share one Keychain read pass.
|
||||
// Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
|
||||
if (inflightLoad) {
|
||||
return inflightLoad
|
||||
}
|
||||
@@ -152,16 +135,11 @@ async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
try {
|
||||
fetched = await readDeviceToken(stored.id)
|
||||
} catch {
|
||||
// Why: a transient Keychain failure for one entry (e.g.
|
||||
// errSecInteractionNotAllowed while the device is briefly locked,
|
||||
// or a single corrupt record) must not blank the entire host list.
|
||||
// Skip just this host — it'll reappear on the next load.
|
||||
// Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it.
|
||||
continue
|
||||
}
|
||||
if (!fetched) {
|
||||
// Why: orphaned metadata with no matching keychain entry — most
|
||||
// likely a stale record from a development install. Skip it
|
||||
// rather than surface a half-broken host.
|
||||
// Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host.
|
||||
continue
|
||||
}
|
||||
token = fetched
|
||||
@@ -187,8 +165,7 @@ export async function resolvePairingHostIdentity(
|
||||
publicKeyB64: string,
|
||||
newHostId: string
|
||||
): Promise<{ id: string; name: string }> {
|
||||
// Why: one durable read both preserves an existing identity and names a new host,
|
||||
// avoiding duplicate cards and a second serial storage read before connecting.
|
||||
// Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards.
|
||||
await hostListMutation
|
||||
const hosts = await readStoredHostsForMutation()
|
||||
const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64)
|
||||
@@ -201,8 +178,7 @@ async function readStoredHostsForMutation(): Promise<StoredHostProfile[]> {
|
||||
try {
|
||||
const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY))
|
||||
if (!parsed) {
|
||||
// Why: refuse to RMW over unreadable payload — treating it as [] would
|
||||
// wipe the durable host list on the next rename/remove/save.
|
||||
// Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write.
|
||||
throw new Error('host list storage unreadable')
|
||||
}
|
||||
return parsed
|
||||
@@ -260,8 +236,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
|
||||
}
|
||||
if (index >= 0) {
|
||||
updatedExistingHost = true
|
||||
// Why: affected installs may already contain duplicate rows; an authoritative
|
||||
// save is the safe point to collapse them to the preserved host id.
|
||||
// Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id.
|
||||
return hosts
|
||||
.filter(({ id }) => !duplicateHostIds.has(id))
|
||||
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
|
||||
@@ -272,11 +247,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
|
||||
}
|
||||
return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
|
||||
})
|
||||
// Why: write metadata BEFORE the keychain token so a crash between the two
|
||||
// leaves orphaned metadata (which loadHosts skips and removeHost can clean
|
||||
// up) rather than an orphaned keychain token with no metadata pointer —
|
||||
// the latter would persist forever since removeHost only deletes by hostId
|
||||
// from current metadata.
|
||||
// Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever.
|
||||
await writeDeviceToken(stored.id, validated.deviceToken)
|
||||
tokenCache.set(stored.id, validated.deviceToken)
|
||||
if (validated.endpoints) {
|
||||
@@ -293,8 +264,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
|
||||
overlayRemovalIds.push(stored.id)
|
||||
}
|
||||
if (overlayRemovalIds.length > 0) {
|
||||
// Why: reusing an id for direct-only re-pairing must not retain routing
|
||||
// metadata from the host's previous transport state.
|
||||
// Why: reusing an id for direct-only re-pairing must not retain routing metadata from the previous transport state.
|
||||
await removeMobileRelayHostOverlays(overlayRemovalIds)
|
||||
}
|
||||
for (const duplicateHostId of duplicateHostIds) {
|
||||
@@ -313,11 +283,9 @@ export async function removeHost(hostId: string): Promise<void> {
|
||||
try {
|
||||
await removeMobileRelayHostOverlay(hostId)
|
||||
} catch {
|
||||
// The missing legacy base is authoritative, so a retained overlay cannot
|
||||
// resurrect this host and can be cleaned on a later explicit retry.
|
||||
// Base removal is authoritative; a retained overlay can't resurrect the host and is cleaned on a later retry.
|
||||
}
|
||||
// Why: await only the durable cleanup intent (AsyncStorage). Native keychain
|
||||
// delete can reject or stall and must not freeze removeHost / the UI.
|
||||
// Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI.
|
||||
try {
|
||||
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
|
||||
} catch {
|
||||
@@ -333,10 +301,7 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
|
||||
return retryPendingHostCredentialCleanups(deleteHostCredentials)
|
||||
}
|
||||
|
||||
// Why: Edit host can change name and endpoint together; a single
|
||||
// mutateStoredHosts pass keeps both fields committed atomically so a
|
||||
// mid-save failure can never persist one change without the other, and a
|
||||
// host removed mid-edit throws consistently instead of silently no-oping.
|
||||
// Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other.
|
||||
export async function updateHostNameAndEndpoint(
|
||||
hostId: string,
|
||||
updates: { name?: string; endpoint?: string }
|
||||
@@ -368,9 +333,7 @@ export async function updateLastConnected(hostId: string): Promise<void> {
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// Why: last-connected is a best-effort timestamp and callers fire it with
|
||||
// `void`. Swallow unreadable-storage failures so they don't surface as an
|
||||
// unhandled promise rejection.
|
||||
// Why: best-effort timestamp fired with void; swallow so unreadable storage doesn't reject.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,74 +79,36 @@ export type RpcClient = {
|
||||
viewport: { cols: number; rows: number }
|
||||
) => void
|
||||
getState: () => ConnectionState
|
||||
// Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross
|
||||
// a threshold. 0 means never failed; counter is reset on successful open.
|
||||
// 0 means never failed (reset on successful open); the UI escalates "Reconnecting…" to "Can't connect" past a threshold.
|
||||
getReconnectAttempt: () => number
|
||||
// Why: timestamp (ms epoch) of the last time we reached 'connected'.
|
||||
// null = never connected since the client was created. Used by the UI
|
||||
// to distinguish "host moved/never reachable" from "transient blip".
|
||||
// Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip".
|
||||
getLastConnectedAt: () => number | null
|
||||
onStateChange: (listener: (state: ConnectionState) => void) => () => void
|
||||
// Why: app-resume hook. Android/iOS can kill the TCP path or park the
|
||||
// reconnect loop while the app is backgrounded; callers invoke this on
|
||||
// AppState 'active' so the session recovers without an app restart.
|
||||
// Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover.
|
||||
notifyForeground: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
// Why: tiered backoff. The first four entries (500ms→4s) keep
|
||||
// auto-recovery snappy for the common case — a brief Wi-Fi blip,
|
||||
// laptop wake, or AP-isolation cycle. Beyond that we slow down
|
||||
// (8s→60s) so a phone whose desktop is genuinely unreachable doesn't
|
||||
// burn a TCP SYN every 4s indefinitely while still healing on its
|
||||
// own when the network recovers. With 12 total attempts, the last
|
||||
// four reuse the 60s cap (Math.min(idx, length-1)), so total elapsed
|
||||
// time across all 12 attempts is ≈ 6 minutes before the give-up cap
|
||||
// fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s).
|
||||
// Why: tiered backoff — fast early entries recover blips; the slow tail avoids burning a SYN every 4s on an unreachable desktop.
|
||||
const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
|
||||
// Why: cap fast auto-retry once we're clearly unreachable for a long time.
|
||||
// With the tiered backoff above this is ≈ 6 minutes of continuous
|
||||
// failure before the UI surfaces the re-pair banner. The longer
|
||||
// runway tolerates flaky AP-isolation routers and laptop sleep cycles
|
||||
// that briefly drop the LAN path. MUST stay aligned with
|
||||
// connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable"
|
||||
// verdict matches the moment the loop slows to the trickle cadence.
|
||||
// Why: ≈6 min of failure before the re-pair banner; MUST stay aligned with connection-health.ts UNREACHABLE_ATTEMPTS.
|
||||
const GIVE_UP_AFTER_ATTEMPTS = 12
|
||||
// Why: past the cap the loop must never park permanently. A wedged
|
||||
// Tailscale/VPN tunnel produces no AppState or network-type transition
|
||||
// (still Wi-Fi, still "online"), so no revival nudge ever fires — users
|
||||
// had to toggle Tailscale off/on just to force one. A slow trickle dial
|
||||
// self-heals once the tunnel recovers while staying cheap: one TCP
|
||||
// attempt per 90s, foreground-only (iOS/Android suspend JS timers in
|
||||
// the background).
|
||||
// Why: never park past the cap — a wedged VPN fires no AppState/network nudge to revive it, so trickle-dial every 90s to self-heal.
|
||||
const TRICKLE_RECONNECT_DELAY_MS = 90_000
|
||||
// Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead.
|
||||
// Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair
|
||||
// while the desktop still listed it as paired with a valid token — a transient
|
||||
// rejection (mid-session resume race, a stale frame after background) latched
|
||||
// the terminal auth-failed state permanently. Retry the full handshake this
|
||||
// many times with a clean reconnect before declaring auth dead. A genuinely
|
||||
// revoked token is rejected on every attempt and converges to auth-failed in
|
||||
// seconds; a one-off glitch self-heals without the user re-pairing.
|
||||
// Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed.
|
||||
const AUTH_RETRY_BUDGET = 3
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
// Why: RN's WebSocket implementation may not expose static readyState
|
||||
// constants, but the protocol value for CONNECTING is stable across runtimes.
|
||||
// Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes.
|
||||
const WEBSOCKET_CONNECTING_STATE = 0
|
||||
|
||||
// Why: RN auto-pongs WebSocket pings natively, so JS needs an app-level
|
||||
// liveness probe to detect half-open sockets. Any inbound app traffic after
|
||||
// a probe starts proves the link is alive; otherwise an unanswered probe
|
||||
// force-closes the socket so the reconnect path can recover.
|
||||
// Why: RN auto-pongs pings natively, so JS needs an app-level probe to detect half-open sockets.
|
||||
const ACTIVITY_PROBE_INTERVAL_MS = 20_000
|
||||
|
||||
export type ConnectOptions = {
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
// Fires for every observable lifecycle event so the UI can render a
|
||||
// detailed connection log. Useful when 'Connecting…' hangs forever
|
||||
// (e.g. broken Tailscale route) and you need to see *where* it's stuck.
|
||||
// Fires for every lifecycle event so the UI can show where 'Connecting…' is stuck (e.g. broken Tailscale route).
|
||||
onLog?: ConnectionLogSink
|
||||
}
|
||||
|
||||
@@ -185,14 +147,10 @@ export function connect(
|
||||
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
|
||||
let intentionallyClosed = false
|
||||
// Why: consecutive auth rejections since the last successful connect. We
|
||||
// tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching auth-failed
|
||||
// so a transient rejection doesn't force a needless re-pair. Reset to 0 on
|
||||
// every 'connected'.
|
||||
// Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair.
|
||||
let authRejectionCount = 0
|
||||
let lastConnectedAt: number | null = null
|
||||
// Why: cheap diagnostics for RN/OkHttp process-state poisoning: do retry
|
||||
// attempts differ, is anything inbound, and are closes instant or slow?
|
||||
// Why: cheap diagnostics for RN/OkHttp process-state poisoning (retry cadence, inbound traffic, close timing).
|
||||
let lastInboundAt: number | null = null
|
||||
let inboundSequence = 0
|
||||
let lastWsClosedAt: number | null = null
|
||||
@@ -200,7 +158,6 @@ export function connect(
|
||||
let currentWsOpenedAt: number | null = null
|
||||
|
||||
// Why: fresh ephemeral keypair per connection provides forward secrecy.
|
||||
// The shared key is derived from our ephemeral secret + server's static public key.
|
||||
let sharedKey: Uint8Array | null = null
|
||||
const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64)
|
||||
|
||||
@@ -218,9 +175,7 @@ export function connect(
|
||||
stateListeners.add(onStateChange)
|
||||
}
|
||||
|
||||
// Diagnostic: tracks how long we've been in the current state. Useful
|
||||
// for spotting "stuck in connecting" or "stuck in reconnecting" cases
|
||||
// in the logs.
|
||||
// Diagnostic: dwell time in the current state, for spotting "stuck in connecting/reconnecting".
|
||||
let stateEnteredAt = Date.now()
|
||||
|
||||
function rejectConnectWaiters(reason: string) {
|
||||
@@ -250,8 +205,7 @@ export function connect(
|
||||
})
|
||||
if (next === 'connected') {
|
||||
lastConnectedAt = Date.now()
|
||||
// Why: a clean handshake proves the token is valid — clear the auth
|
||||
// retry budget so a future isolated rejection gets the full budget again.
|
||||
// Why: a clean handshake proves the token is valid — reset the auth retry budget.
|
||||
authRejectionCount = 0
|
||||
for (const waiter of connectWaiters.splice(0)) {
|
||||
if (waiter.timeout) {
|
||||
@@ -269,8 +223,7 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: don't dump device tokens / full URLs into log scrolls; truncate to
|
||||
// the host:port so reconnect lifecycles are still readable.
|
||||
// Why: keep device tokens / full URLs out of log scrolls — truncate to host:port.
|
||||
function redactedEndpoint(ep: string): string {
|
||||
try {
|
||||
const m = ep.match(/^wss?:\/\/([^/]+)/i)
|
||||
@@ -288,17 +241,13 @@ export function connect(
|
||||
return Promise.reject(new Error('Client closed'))
|
||||
}
|
||||
if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) {
|
||||
// Why: past the retry cap the loop only trickles every 90s — callers
|
||||
// must fail fast rather than hang on a host that's been unreachable
|
||||
// for minutes. A trickle dial that succeeds flips state to 'connected'
|
||||
// and later requests go through normally.
|
||||
// Why: past the cap the loop only trickles every 90s — fail fast instead of hanging on a long-unreachable host.
|
||||
return Promise.reject(new Error('Connection retry limit reached'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter: ConnectWaiter = { resolve, reject, timeout: null }
|
||||
if (timeoutMs !== undefined) {
|
||||
// Why: explicit per-request timeouts must include offline/reconnect
|
||||
// waiting, not only the RPC after the socket becomes connected.
|
||||
// Why: per-request timeouts must cover offline/reconnect waiting, not just the RPC after connect.
|
||||
waiter.timeout = setTimeout(
|
||||
() => {
|
||||
const index = connectWaiters.indexOf(waiter)
|
||||
@@ -328,11 +277,7 @@ export function connect(
|
||||
console.log('[net] openConnection', {
|
||||
attempt: reconnectAttempt,
|
||||
endpoint: redactedEndpoint(endpoint),
|
||||
// Why: process-poisoning diagnostic. If wsCount is high (e.g. >50)
|
||||
// and every recent open fails with 1006, suspect RN/OkHttp internal
|
||||
// pool corruption that only force-quit clears. Compare msSinceLast*
|
||||
// values to the failure cadence: instant repeated fails with no
|
||||
// inbound traffic between them = process-state stuck.
|
||||
// Why: diagnostic for RN/OkHttp pool corruption — high wsCount + repeated 1006 closes means process-state stuck.
|
||||
wsCount: wsConstructionCounter,
|
||||
msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null,
|
||||
msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null,
|
||||
@@ -354,8 +299,7 @@ export function connect(
|
||||
if (ws === openingWs) {
|
||||
return false
|
||||
}
|
||||
// Why: React Native can deliver callbacks from a timed-out socket after
|
||||
// reconnect has swapped in a replacement; stale events must not mutate it.
|
||||
// Why: RN can deliver callbacks from a timed-out socket after reconnect swapped in a replacement — ignore them.
|
||||
console.log('[net] stale ws event ignored', {
|
||||
eventName,
|
||||
state,
|
||||
@@ -364,9 +308,7 @@ export function connect(
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: React Native can leave TCP/WebSocket opens pending indefinitely on
|
||||
// flaky network handoffs. Force the existing onclose reconnect path if
|
||||
// onopen never arrives, instead of leaving the UI stuck at "Connecting...".
|
||||
// Why: RN can leave opens pending forever on flaky handoffs — force reconnect if onopen never arrives.
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) {
|
||||
@@ -396,9 +338,7 @@ export function connect(
|
||||
setState('handshaking')
|
||||
emitLog('success', 'WebSocket open', 'Starting E2EE handshake')
|
||||
|
||||
// Why: generate a fresh ephemeral keypair for each connection.
|
||||
// This provides forward secrecy — compromising one session's key
|
||||
// doesn't compromise past or future sessions.
|
||||
// Why: fresh ephemeral keypair per connection provides forward secrecy.
|
||||
const ephemeral = generateKeyPair()
|
||||
const hello = JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
@@ -437,8 +377,7 @@ export function connect(
|
||||
lastInboundAt = Date.now()
|
||||
const raw = typeof rawData === 'string' ? rawData : null
|
||||
|
||||
// Why: during handshaking, e2ee_ready is plaintext because it precedes
|
||||
// encrypted auth; e2ee_authenticated/e2ee_error are encrypted.
|
||||
// Why: e2ee_ready is plaintext (precedes encrypted auth); e2ee_authenticated/e2ee_error are encrypted.
|
||||
if (state === 'handshaking') {
|
||||
if (raw === null) {
|
||||
return
|
||||
@@ -481,9 +420,7 @@ export function connect(
|
||||
removeStreamListener(id)
|
||||
continue
|
||||
}
|
||||
// Why: setState('connected') notifies UI listeners synchronously;
|
||||
// a listener may subscribe and send immediately before this
|
||||
// reconnect replay loop resumes.
|
||||
// Why: a UI listener notified synchronously by setState('connected') may already have sent this stream — skip it.
|
||||
if (stream.sent) {
|
||||
continue
|
||||
}
|
||||
@@ -515,8 +452,7 @@ export function connect(
|
||||
return
|
||||
}
|
||||
|
||||
// Why: guard against decrypt with an invalid key — sharedKey can be null
|
||||
// after destroy() or if a message arrives during a reconnect race.
|
||||
// Why: sharedKey can be null after destroy() or a reconnect race — don't decrypt with an invalid key.
|
||||
if (!sharedKey || sharedKey.length !== 32) {
|
||||
return
|
||||
}
|
||||
@@ -553,10 +489,7 @@ export function connect(
|
||||
}
|
||||
recordValidatedInboundTraffic()
|
||||
|
||||
// Why: a mid-session unauthorized may be a transient glitch, not a dead
|
||||
// pairing (issue #5200). handleAuthRejection retries the handshake a few
|
||||
// times before latching auth-failed, while still bounding churn via the
|
||||
// budget so a genuinely revoked token doesn't reconnect forever.
|
||||
// Why: a mid-session unauthorized may be transient (issue #5200) — handleAuthRejection retries before latching auth-failed.
|
||||
if (!response.ok && response.error.code === 'unauthorized') {
|
||||
handleAuthRejection('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
@@ -646,18 +579,12 @@ export function connect(
|
||||
ws.onclose = (event) => {
|
||||
const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined
|
||||
const closeAt = Date.now()
|
||||
// Why: time-since-construct distinguishes failure modes. Instant
|
||||
// close (<300ms) = TCP RST / port closed / route unreachable / RN
|
||||
// synchronous reject. Mid (300ms–3s) = DNS/connect attempt + reset.
|
||||
// Slow (>3s) = TCP SYN timeout / packet loss / NAT wedge. If an
|
||||
// entire reconnect burst is all instant, the problem is local
|
||||
// process state or routing, not packet loss.
|
||||
// Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/packet loss.
|
||||
const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null
|
||||
const aliveMs =
|
||||
currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null
|
||||
const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null
|
||||
// Why: statically imported (not closure-built) — an earlier hot-reload
|
||||
// bug came from a stale closure capturing a half-loaded module.
|
||||
// Why: statically imported — a hot-reload bug came from a stale closure capturing a half-loaded module.
|
||||
const closeEvent = describeSocketEvent(event)
|
||||
console.log('[net] ws.onclose', {
|
||||
code: e?.code,
|
||||
@@ -682,9 +609,7 @@ export function connect(
|
||||
if (ignoreStaleSocketEvent('error')) {
|
||||
return
|
||||
}
|
||||
// Why: RN surfaces network errors here (DNS failure, TCP RST, etc).
|
||||
// onclose fires right after, but logging the error message gives us
|
||||
// the original cause that the close code alone can hide.
|
||||
// Why: RN surfaces the original network error here — onclose follows but its close code alone hides the cause.
|
||||
const e = event as { message?: string } | undefined
|
||||
const errEvent = describeSocketEvent(event)
|
||||
console.log('[net] ws.onerror', {
|
||||
@@ -734,11 +659,7 @@ export function connect(
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
// Why: a token rejection (handshake e2ee_error/unauthorized or a mid-session
|
||||
// unauthorized RPC) may be transient — issue #5200. Retry the full handshake
|
||||
// up to AUTH_RETRY_BUDGET times before declaring auth dead, so a one-off
|
||||
// glitch self-heals instead of forcing the user to re-pair. A genuinely
|
||||
// revoked token fails every retry and latches auth-failed within seconds.
|
||||
// Why: an auth rejection may be transient (issue #5200) — retry up to AUTH_RETRY_BUDGET times before latching auth-failed.
|
||||
function handleAuthRejection(reason: string): void {
|
||||
activeBrowserScreencastRequestId = null
|
||||
pendingBrowserScreencastRequestId = null
|
||||
@@ -754,9 +675,7 @@ export function connect(
|
||||
'Authentication rejected',
|
||||
`Retrying (${authRejectionCount}/${AUTH_RETRY_BUDGET})`
|
||||
)
|
||||
// Why: close the current socket but DON'T set intentionallyClosed —
|
||||
// we want handleSocketClosed to route into the reconnect path so the
|
||||
// token gets a fresh handshake. rejectAllPending unblocks in-flight RPCs.
|
||||
// Why: close without setting intentionallyClosed so handleSocketClosed routes to reconnect and retries the handshake.
|
||||
const closing = ws
|
||||
ws = null
|
||||
sharedKey = null
|
||||
@@ -782,19 +701,11 @@ export function connect(
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
// Why: spinning fast reconnects forever drains battery and floods logs
|
||||
// when the host is genuinely unreachable (wrong IP, port closed,
|
||||
// host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a
|
||||
// "Can't reach desktop, re-pair?" banner and the loop drops to the
|
||||
// 90s trickle cadence instead of parking — a permanently parked loop
|
||||
// could only be revived by an AppState/network transition, which a
|
||||
// wedged VPN tunnel never produces.
|
||||
// Why: past the cap, trickle (never park) — a parked loop only revives on a network transition a wedged VPN never produces.
|
||||
const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS
|
||||
let delay: number
|
||||
if (pastGiveUpCap) {
|
||||
// Why: the counter holds at the cap — connection-health thresholds and
|
||||
// the "Can't reach desktop" verdict key off attempts >= 12, and a
|
||||
// successful open resets it to 0 anyway.
|
||||
// Why: hold the counter at the cap — connection-health's "Can't reach desktop" verdict keys off attempts >= 12.
|
||||
delay = TRICKLE_RECONNECT_DELAY_MS
|
||||
rejectConnectWaiters('Connection retry limit reached')
|
||||
} else {
|
||||
@@ -824,10 +735,7 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: app-level liveness probe — see ACTIVITY_PROBE_INTERVAL_MS comment
|
||||
// at the top of the file. Fires while the channel is in 'connected'
|
||||
// state, sends a tiny status.get, and force-closes the WS if the probe
|
||||
// fails (which the existing onclose path then turns into a reconnect).
|
||||
// Why: app-level liveness probe (see ACTIVITY_PROBE_INTERVAL_MS) — force-closes the WS on failure so onclose reconnects.
|
||||
function runActivityProbe() {
|
||||
if (state !== 'connected' || !ws) {
|
||||
return
|
||||
@@ -966,8 +874,7 @@ export function connect(
|
||||
removeStreamListener(id)
|
||||
return
|
||||
}
|
||||
// Why: sent streams may still reply with `ready`; keep a tombstone so we
|
||||
// can immediately unsubscribe. Queued streams never reached desktop.
|
||||
// Why: a sent stream may still reply `ready`; keep the tombstone to unsubscribe it (queued streams never reached the desktop).
|
||||
if (!stream.sent) {
|
||||
removeStreamListener(id)
|
||||
}
|
||||
@@ -1013,11 +920,7 @@ export function connect(
|
||||
hasKey: !!sharedKey,
|
||||
state
|
||||
})
|
||||
// Why: if the state machine still thinks we're connected but the
|
||||
// underlying WebSocket has flipped to CLOSING/CLOSED without onclose
|
||||
// having fired (RN's WebSocket sometimes drops the event, or the
|
||||
// server half-closed the stream), force a reconnect. Without this
|
||||
// every send silently fails forever and the user sees a frozen UI.
|
||||
// Why: RN can drop onclose, leaving state 'connected' over a dead socket; force reconnect or every send silently fails forever.
|
||||
if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) {
|
||||
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
|
||||
readyState: ws.readyState
|
||||
@@ -1125,9 +1028,7 @@ export function connect(
|
||||
if (pendingBrowserScreencastRequestId && pendingBrowserScreencastRequestId !== id) {
|
||||
disposeBrowserScreencastStream(pendingBrowserScreencastRequestId)
|
||||
}
|
||||
// Why: browser screencast frames are connection-scoped and carry no
|
||||
// stream id. Wait for the replacement stream's ready response before
|
||||
// routing frames, so in-flight old-page pixels are dropped.
|
||||
// Why: screencast frames carry no stream id, so route only after the new stream's ready to drop stale old-page pixels.
|
||||
pendingBrowserScreencastRequestId = id
|
||||
activeBrowserScreencastRequestId = null
|
||||
}
|
||||
@@ -1140,9 +1041,7 @@ export function connect(
|
||||
removeStreamListener(id)
|
||||
}
|
||||
} else {
|
||||
// Stream is registered but the actual outbound subscribe will be
|
||||
// sent (or re-sent) when the channel reaches 'connected'. Useful
|
||||
// when terminals don't load — confirms the request is queued.
|
||||
// Registered now; the outbound subscribe is (re-)sent once the channel reaches 'connected'.
|
||||
console.log('[net] subscribe queued — waiting for connected', { method, state })
|
||||
}
|
||||
|
||||
@@ -1157,12 +1056,7 @@ export function connect(
|
||||
return
|
||||
}
|
||||
if (stream?.method === 'terminal.subscribe') {
|
||||
// Why: the runtime registers cleanup under the composite key
|
||||
// `${terminal}:${clientId}` so two phones subscribing to the same
|
||||
// terminal handle don't evict each other. Echo that composite key
|
||||
// back on unsubscribe; also include `client.id` so the server can
|
||||
// reconstruct it if a stale build emits a bare-handle id. See
|
||||
// docs/mobile-presence-lock.md.
|
||||
// Why: server keys cleanup by composite `${terminal}:${clientId}` so two phones don't evict each other. See docs/mobile-presence-lock.md.
|
||||
const unsubscribeParams = buildTerminalUnsubscribeParams(stream.params)
|
||||
if (unsubscribeParams) {
|
||||
sendEncrypted({
|
||||
@@ -1211,20 +1105,14 @@ export function connect(
|
||||
return
|
||||
}
|
||||
if (state === 'connected') {
|
||||
// Why: the OS can kill the TCP path while the app is backgrounded
|
||||
// without delivering onclose, leaving a half-open socket that
|
||||
// blackholes input. Probe now so death is detected in ≤8s instead
|
||||
// of waiting out the 20s interval (issue #5049).
|
||||
// Why: OS can kill the TCP path while backgrounded without onclose; probe now to detect the half-open socket in ≤8s (issue #5049).
|
||||
console.log('[net] foreground — probing live connection')
|
||||
startActivityProbe()
|
||||
runActivityProbe()
|
||||
return
|
||||
}
|
||||
if (state === 'reconnecting') {
|
||||
// Why: while backgrounded the retry loop may be sitting on a 60s
|
||||
// backoff or 90s trickle timer. Returning to the foreground is a
|
||||
// strong user signal — restart with a fresh attempt budget
|
||||
// immediately instead of waiting out the timer.
|
||||
// Why: foreground is a strong user signal — restart immediately instead of waiting out a 60s/90s backoff timer.
|
||||
console.log('[net] foreground — restarting reconnect loop', {
|
||||
attempt: reconnectAttempt,
|
||||
hadTimer: !!reconnectTimer
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.146-rc.0",
|
||||
"version": "1.4.147-rc.4",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -230,7 +230,8 @@
|
||||
"sherpa-onnx-darwin-x64": "1.12.37",
|
||||
"sherpa-onnx-linux-arm64": "1.12.37",
|
||||
"sherpa-onnx-linux-x64": "1.12.37",
|
||||
"sherpa-onnx-win-x64": "1.12.37"
|
||||
"sherpa-onnx-win-x64": "1.12.37",
|
||||
"windows-native-registry": "3.2.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx,mjs,mts,cts}": [
|
||||
|
||||
Generated
+18
@@ -399,6 +399,9 @@ importers:
|
||||
sherpa-onnx-win-x64:
|
||||
specifier: 1.12.37
|
||||
version: 1.12.37
|
||||
windows-native-registry:
|
||||
specifier: 3.2.2
|
||||
version: 3.2.2
|
||||
|
||||
packages:
|
||||
|
||||
@@ -5453,6 +5456,9 @@ packages:
|
||||
node-addon-api@1.7.2:
|
||||
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
|
||||
|
||||
node-addon-api@4.3.0:
|
||||
resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
@@ -6745,6 +6751,10 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
windows-native-registry@3.2.2:
|
||||
resolution: {integrity: sha512-yg1ZnuzUExn+Uq3cODB23Ju15URw0F/sRrSpjavVhHMi46s6Pcukp4+PtumoELmefoeHarCgci0XV34COpuYkw==}
|
||||
os: [win32]
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -12003,6 +12013,9 @@ snapshots:
|
||||
node-addon-api@1.7.2:
|
||||
optional: true
|
||||
|
||||
node-addon-api@4.3.0:
|
||||
optional: true
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-api-version@0.2.1:
|
||||
@@ -13465,6 +13478,11 @@ snapshots:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
windows-native-registry@3.2.2:
|
||||
dependencies:
|
||||
node-addon-api: 4.3.0
|
||||
optional: true
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
@@ -130,18 +130,18 @@
|
||||
{
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"releaseRevision": 24,
|
||||
"packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
|
||||
"gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
|
||||
"releaseRevision": 25,
|
||||
"packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908",
|
||||
"gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 22861,
|
||||
"size": 22676,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
|
||||
"textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
|
||||
"identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5"
|
||||
"exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
|
||||
"textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
|
||||
"identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -899,6 +899,22 @@
|
||||
"identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 25,
|
||||
"packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908",
|
||||
"gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 22676,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
|
||||
"textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305",
|
||||
"identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mobile-fit-debug": [
|
||||
|
||||
@@ -183,7 +183,7 @@ Wait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor
|
||||
|
||||
## Worker Terminals
|
||||
|
||||
Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, create the worker in the active worktree:
|
||||
Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:
|
||||
|
||||
```bash
|
||||
orca terminal create --worktree active --title <task-name> --command "codex" --json
|
||||
@@ -191,7 +191,9 @@ orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Use a new worktree only when explicitly requested or when independent isolated checkout state is intended. For supervised new-worktree workers, decide the desired Orca lineage before creation: use child lineage only when the work is conceptually stacked under or dependent on the active worktree, and use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks. Decide the Git base separately from lineage: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.
|
||||
Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.
|
||||
|
||||
When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --agent codex --json
|
||||
@@ -203,11 +205,11 @@ orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
|
||||
For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo <selector>`.
|
||||
|
||||
**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
|
||||
Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.
|
||||
|
||||
Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent under the same worktree in the sidebar even though it is a child dispatch in Orca orchestration state. A visible parent/child worktree relationship requires creating a child worktree, but do that only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree.
|
||||
Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.
|
||||
|
||||
Other terminal commands coordinators often need:
|
||||
|
||||
@@ -234,7 +236,6 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding
|
||||
- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.
|
||||
- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.
|
||||
- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.
|
||||
- Prefer inter-worktree workers only for independent work that does not need current uncommitted state. When same-worktree work is required, create fresh terminals in that worktree and keep edit ownership clear.
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ Wait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor
|
||||
|
||||
## Worker Terminals
|
||||
|
||||
Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, create the worker in the active worktree:
|
||||
Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:
|
||||
|
||||
```bash
|
||||
orca terminal create --worktree active --title <task-name> --command "codex" --json
|
||||
@@ -191,7 +191,9 @@ orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Use a new worktree only when explicitly requested or when independent isolated checkout state is intended. For supervised new-worktree workers, decide the desired Orca lineage before creation: use child lineage only when the work is conceptually stacked under or dependent on the active worktree, and use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks. Decide the Git base separately from lineage: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.
|
||||
Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.
|
||||
|
||||
When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --agent codex --json
|
||||
@@ -203,11 +205,11 @@ orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
|
||||
For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo <selector>`.
|
||||
|
||||
**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
|
||||
Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.
|
||||
|
||||
Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent under the same worktree in the sidebar even though it is a child dispatch in Orca orchestration state. A visible parent/child worktree relationship requires creating a child worktree, but do that only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree.
|
||||
Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.
|
||||
|
||||
Other terminal commands coordinators often need:
|
||||
|
||||
@@ -234,7 +236,6 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding
|
||||
- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.
|
||||
- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.
|
||||
- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.
|
||||
- Prefer inter-worktree workers only for independent work that does not need current uncommitted state. When same-worktree work is required, create fresh terminals in that worktree and keep edit ownership clear.
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,19 +10,13 @@ import { RuntimeClientError } from '../runtime-client'
|
||||
import { getTerminalHandle } from '../selectors'
|
||||
import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary'
|
||||
|
||||
// Why: 15 s is well under Claude Code's empirical ~2 min Bash-tool silence
|
||||
// budget and generates only ~40 lines per 10 min wait — enough to assure the
|
||||
// parent process the subprocess is alive without flooding logs. See design
|
||||
// doc §3.4.
|
||||
// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
|
||||
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
|
||||
}
|
||||
|
||||
// Why: test-only escape hatch so subprocess tests can verify the feature in
|
||||
// under 10 s rather than needing a full 15 s silence window. Production users
|
||||
// should never set this — there is no surface documentation. A bogus value
|
||||
// falls back to the default rather than disabling the keepalive.
|
||||
// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default.
|
||||
function resolveKeepaliveIntervalMs(): number {
|
||||
const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS
|
||||
if (!raw) {
|
||||
@@ -40,16 +34,12 @@ function startCheckKeepalive(deadlineMs: number | undefined): () => void {
|
||||
const interval = setInterval(() => {
|
||||
const payload = {
|
||||
_keepalive: true,
|
||||
// Why: retain the old marker for scripts filtering merged stderr while
|
||||
// callers migrate to the unambiguous _keepalive field.
|
||||
// Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive.
|
||||
_heartbeat: true,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
deadlineMs: deadlineMs ?? null
|
||||
}
|
||||
// Why: `process.stderr.write` is line-flushed per-call in Node, whereas a
|
||||
// fully-buffered writer would hold all keepalive lines until exit and
|
||||
// silently defeat the whole point of the ping. Subprocess test asserts
|
||||
// this by reading stderr incrementally. See §3.4.
|
||||
// Why: process.stderr.write is line-flushed per-call in Node; a buffered writer would hold keepalives until exit. See §3.4.
|
||||
process.stderr.write(`${JSON.stringify(payload)}\n`)
|
||||
}, resolveKeepaliveIntervalMs())
|
||||
if (typeof interval.unref === 'function') {
|
||||
@@ -58,8 +48,7 @@ function startCheckKeepalive(deadlineMs: number | undefined): () => void {
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
|
||||
// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI can surface a
|
||||
// clear enum-aware error before the generic RPC Zod "Missing --status" message.
|
||||
// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI surfaces an enum-aware error before the generic RPC message.
|
||||
const TASK_STATUS_VALUES = [
|
||||
'pending',
|
||||
'ready',
|
||||
@@ -114,8 +103,7 @@ function getOptionalStructuredMessagePayload(
|
||||
'Use either --payload or structured payload flags, not both.'
|
||||
)
|
||||
}
|
||||
// Why: raw JSON arguments are fragile in Windows PowerShell; these flags let
|
||||
// workers send parseable orchestration payloads without shell-specific quoting.
|
||||
// Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting.
|
||||
const payload: Record<string, string | string[]> = {}
|
||||
if (taskId) {
|
||||
payload.taskId = taskId
|
||||
@@ -152,9 +140,7 @@ async function resolveOrchestrationTerminalHandle(
|
||||
const envHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
if (envHandle && envHandle.length > 0) {
|
||||
if (flagName === 'from' && options.validateEnvHandle) {
|
||||
// Why: long-lived shells can retain an ORCA_TERMINAL_HANDLE after the
|
||||
// runtime remints the pane handle; do not bake that stale id into
|
||||
// coordinator preambles.
|
||||
// Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles.
|
||||
const live = await isLiveTerminalHandle(envHandle, client)
|
||||
if (!live) {
|
||||
const reminted = await resolveOrchestrationPaneTerminalHandle(client)
|
||||
@@ -184,8 +170,7 @@ async function resolveTaskCreatorTerminalHandle(
|
||||
live = await isLiveTerminalHandle(envHandle, client)
|
||||
} catch (err) {
|
||||
if (isOptionalTaskCreatorHandleError(err)) {
|
||||
// Why: creator handles are best-effort lineage metadata; graph
|
||||
// unavailability should not block task creation itself.
|
||||
// Why: creator handles are best-effort lineage metadata; graph unavailability must not block task creation.
|
||||
return undefined
|
||||
}
|
||||
throw err
|
||||
@@ -242,8 +227,7 @@ async function resolveOrchestrationPaneTerminalHandle(
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
// Why: pane key reminting preserves the caller identity; focus-based
|
||||
// active-terminal fallback can point at a different pane.
|
||||
// Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane.
|
||||
const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', {
|
||||
paneKey
|
||||
})
|
||||
@@ -362,17 +346,11 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
!getOptionalStringFlag(flags, 'from') &&
|
||||
!process.env.ORCA_TERMINAL_HANDLE
|
||||
) {
|
||||
// Why: focus is not lifecycle authority; injected dispatches carry an
|
||||
// explicit worker handle so an identity-less subprocess must fail closed.
|
||||
// Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker.
|
||||
throwNoActiveSenderTerminal()
|
||||
}
|
||||
|
||||
// Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness
|
||||
// probe (terminal.show throws runtime_unavailable in the exact mid-restart
|
||||
// window worker_done must survive) and no pane remint (pre-payload-
|
||||
// authority runtimes require from === the equally stale assignee_handle,
|
||||
// and coordinator replies route to the sender row while the worker's own
|
||||
// `check` reads its env-handle inbox).
|
||||
// Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness probe (worker_done must survive the mid-restart window) and no remint (older runtimes require from === the stale assignee_handle).
|
||||
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
|
||||
const result = await client.call<OrchestrationSendResult>('orchestration.send', {
|
||||
from,
|
||||
@@ -383,14 +361,12 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
priority: getOptionalStringFlag(flags, 'priority'),
|
||||
threadId: getOptionalStringFlag(flags, 'thread-id'),
|
||||
payload: getOptionalStructuredMessagePayload(flags),
|
||||
// Why: the pane key is the remint-stable sender identity the runtime
|
||||
// verifies lifecycle ownership against; older runtimes strip it.
|
||||
// Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it.
|
||||
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
|
||||
devMode: isDevCliInvocation()
|
||||
})
|
||||
if ('message' in result.result && result.result.lifecycle?.action === 'rejected') {
|
||||
// Why: a persisted diagnostic is not a successful lifecycle signal;
|
||||
// the non-zero status prevents workers from treating it as completion.
|
||||
// Why: a rejected lifecycle signal isn't completion; non-zero exit stops workers from treating it as such.
|
||||
process.exitCode = 1
|
||||
}
|
||||
printResult(result, json, (r) => {
|
||||
@@ -407,9 +383,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration check': async ({ flags, client, cwd, json }) => {
|
||||
const wait = flags.has('wait')
|
||||
const peek = flags.has('peek')
|
||||
// Why: enforce mode exclusivity client-side too — an older runtime strips
|
||||
// the unknown `peek` param and would otherwise execute --unread --peek as
|
||||
// a destructive mark-read.
|
||||
// Why: enforce mode exclusivity client-side — older runtimes strip unknown peek and run --unread --peek as destructive mark-read.
|
||||
if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
@@ -419,13 +393,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms')
|
||||
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
|
||||
|
||||
// Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce
|
||||
// no output for ~2 min (shorter on the non-interactive path). Emit a
|
||||
// keepalive line to stderr every KEEPALIVE_INTERVAL_MS while the wait is
|
||||
// active so the parent process can see the subprocess is still alive.
|
||||
// Stderr rather than stdout so stdout stays a single final JSON payload,
|
||||
// and JSON-shaped rather than `# …` so `2>&1 | jq` pipelines still work
|
||||
// (jq refuses `#`-prefixed lines). See design doc §3.4.
|
||||
// Why: Claude Code auto-backgrounds subprocesses silent ~2 min; emit JSON keepalives to stderr (stdout stays one payload). See §3.4.
|
||||
const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null
|
||||
type CheckResult = {
|
||||
messages: MessageSummary[]
|
||||
@@ -436,10 +404,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
try {
|
||||
result = await client.call<CheckResult>('orchestration.check', {
|
||||
terminal,
|
||||
// Why: --peek also sends unread:false so runtimes that predate the
|
||||
// peek param (which their non-strict schema strips) degrade to the
|
||||
// non-consuming all-messages mode instead of the destructive
|
||||
// mark-read default; the read filter below restores peek semantics.
|
||||
// Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read.
|
||||
unread: flags.has('unread') ? true : peek ? false : undefined,
|
||||
peek: peek ? true : undefined,
|
||||
all: flags.has('all') ? true : undefined,
|
||||
@@ -455,19 +420,14 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
const rawRowCount = result.result.messages.length
|
||||
const unreadOnly = result.result.messages.filter((m) => m.read !== 1)
|
||||
const removedReadRows = unreadOnly.length !== rawRowCount
|
||||
// Why: read rows in a peek response are the pre-peek-runtime signature
|
||||
// (its schema stripped `peek` and it ran the all mode). Such a runtime
|
||||
// returned instead of blocking, so honoring --wait is impossible —
|
||||
// failing beats silently returning empty before the deadline.
|
||||
// Why: read rows mean a pre-peek runtime ran the all mode and returned instead of blocking; can't honor --wait, so fail loudly.
|
||||
if (wait && removedReadRows && unreadOnly.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'peek_wait_unsupported',
|
||||
'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.'
|
||||
)
|
||||
}
|
||||
// Why: pre-peek runtimes cap the all mode at the newest 100 rows, so a
|
||||
// full page means older unread messages may have been cut off. Warn on
|
||||
// stderr so stdout stays a single JSON payload.
|
||||
// Why: pre-peek runtimes cap the all mode at 100 rows; a full page may hide older unread — warn on stderr.
|
||||
if (removedReadRows && rawRowCount >= 100) {
|
||||
console.error(
|
||||
'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.'
|
||||
@@ -477,8 +437,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
...result,
|
||||
result: {
|
||||
...result.result,
|
||||
// Why: a pre-peek runtime builds `formatted` from all rows; drop it
|
||||
// when the read filter removed any so output matches the peek set.
|
||||
// Why: a pre-peek runtime builds `formatted` from all rows; drop it so output matches the filtered peek set.
|
||||
...(removedReadRows ? { formatted: undefined } : {}),
|
||||
messages: unreadOnly,
|
||||
count: unreadOnly.length
|
||||
@@ -521,8 +480,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
if (r.count === 0) {
|
||||
return 'No messages.'
|
||||
}
|
||||
// Why: default output omits body/payload for at-a-glance sweeps; --full
|
||||
// prints them verbatim so callers can audit without parsing --json.
|
||||
// Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing.
|
||||
return r.messages
|
||||
.map((m) => {
|
||||
const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`
|
||||
@@ -577,9 +535,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
ready: flags.has('ready') ? true : undefined,
|
||||
brief: brief ? true : undefined
|
||||
})
|
||||
// Why: current runtimes abbreviate server-side (rows carry
|
||||
// spec_truncated) so full specs never cross the wire; older runtimes
|
||||
// strip the brief param and need the client-side fallback.
|
||||
// Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback.
|
||||
const needsClientAbbreviation =
|
||||
brief && result.result.tasks.some((task) => task.spec_truncated === undefined)
|
||||
const output = needsClientAbbreviation
|
||||
@@ -671,18 +627,10 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
timeoutMs: parsedTimeoutMs,
|
||||
from
|
||||
},
|
||||
// Why: the runtime's `waitForMessage` can block up to `timeoutMs`, but
|
||||
// the RPC transport has its own 60s default timeout that would fire
|
||||
// first. Extend the per-call timeout by a small grace window so the
|
||||
// RPC doesn't abort before the runtime's internal timeout resolves.
|
||||
// Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves.
|
||||
{ timeoutMs: timeoutMs + 5_000 }
|
||||
)
|
||||
// Why: deliberate bypass of `printResult`. `--json` on `ask` emits a
|
||||
// single-line bare JSON object (no RPC envelope, no multi-line pretty-
|
||||
// print) so workers can pipe `orca orchestration ask … --json | jq -r
|
||||
// .answer` without reaching into a `result` envelope. This diverges from
|
||||
// every other orchestration verb; called out in the commit message and
|
||||
// guarded by a unit test in orchestration.test.ts.
|
||||
// Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs.
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result.result))
|
||||
} else if (result.result.answer !== null) {
|
||||
@@ -698,8 +646,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
|
||||
'orchestration dispatch-show': async ({ flags, client, cwd, json }) => {
|
||||
const showPreamble = flags.has('preamble') ? true : undefined
|
||||
// Why: resolve --from when previewing so the preamble embeds a real
|
||||
// coordinator handle, matching what an actual dispatch would produce.
|
||||
// Why: resolve --from so the previewed preamble embeds a real coordinator handle like an actual dispatch.
|
||||
const from = showPreamble
|
||||
? await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
: undefined
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { getActiveViewPreferenceFile } from './active-view-preference'
|
||||
|
||||
const testState = { dir: '' }
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').replace('encrypted:', '')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
|
||||
vi.mock('./ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: vi.fn(),
|
||||
sshConfigHostsToTargets: vi.fn()
|
||||
}))
|
||||
|
||||
describe('active-view persistence boundary', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-active-view-boundary-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('persists a view switch without changing the global durable snapshot', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
const { Store } = await import('./persistence')
|
||||
const dataFile = join(testState.dir, 'orca-data.json')
|
||||
const store = new Store({ dataFile })
|
||||
store.flush()
|
||||
const durableBefore = readFileSync(dataFile, 'utf-8')
|
||||
|
||||
store.updateUI({ activeView: 'settings' })
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await store.waitForPendingWrite()
|
||||
|
||||
const preferenceFile = getActiveViewPreferenceFile(dataFile)
|
||||
const preferencePayload = readFileSync(preferenceFile, 'utf-8')
|
||||
expect(Buffer.byteLength(preferencePayload)).toBeLessThan(64)
|
||||
expect(JSON.parse(preferencePayload)).toEqual({ activeView: 'settings' })
|
||||
expect(readFileSync(dataFile, 'utf-8')).toBe(durableBefore)
|
||||
expect(store.getUI().activeView).toBe('settings')
|
||||
|
||||
const reloaded = new Store({ dataFile })
|
||||
expect(reloaded.getUI().activeView).toBe('settings')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { ActiveViewPreference, getActiveViewPreferenceFile } from './active-view-preference'
|
||||
|
||||
describe('ActiveViewPreference', () => {
|
||||
let dir: string
|
||||
let dataFile: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-active-view-'))
|
||||
dataFile = join(dir, 'orca-data.json')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('migrates the legacy durable-state value into the profile sidecar', async () => {
|
||||
vi.useFakeTimers()
|
||||
const preference = new ActiveViewPreference(dataFile, 'tasks')
|
||||
|
||||
expect(preference.get()).toBe('tasks')
|
||||
expect(existsSync(getActiveViewPreferenceFile(dataFile))).toBe(false)
|
||||
|
||||
// The renderer reasserts its hydrated value once persistence is ready.
|
||||
expect(preference.set('tasks')).toBe(false)
|
||||
vi.advanceTimersByTime(100)
|
||||
await preference.waitForPendingWrite()
|
||||
|
||||
expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
|
||||
activeView: 'tasks'
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces rapid switches into one tiny preference write', async () => {
|
||||
vi.useFakeTimers()
|
||||
const preference = new ActiveViewPreference(dataFile, 'terminal')
|
||||
|
||||
preference.set('settings')
|
||||
vi.advanceTimersByTime(50)
|
||||
preference.set('automations')
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(existsSync(getActiveViewPreferenceFile(dataFile))).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await preference.waitForPendingWrite()
|
||||
|
||||
expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
|
||||
activeView: 'automations'
|
||||
})
|
||||
})
|
||||
|
||||
it('flushes synchronously for an immediate graceful exit', () => {
|
||||
vi.useFakeTimers()
|
||||
const preference = new ActiveViewPreference(dataFile, 'terminal')
|
||||
|
||||
preference.set('settings')
|
||||
preference.flushOrThrow()
|
||||
|
||||
expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
|
||||
activeView: 'settings'
|
||||
})
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
|
||||
activeView: 'settings'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores an invalid sidecar and invalid updates', () => {
|
||||
writeFileSync(getActiveViewPreferenceFile(dataFile), '{"activeView":"unknown"}', 'utf-8')
|
||||
const preference = new ActiveViewPreference(dataFile, 'not-a-view')
|
||||
|
||||
expect(preference.get()).toBe('terminal')
|
||||
expect(preference.set('also-not-a-view')).toBe(false)
|
||||
expect(preference.get()).toBe('terminal')
|
||||
})
|
||||
|
||||
it('rejects inherited object keys from a corrupt sidecar', () => {
|
||||
// Why: `constructor`/`__proto__` are truthy under `in`; the sidecar must not
|
||||
// treat them as a valid view and leave the main surface blank.
|
||||
writeFileSync(getActiveViewPreferenceFile(dataFile), '{"activeView":"constructor"}', 'utf-8')
|
||||
const preference = new ActiveViewPreference(dataFile, 'tasks')
|
||||
|
||||
expect(preference.get()).toBe('tasks')
|
||||
expect(preference.set('__proto__')).toBe(false)
|
||||
expect(preference.get()).toBe('tasks')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { TopLevelView } from '../shared/types'
|
||||
import { isTopLevelView } from '../shared/top-level-view'
|
||||
|
||||
const ACTIVE_VIEW_FILE_NAME = 'active-view.json'
|
||||
const SAVE_DEBOUNCE_MS = 100
|
||||
|
||||
type ActiveViewFile = {
|
||||
activeView: TopLevelView
|
||||
}
|
||||
|
||||
export function getActiveViewPreferenceFile(dataFile: string): string {
|
||||
return join(dirname(dataFile), ACTIVE_VIEW_FILE_NAME)
|
||||
}
|
||||
|
||||
function readActiveView(file: string): TopLevelView | null {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<ActiveViewFile>
|
||||
return isTopLevelView(parsed.activeView) ? parsed.activeView : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function serializeActiveView(activeView: TopLevelView): string {
|
||||
return `${JSON.stringify({ activeView } satisfies ActiveViewFile)}\n`
|
||||
}
|
||||
|
||||
export class ActiveViewPreference {
|
||||
private readonly file: string
|
||||
private activeView: TopLevelView
|
||||
private persistedActiveView: TopLevelView | null
|
||||
private writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pendingWrite: Promise<void> | null = null
|
||||
private writeGeneration = 0
|
||||
|
||||
constructor(dataFile: string, legacyActiveView: unknown) {
|
||||
this.file = getActiveViewPreferenceFile(dataFile)
|
||||
const storedActiveView = readActiveView(this.file)
|
||||
const fallbackActiveView = isTopLevelView(legacyActiveView) ? legacyActiveView : 'terminal'
|
||||
this.activeView = storedActiveView ?? fallbackActiveView
|
||||
this.persistedActiveView = storedActiveView
|
||||
}
|
||||
|
||||
get(): TopLevelView {
|
||||
return this.activeView
|
||||
}
|
||||
|
||||
set(value: unknown): boolean {
|
||||
if (!isTopLevelView(value)) {
|
||||
return false
|
||||
}
|
||||
const changed = value !== this.activeView
|
||||
this.activeView = value
|
||||
if (
|
||||
value !== this.persistedActiveView ||
|
||||
this.writeTimer !== null ||
|
||||
this.pendingWrite !== null
|
||||
) {
|
||||
this.scheduleSave()
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private scheduleSave(): void {
|
||||
this.writeGeneration += 1
|
||||
if (this.writeTimer) {
|
||||
clearTimeout(this.writeTimer)
|
||||
}
|
||||
this.writeTimer = setTimeout(() => {
|
||||
this.writeTimer = null
|
||||
const generation = this.writeGeneration
|
||||
const activeView = this.activeView
|
||||
const previousWrite = this.pendingWrite ?? Promise.resolve()
|
||||
const nextWrite = previousWrite
|
||||
.then(() => this.writeAsync(activeView, generation))
|
||||
.catch((error) => {
|
||||
console.error('[active-view] Failed to persist preference:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingWrite === nextWrite) {
|
||||
this.pendingWrite = null
|
||||
}
|
||||
})
|
||||
this.pendingWrite = nextWrite
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
private async writeAsync(activeView: TopLevelView, generation: number): Promise<void> {
|
||||
const tmpFile = `${this.file}.${process.pid}.${generation}.tmp`
|
||||
let renamed = false
|
||||
try {
|
||||
await mkdir(dirname(this.file), { recursive: true })
|
||||
await writeFile(tmpFile, serializeActiveView(activeView), 'utf-8')
|
||||
// Why: keep the generation guard and the swap synchronous (no await between),
|
||||
// or a concurrent flushOrThrow could rename a newer view that this stale write
|
||||
// then clobbers, restoring the prior view on next launch.
|
||||
if (generation !== this.writeGeneration) {
|
||||
return
|
||||
}
|
||||
renameSync(tmpFile, this.file)
|
||||
renamed = true
|
||||
this.persistedActiveView = activeView
|
||||
} finally {
|
||||
if (!renamed) {
|
||||
await rm(tmpFile).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushOrThrow(): void {
|
||||
if (this.writeTimer) {
|
||||
clearTimeout(this.writeTimer)
|
||||
this.writeTimer = null
|
||||
}
|
||||
const asyncWriteWasInFlight = this.pendingWrite !== null
|
||||
this.writeGeneration += 1
|
||||
this.pendingWrite = null
|
||||
if (!asyncWriteWasInFlight && this.activeView === this.persistedActiveView) {
|
||||
return
|
||||
}
|
||||
mkdirSync(dirname(this.file), { recursive: true })
|
||||
const tmpFile = `${this.file}.${process.pid}.${this.writeGeneration}.tmp`
|
||||
writeFileSync(tmpFile, serializeActiveView(this.activeView), 'utf-8')
|
||||
renameSync(tmpFile, this.file)
|
||||
this.persistedActiveView = this.activeView
|
||||
}
|
||||
|
||||
async waitForPendingWrite(): Promise<void> {
|
||||
if (this.pendingWrite) {
|
||||
await this.pendingWrite
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
// Why: when first-message auto-name is on, the first time an agent starts
|
||||
// working in a freshly-created workspace we replace the auto-generated creature
|
||||
// branch (e.g. `you/Nautilus`) with a short, work-derived name. This module
|
||||
// owns the orchestration: gate on the signal, enforce the safety guardrails,
|
||||
// summarize the prompt via the configured agent, and rename.
|
||||
// On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name.
|
||||
import type { GlobalSettings, Repo } from '../../shared/types'
|
||||
import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
@@ -59,10 +55,7 @@ export type FirstWorkBranchRenameDeps = {
|
||||
setDisplayName: (worktreeId: string, displayName: string) => void
|
||||
/** Align the on-disk folder with the new branch leaf (best-effort, local-only). */
|
||||
renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise<boolean>
|
||||
/** Record (or clear with null) a user-facing auto-rename generation failure
|
||||
* so the sidebar can show a "rename failed" badge instead of silent retries.
|
||||
* `failureOutput` carries the bounded full CLI output for local on-demand
|
||||
* display; omitted/null replaces any stale capture. */
|
||||
/** Record (or clear with null) an auto-rename failure for the sidebar "rename failed" badge; `failureOutput` carries bounded CLI output for on-demand display. */
|
||||
setRenameError: (
|
||||
worktreeId: string,
|
||||
error: string | null,
|
||||
@@ -74,12 +67,7 @@ export type FirstWorkBranchRenameDeps = {
|
||||
onRenamed: (repoId: string) => void
|
||||
}
|
||||
|
||||
// Why: `inFlight` guards against concurrent runs while generation (seconds) is
|
||||
// pending; `settled` short-circuits worktrees we've reached a definitive verdict
|
||||
// on (renamed, or permanently ineligible) so we never re-probe git on later
|
||||
// `working` events — important because the hook fires every turn for every pane.
|
||||
// A *transient* failure (agent not reachable yet, brief detached HEAD) is NOT
|
||||
// settled, so the real first prompt can still succeed on a later event.
|
||||
// inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later).
|
||||
const inFlightWorktreeIds = new Set<string>()
|
||||
const settledWorktreeIds = new Set<string>()
|
||||
export const FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT = 500
|
||||
@@ -91,8 +79,7 @@ export function resetFirstWorkBranchRenameState(): void {
|
||||
}
|
||||
|
||||
function rememberSettledWorktreeId(worktreeId: string): void {
|
||||
// Why: the app can see unbounded worktree ids over a long session; evicting
|
||||
// oldest entries trades a rare re-probe for bounded process memory.
|
||||
// Why: worktree ids are unbounded over a long session; evict oldest to bound memory (costs a rare re-probe).
|
||||
settledWorktreeIds.delete(worktreeId)
|
||||
settledWorktreeIds.add(worktreeId)
|
||||
while (settledWorktreeIds.size > FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT) {
|
||||
@@ -115,8 +102,7 @@ export async function maybeAutoRenameBranchOnFirstWork(
|
||||
if (!deps.getSettings().autoRenameBranchFromWork) {
|
||||
return
|
||||
}
|
||||
// Resolve the worktree from the tab (authoritative) rather than the
|
||||
// agent-echoed worktreeId, which is not present on every hook event.
|
||||
// Resolve from the tab (authoritative); the agent-echoed worktreeId isn't present on every event.
|
||||
const tabId = parsePaneKey(event.paneKey)?.tabId ?? event.tabId
|
||||
const worktreeId = (tabId ? deps.resolveWorktreeIdForTab(tabId) : undefined) ?? event.worktreeId
|
||||
if (!worktreeId) {
|
||||
@@ -132,37 +118,27 @@ export async function maybeAutoRenameBranchOnFirstWork(
|
||||
}
|
||||
inFlightWorktreeIds.add(worktreeId)
|
||||
try {
|
||||
// `settled` true means a definitive verdict (renamed or permanently
|
||||
// ineligible); false means a transient bail that should retry later.
|
||||
// settled = definitive verdict (renamed/ineligible); false = transient bail to retry later.
|
||||
const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps)
|
||||
if (settled) {
|
||||
rememberSettledWorktreeId(worktreeId)
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: best-effort, opt-in convenience. A failure must never disrupt the
|
||||
// agent the user actually launched, so swallow after logging.
|
||||
// Why: best-effort convenience; a failure must never disrupt the user's agent, so swallow after logging.
|
||||
console.warn('[auto-branch-rename] rename attempt failed:', error)
|
||||
} finally {
|
||||
inFlightWorktreeIds.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a definitive verdict was reached (renamed, or permanently
|
||||
* ineligible — so stop re-probing), false for a transient bail worth retrying.
|
||||
*/
|
||||
/** Returns true for a definitive verdict (renamed or permanently ineligible → stop re-probing), false for a transient bail worth retrying. */
|
||||
async function runAutoRename(
|
||||
worktreeId: string,
|
||||
prompt: string,
|
||||
assistantMessage: string | undefined,
|
||||
deps: FirstWorkBranchRenameDeps
|
||||
): Promise<boolean> {
|
||||
// Why: when the feature appears not to fire, the question is always "which
|
||||
// gate bailed?" — this names the gate so a single log line answers it.
|
||||
// `stop` = permanent (don't retry); `retry` = transient (try again later).
|
||||
// `clearError` clears a stale "rename failed" badge when the worktree reaches
|
||||
// a benign final state (ineligible, user-renamed, or pushed) after an earlier
|
||||
// transient generation failure raised one — otherwise it would stick forever.
|
||||
// `stop` = permanent skip (logs which gate bailed); `retry` = transient; `clearError` drops a stale "rename failed" badge on a benign final state.
|
||||
const stop = (reason: string, clearError = false): true => {
|
||||
if (clearError) {
|
||||
deps.setRenameError(worktreeId, null)
|
||||
@@ -188,9 +164,7 @@ async function runAutoRename(
|
||||
}
|
||||
|
||||
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
|
||||
// Why: worktreePath is a Git subprocess cwd. Folder-workspace instance IDs
|
||||
// carry a synthetic `::workspace:<uuid>` suffix that is not a real directory,
|
||||
// so resolve to the backing folder or Git spawns against a nonexistent cwd.
|
||||
// Why: folder-workspace ids carry a synthetic `::workspace:<uuid>` suffix that's not a real dir; resolve to the backing folder for Git's cwd.
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
if (!repo || !parsed) {
|
||||
return stop('unresolved repo or worktree id')
|
||||
@@ -223,8 +197,7 @@ async function runAutoRename(
|
||||
return stop(`branch "${currentBranch}" already has an upstream`, true)
|
||||
}
|
||||
if (upstreamProbe.outcome === 'probe-failed') {
|
||||
// Why: settling an unreadable probe as "has upstream" made this failure
|
||||
// silent and permanent via the settled cache (issue #7808).
|
||||
// Why: settling an unreadable probe as "has upstream" made this silent + permanent via the settled cache (issue #7808).
|
||||
const probeError = upstreamProbe.message.replace(/\s+/g, ' ').trim()
|
||||
deps.setRenameError(
|
||||
worktreeId,
|
||||
@@ -237,8 +210,7 @@ async function runAutoRename(
|
||||
const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null)
|
||||
const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo)
|
||||
if (!resolvedParams.ok) {
|
||||
// Why: a generation-step failure (vs a benign skip) is user-actionable, so
|
||||
// surface it on the card rather than leaving a silent "rename pending".
|
||||
// Why: a generation-step failure (vs a benign skip) is user-actionable, so surface it on the card.
|
||||
deps.setRenameError(worktreeId, resolvedParams.error)
|
||||
return stop(`no generation agent: ${resolvedParams.error}`)
|
||||
}
|
||||
@@ -256,17 +228,14 @@ async function runAutoRename(
|
||||
target
|
||||
)
|
||||
if (!generated.success) {
|
||||
// Transient: the agent may be momentarily busy/unreachable — let a later
|
||||
// event retry rather than permanently leaving the creature name.
|
||||
// A user-canceled generation isn't a failure to surface, so skip the badge.
|
||||
// Transient failure: retry on a later event; skip the badge for a user-canceled generation.
|
||||
if (!generated.canceled) {
|
||||
deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null)
|
||||
}
|
||||
return retry(`generation failed: ${generated.error}`)
|
||||
}
|
||||
|
||||
// Re-validate after generation (it can take seconds): the branch must be the
|
||||
// same unpublished creature branch we started from, or we leave it alone.
|
||||
// Re-validate after generation (takes seconds): bail if the branch changed or was published meanwhile.
|
||||
const branchNow = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
|
||||
if (branchNow !== currentBranch) {
|
||||
return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`)
|
||||
@@ -283,14 +252,12 @@ async function runAutoRename(
|
||||
const username = provider
|
||||
? (await getSshGitUsername(provider, repo.path)) || null
|
||||
: (await resolveLocalGitUsername(repo.path)) || null
|
||||
// The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip
|
||||
// it so it doesn't double-prefix the branch or leak into the display name.
|
||||
// The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip it to avoid double-prefixing.
|
||||
const slug = stripConfiguredBranchPrefix(
|
||||
generated.slug,
|
||||
getConfiguredBranchPrefix(settings, username)
|
||||
)
|
||||
// Prefix-only model output strips to empty; renaming with it would just
|
||||
// re-add the prefix (`tmchow/tmchow`), so treat it as a benign skip.
|
||||
// Prefix-only output strips to empty; renaming would just re-add the prefix (`tmchow/tmchow`), so skip.
|
||||
if (!slug) {
|
||||
return stop('model produced only the configured prefix', true)
|
||||
}
|
||||
@@ -301,8 +268,7 @@ async function runAutoRename(
|
||||
currentBranch
|
||||
)
|
||||
if (!newBranch || newBranch === currentBranch) {
|
||||
// Generation succeeded but yielded no distinct name — terminal and benign,
|
||||
// so clear any stale failure badge a prior transient attempt left behind.
|
||||
// No distinct name is terminal and benign, so clear any stale failure badge from a prior transient attempt.
|
||||
return stop(`no distinct unique branch name for slug "${slug}"`, true)
|
||||
}
|
||||
|
||||
@@ -310,12 +276,9 @@ async function runAutoRename(
|
||||
? provider.renameCurrentBranch(worktreePath, newBranch)
|
||||
: renameCurrentBranch(exec, newBranch))
|
||||
|
||||
// resolveUniqueBranchName may have appended a collision suffix (`-2`, …), so
|
||||
// derive the sidebar name and on-disk folder from the *resolved* branch leaf,
|
||||
// not the pre-suffix slug, to keep branch, display, and folder aligned.
|
||||
// resolveUniqueBranchName may append a collision suffix (`-2`, …), so derive names from the resolved leaf, not the slug.
|
||||
const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1)
|
||||
// Keep the sidebar name in sync with the branch — but only when it is still
|
||||
// the auto-generated creature name, so a name the user typed is left alone.
|
||||
// Only sync the sidebar name when it's still the auto-generated creature name, so a user-typed name is left alone.
|
||||
const currentDisplayName = deps.getCurrentDisplayName(worktreeId)
|
||||
const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf })
|
||||
const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName)
|
||||
@@ -326,10 +289,7 @@ async function runAutoRename(
|
||||
// A successful rename clears any stale generation-failure surfaced earlier.
|
||||
deps.setRenameError(worktreeId, null)
|
||||
|
||||
// Align the on-disk folder with the new branch leaf. Best-effort and local-only:
|
||||
// a skip or failure (remote, Windows lock, dest taken) leaves the folder as-is
|
||||
// and must never undo the branch/display rename that already landed. Runs after
|
||||
// setDisplayName so the new display name rides along into the migrated identity.
|
||||
// Best-effort local-only folder align, after setDisplayName so the new name rides into the migrated identity; a failure must never undo the landed rename.
|
||||
let folderRenamed = false
|
||||
if (deps.renameWorktreeFolder) {
|
||||
try {
|
||||
@@ -339,9 +299,7 @@ async function runAutoRename(
|
||||
}
|
||||
}
|
||||
|
||||
// A successful folder rename already invalidated caches and pushed a
|
||||
// worktrees:changed carrying the id mapping; a second onRenamed would only
|
||||
// trigger a redundant renderer re-list. Otherwise notify for the branch rename.
|
||||
// A folder rename already notified the renderer, so a second onRenamed would just re-list redundantly.
|
||||
if (!folderRenamed) {
|
||||
deps.onRenamed(repo.id)
|
||||
}
|
||||
|
||||
@@ -38,16 +38,11 @@ export type HooksConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Why: host-level backstop (seconds) for Orca-managed status hooks. The shell
|
||||
// wrapper's curl `--max-time 1.5` is the normal dead-endpoint bound; this caps a
|
||||
// hook the agent host itself runs in case that transport budget is bypassed.
|
||||
// Intentionally independent of Copilot's `timeoutSec: 5` — both managed budgets
|
||||
// coexist by design (#4633).
|
||||
// Why: host-level backstop timeout for status hooks, independent of the curl --max-time and Copilot's timeoutSec (#4633).
|
||||
export const MANAGED_HOOK_TIMEOUT_SECONDS = 10
|
||||
export const MANAGED_HOOK_TIMEOUT_MILLISECONDS = MANAGED_HOOK_TIMEOUT_SECONDS * 1000
|
||||
|
||||
// Nested command hook used by the Claude-shaped `hooks: [...]` schema (Claude,
|
||||
// Codex, Gemini, Droid, Grok, Command Code, Devin).
|
||||
// Nested command hook for the Claude-shaped `hooks: [...]` schema (Claude, Codex, Gemini, Droid, Grok, Command Code, Devin).
|
||||
export function buildManagedCommandHook(
|
||||
command: string,
|
||||
timeout = MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
@@ -55,8 +50,7 @@ export function buildManagedCommandHook(
|
||||
return { type: 'command', command, timeout }
|
||||
}
|
||||
|
||||
// Direct command definition used by schemas that put `command` on the
|
||||
// definition itself (Cursor's documented top-level shape).
|
||||
// Direct command definition for schemas that put `command` on the definition itself (Cursor's top-level shape).
|
||||
export function buildManagedCommandDefinition(command: string): HookDefinition {
|
||||
return { command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
|
||||
}
|
||||
@@ -78,18 +72,12 @@ export function readHooksJson(configPath: string): HooksConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: callers in install/remove need to match not just the exact current
|
||||
// managed command, but also stale entries pointing at old script paths — e.g.
|
||||
// from a previous dev build with a different Electron userData dir, or a
|
||||
// parallel dev/prod install. Matching by the managed script's file name
|
||||
// (under any `agent-hooks/` directory) lets a fresh install sweep those
|
||||
// without touching unrelated user-authored hooks.
|
||||
// Why: match by script file name, not exact command, so a fresh install sweeps stale entries from old/parallel installs.
|
||||
export function createManagedCommandMatcher(
|
||||
scriptFileName: string
|
||||
): (command: string | undefined) => boolean {
|
||||
const scriptStem = scriptFileName.replace(/\.(?:cmd|ps1|sh)$/, '')
|
||||
// Why: local Windows installs use .cmd or Copilot's .ps1, while SSH/POSIX
|
||||
// installs use .sh. A platform switch must still sweep stale Orca hooks.
|
||||
// Why: installs use .cmd/.ps1 (Windows) or .sh (SSH/POSIX); match all so a platform switch still sweeps stale hooks.
|
||||
const needles = [
|
||||
`agent-hooks/${scriptFileName}`,
|
||||
`agent-hooks/${scriptStem}.cmd`,
|
||||
@@ -119,8 +107,7 @@ function decodePowerShellEncodedCommand(command: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: prod, dev, and parallel Orca instances must write the same managed
|
||||
// settings entry instead of racing between per-userData script paths.
|
||||
// Why: prod/dev/parallel Orca instances must write the same managed entry, not race between per-userData script paths.
|
||||
export function getSharedManagedScriptPath(scriptFileName: string): string {
|
||||
return join(homedir(), '.orca', 'agent-hooks', scriptFileName)
|
||||
}
|
||||
@@ -129,16 +116,9 @@ function quotePosixShellString(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
// Why: a stale managed hook entry (left over after the user wiped userData,
|
||||
// switched dev↔prod installs, or had a partial install fail) used to fire
|
||||
// `/bin/sh "<missing path>"` on every tool call, which exits 127 and surfaces
|
||||
// as `PreToolUse hook (failed) error: hook exited with code 127` in the agent
|
||||
// transcript. Guarding for a regular readable executable file makes a broken
|
||||
// install a silent no-op without hiding failures from a script that starts.
|
||||
// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call.
|
||||
export function wrapPosixHookCommand(scriptPath: string, env: Record<string, string> = {}): string {
|
||||
// Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken
|
||||
// literally — avoids a shell-injection footgun if a future caller passes an
|
||||
// arbitrary path.
|
||||
// Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path.
|
||||
const quoted = quotePosixShellString(scriptPath)
|
||||
const envPrefix = Object.entries(env)
|
||||
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
|
||||
@@ -162,8 +142,7 @@ export function wrapWindowsHookCommand(
|
||||
scriptPath: string,
|
||||
env: Record<string, string> = {}
|
||||
): string {
|
||||
// Why: the encoded launcher protects paths across Windows hook shells and
|
||||
// owns stdin when a stale config points at a missing managed script.
|
||||
// Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script.
|
||||
const quoted = quotePowerShellString(scriptPath)
|
||||
const envPrefix = Object.entries(env)
|
||||
.map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `)
|
||||
@@ -176,14 +155,7 @@ export function wrapWindowsHookCommand(
|
||||
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
|
||||
|
||||
export function wrapWindowsCmdHookCommand(scriptPath: string): string {
|
||||
// Why: Codex/Antigravity/Devin launch the hook command as a program (argv[0]),
|
||||
// NOT through cmd.exe, so the launcher must be a single directly-spawnable
|
||||
// token. The bare .cmd path is exactly that. A cmd-builtin `if exist …`
|
||||
// launcher is unspawnable — its argv[0] is `if` — so it fails every hook with
|
||||
// exit 1 (#8430 regression). A stale/missing script therefore surfaces a normal
|
||||
// launch failure here; the missing-script stdin drain lives on the encoded
|
||||
// fallback below, which needs a real interpreter for spaces/metacharacters
|
||||
// anyway and cannot be reached by a cmd-builtin drain without breaking spawn.
|
||||
// Why: Codex/Antigravity/Devin spawn the hook as argv[0], not via cmd.exe, so it must be one spawnable token; a cmd `if exist` launcher isn't (#8430).
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
@@ -191,17 +163,15 @@ export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
|
||||
|
||||
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
|
||||
const bashPath = scriptPath.replaceAll('\\', '/')
|
||||
// Why: Claude's Git Bash runner can execute a forward-slash .cmd directly;
|
||||
// unsafe paths stay encoded and the fast path gains a missing-file drain.
|
||||
// Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded.
|
||||
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath)
|
||||
? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
: wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
|
||||
// Why: Codex runs these hooks inline on every turn. PowerShell startup alone
|
||||
// makes trusted Windows hooks visibly slow, so mirror the POSIX curl path.
|
||||
// Qualify curl so a repo-local curl.exe cannot hijack hook payloads.
|
||||
// Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path.
|
||||
// Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads.
|
||||
return [
|
||||
`"%SystemRoot%\\System32\\curl.exe" -sS -X POST "http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}" ^`,
|
||||
' --connect-timeout 0.5 --max-time 1.5 ^',
|
||||
@@ -217,12 +187,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
// Why: status hooks fire up to 6× per turn; spawning PowerShell per post adds
|
||||
// ~300ms of interpreter startup each, which Codex 0.140's synchronous "Running
|
||||
// <event> hook" rows make visible. curl.exe (Windows 10 1803+) posts the same
|
||||
// form fields as the POSIX hook and reads the raw payload from stdin via
|
||||
// `--data-urlencode payload@-`, so UTF-8 (e.g. CJK prompts) survives byte-for-
|
||||
// byte without the code-page translation that previously forced PowerShell.
|
||||
// Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both.
|
||||
export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string {
|
||||
return [
|
||||
'"%SystemRoot%\\System32\\curl.exe" -sS -X POST',
|
||||
@@ -294,8 +259,7 @@ export function hookDefinitionHasManagedCommand(
|
||||
)
|
||||
}
|
||||
|
||||
// Why: temp+rename so concurrent Orca instances writing this shared path can't
|
||||
// produce a torn script that an in-flight `/bin/sh <scriptPath>` would source.
|
||||
// Why: temp+rename so concurrent writers can't leave a torn script for an in-flight /bin/sh to source.
|
||||
export function writeManagedScript(scriptPath: string, content: string): void {
|
||||
const dir = dirname(scriptPath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
@@ -316,9 +280,7 @@ export function writeManagedScript(scriptPath: string, content: string): void {
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
try {
|
||||
writeScriptWithAclRetry(tmpPath, content)
|
||||
// Why: chmod before rename so the canonical path is never visible in a
|
||||
// unreadable/non-executable state; wrapPosixHookCommand's guards would
|
||||
// silently skip the hook in that window.
|
||||
// Why: chmod before rename so the canonical path is never visible non-executable, else the POSIX guard skips the hook.
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(tmpPath, 0o755)
|
||||
}
|
||||
@@ -334,8 +296,7 @@ export function writeManagedScript(scriptPath: string, content: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: on Windows, write may fail with EPERM if the target directory has a
|
||||
// restrictive DACL. Grant an explicit ACL on EPERM and retry once.
|
||||
// Why: a restrictive directory DACL makes writes fail with EPERM on Windows; grant an ACL and retry once.
|
||||
function writeScriptWithAclRetry(scriptPath: string, content: string): void {
|
||||
try {
|
||||
writeFileSync(scriptPath, content, 'utf-8')
|
||||
@@ -357,39 +318,25 @@ export function writeHooksJson(configPath: string, config: HooksConfig): void {
|
||||
const dir = dirname(configPath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
// Why: write to a temp file then rename so a crash or disk-full mid-write
|
||||
// leaves the original untouched. This is the only safe way to update a
|
||||
// config file the user may have hand-edited.
|
||||
//
|
||||
// Why randomUUID: Date.now() alone collides when two install() calls fire in
|
||||
// the same millisecond targeting the same dir (e.g. a future caller that
|
||||
// installs multiple agents sharing a config dir, or rapid reinstalls from
|
||||
// the settings UI). A collision would corrupt one of the two writes. The
|
||||
// UUID suffix makes the tmp path unique per call.
|
||||
// Why: temp+rename leaves the original untouched on a crash/disk-full mid-write.
|
||||
// Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond.
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
const serialized = `${JSON.stringify(config, null, 2)}\n`
|
||||
|
||||
// Why: skip the write (and therefore the .bak rotation) when the on-disk
|
||||
// content is already identical. Without this, every install() rewrites the
|
||||
// file and rolls the backup forward, which can silently destroy the last
|
||||
// recoverable copy if install() is called repeatedly (e.g. on app start).
|
||||
// Why: skip identical writes so repeated install() calls don't roll the .bak forward and destroy the last recoverable copy.
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
if (readFileSync(configPath, 'utf-8') === serialized) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the normal write path — a read error here is not
|
||||
// worth failing the install for; the atomic write below will either
|
||||
// succeed or throw loudly.
|
||||
// Fall through to the normal write path; a read error isn't worth failing the install for.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(tmpPath, serialized, 'utf-8')
|
||||
// Why: single rolling backup — one file, no accumulation in ~/.claude.
|
||||
// Protects against a merge-logic bug producing bad JSON; the original is
|
||||
// always recoverable from <configPath>.bak until the next write.
|
||||
// Why: single rolling backup so a merge-logic bug producing bad JSON stays recoverable from <configPath>.bak until the next write.
|
||||
if (existsSync(configPath)) {
|
||||
copyFileSync(configPath, `${configPath}.bak`)
|
||||
}
|
||||
|
||||
+182
-118
@@ -157,9 +157,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
state: 'working',
|
||||
prompt: 'review loop',
|
||||
agentType: 'claude',
|
||||
// Why: a working pane can be child-driven (lead already idle).
|
||||
// Ctrl+C does not stop background children, so no terminal done
|
||||
// may be inferred while one is still running.
|
||||
// Why: a working pane can be child-driven while the lead is idle; Ctrl+C doesn't stop children, so no terminal-done may be inferred here.
|
||||
subagents: [{ id: 'a1', state: 'working', startedAt: 900 }]
|
||||
}
|
||||
},
|
||||
@@ -1425,7 +1423,92 @@ describe('AgentHookServer listener replay', () => {
|
||||
server.clearPaneState(PANE)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(PANE)
|
||||
expect(listener).toHaveBeenCalledWith({ paneKey: PANE })
|
||||
})
|
||||
|
||||
it('batches connection cleanup and retains sibling and local statuses', () => {
|
||||
const server = new AgentHookServer()
|
||||
const paneKeyAt = (prefix: string, index: number): string =>
|
||||
makePaneKey(
|
||||
`${prefix}-tab-${index}`,
|
||||
`00000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, '0')}`
|
||||
)
|
||||
const targetPaneKeys = Array.from({ length: 100 }, (_, index) => paneKeyAt('target', index))
|
||||
const siblingPaneKeys = Array.from({ length: 100 }, (_, index) =>
|
||||
paneKeyAt('sibling', index + 100)
|
||||
)
|
||||
const unstampedPaneKey = paneKeyAt('legacy', 250)
|
||||
const statusListener = vi.fn()
|
||||
const clearListener = vi.fn()
|
||||
const internals = server as unknown as AgentHookServerCacheInternals
|
||||
const persistSpy = vi.spyOn(internals, 'scheduleStatusPersist')
|
||||
server.subscribeStatusChanges(statusListener)
|
||||
server.setPaneStatusClearListener(clearListener)
|
||||
for (const paneKey of targetPaneKeys) {
|
||||
server.ingestRemote({ paneKey, payload: { state: 'working', agentType: 'claude' } }, 'ssh-a')
|
||||
}
|
||||
for (const paneKey of siblingPaneKeys) {
|
||||
server.ingestRemote({ paneKey, payload: { state: 'working', agentType: 'claude' } }, 'ssh-b')
|
||||
}
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: unstampedPaneKey,
|
||||
payload: { state: 'working', prompt: '', agentType: 'codex' }
|
||||
})
|
||||
statusListener.mockClear()
|
||||
persistSpy.mockClear()
|
||||
|
||||
server.clearStatusEntriesForConnection('ssh-a')
|
||||
|
||||
expect(statusListener).toHaveBeenCalledOnce()
|
||||
expect(persistSpy).toHaveBeenCalledOnce()
|
||||
expect(clearListener).toHaveBeenCalledOnce()
|
||||
expect(clearListener).toHaveBeenCalledWith({
|
||||
transient: true,
|
||||
connectionId: 'ssh-a',
|
||||
clearedAt: expect.any(Number)
|
||||
})
|
||||
expect(server.getStatusSnapshot().map((entry) => entry.paneKey)).toEqual([
|
||||
...siblingPaneKeys,
|
||||
unstampedPaneKey
|
||||
])
|
||||
})
|
||||
|
||||
it('emits a connection cutoff after a pane-key collision and orders replay after it', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_700_000_000_000)
|
||||
const server = new AgentHookServer()
|
||||
const clearListener = vi.fn()
|
||||
server.setPaneStatusClearListener(clearListener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, payload: { state: 'working', agentType: 'claude' } },
|
||||
'ssh-a'
|
||||
)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, payload: { state: 'working', agentType: 'codex' } },
|
||||
'ssh-b'
|
||||
)
|
||||
|
||||
server.clearStatusEntriesForConnection('ssh-a')
|
||||
const clear = clearListener.mock.calls[0]?.[0] as {
|
||||
transient: true
|
||||
connectionId: string
|
||||
clearedAt: number
|
||||
}
|
||||
server.ingestRemote(
|
||||
{ paneKey: GOOD_PANE, payload: { state: 'working', agentType: 'claude' }, isReplay: true },
|
||||
'ssh-a'
|
||||
)
|
||||
|
||||
expect(clear).toMatchObject({ transient: true, connectionId: 'ssh-a' })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, connectionId: 'ssh-b' }),
|
||||
expect.objectContaining({
|
||||
paneKey: GOOD_PANE,
|
||||
connectionId: 'ssh-a',
|
||||
receivedAt: clear.clearedAt + 1
|
||||
})
|
||||
])
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drops cached statuses and pane-scoped listener caches under one tab prefix', () => {
|
||||
@@ -2486,9 +2569,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: agent-status-over-SSH §3 — ingestRemote must run the same warn-once
|
||||
// cross-build diagnostics the local HTTP path runs, so a remote source of
|
||||
// genuinely stale hooks emits the same signal locally.
|
||||
// Why (agent-status-over-SSH §3): ingestRemote must run the same warn-once diagnostics as the local HTTP path so stale remote hooks signal locally.
|
||||
it('runs warn-once env/version diagnostics on relay-forwarded events', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
@@ -2546,9 +2627,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
'conn-1'
|
||||
)
|
||||
expect(warn.mock.calls.length).toBe(warnsAfterFirst)
|
||||
// Why: pin both invariants — warn-once dedupe AND fanout still fires for
|
||||
// the second event. Without the second assertion, a future refactor that
|
||||
// drops the second event silently would still leave warn-count unchanged.
|
||||
// Why: assert fanout still fires on the second event too, else a refactor that drops it would pass on warn-count alone.
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
server.stop()
|
||||
@@ -3758,9 +3837,7 @@ describe('Claude hook normalization', () => {
|
||||
})
|
||||
|
||||
it('PostToolUse for an unknown tool surfaces the name without input', () => {
|
||||
// Why: we use a per-tool allowlist to decide which field to preview.
|
||||
// Tools we do not recognize render as name-only rather than guessing at
|
||||
// a field, which avoids noisy/misleading previews (e.g. an opaque ID).
|
||||
// Why: a per-tool allowlist picks the preview field; unrecognized tools render name-only to avoid guessing a misleading field (e.g. an opaque ID).
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'claude',
|
||||
buildBody({
|
||||
@@ -3775,9 +3852,7 @@ describe('Claude hook normalization', () => {
|
||||
})
|
||||
|
||||
it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => {
|
||||
// Why: TaskUpdate's tool_input (e.g. { task_id: "3", status: "in_progress" })
|
||||
// has no meaningful preview — rendering "3" is actively confusing. The
|
||||
// allowlist approach leaves toolInput undefined for unlisted tools.
|
||||
// Why: TaskUpdate's tool_input has no meaningful preview (rendering "3" is confusing), so the allowlist leaves toolInput undefined.
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'claude',
|
||||
buildBody({
|
||||
@@ -4059,9 +4134,7 @@ describe('Claude hook normalization', () => {
|
||||
})
|
||||
|
||||
it('finds an assistant reply that sits past the first chunk boundary', () => {
|
||||
// Why: a turn with many large tool_result entries pushes the final text
|
||||
// reply well past the first 64 KB chunk; the chunked scan should keep
|
||||
// reading backward until it finds it.
|
||||
// Why: large tool_result entries push the final reply past the first 64 KB chunk; the scan must keep reading backward to find it.
|
||||
const filler = 'x'.repeat(70_000)
|
||||
const lines = [
|
||||
{ role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } },
|
||||
@@ -4123,8 +4196,7 @@ describe('Claude hook normalization', () => {
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
// Stop event has no tool fields of its own — merged snapshot should still
|
||||
// carry the earlier PreToolUse values.
|
||||
// Stop event has no tool fields, so the merged snapshot must keep the earlier PreToolUse values.
|
||||
const stop = _internals.normalizeHookPayload(
|
||||
'claude',
|
||||
buildBody({ hook_event_name: 'Stop' }),
|
||||
@@ -4151,10 +4223,7 @@ describe('Codex hook normalization', () => {
|
||||
})
|
||||
|
||||
it('PreToolUse surfaces tool name + input preview and stays in working state', () => {
|
||||
// Why: Codex's PreToolUse is NOT an approval prompt — it fires for every
|
||||
// tool call. We map it to `working` (never `waiting`) and use it only to
|
||||
// give the dashboard a live readout during the gap between prompt and
|
||||
// Stop. Real approval signals flow through PermissionRequest.
|
||||
// Why: Codex's PreToolUse fires for every tool call (not an approval), so map it to `working` not `waiting`; approvals flow through PermissionRequest.
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({
|
||||
@@ -4170,10 +4239,7 @@ describe('Codex hook normalization', () => {
|
||||
})
|
||||
|
||||
it('PermissionRequest maps to waiting and surfaces the pending tool input', () => {
|
||||
// Why: Codex asks for user attention through PermissionRequest. Orca's
|
||||
// sidebar red dot depends on this becoming `waiting`; treating it like
|
||||
// PreToolUse would leave the pane looking busy while it is blocked on the
|
||||
// user.
|
||||
// Why: PermissionRequest must map to `waiting` (sidebar red dot); treating it like PreToolUse would leave the pane looking busy while blocked on the user.
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({
|
||||
@@ -4190,9 +4256,7 @@ describe('Codex hook normalization', () => {
|
||||
})
|
||||
|
||||
it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => {
|
||||
// Why: UserPromptSubmit is a turn-boundary event; any tool_name on it
|
||||
// would be leftover noise and should not leak into the working-state
|
||||
// preview. Tool extraction is gated to PreToolUse/PostToolUse.
|
||||
// Why: UserPromptSubmit is a turn boundary; tool extraction is gated to Pre/PostToolUse so stray tool_name can't leak into the preview.
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({
|
||||
@@ -4334,11 +4398,7 @@ describe('OpenCode hook normalization', () => {
|
||||
})
|
||||
|
||||
it('SessionBusy does NOT clear the cached user prompt', () => {
|
||||
// Why: OpenCode emits the user's MessagePart (message.updated) *before*
|
||||
// SessionBusy fires — the session goes idle→busy only after OpenCode begins
|
||||
// processing the prompt. So the cached prompt at SessionBusy is the current
|
||||
// turn's prompt, not the previous turn's. Clearing on SessionBusy would
|
||||
// clobber the data the dashboard needs to render for this turn.
|
||||
// Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard.
|
||||
_internals.normalizeHookPayload(
|
||||
'opencode',
|
||||
buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }),
|
||||
@@ -4373,12 +4433,7 @@ describe('OpenCode hook normalization', () => {
|
||||
})
|
||||
|
||||
it('AskUserQuestion maps to waiting', () => {
|
||||
// Why: OpenCode emits `question.asked` when the agent uses an ask-the-user
|
||||
// tool (distinct from `permission.asked`, which blocks on tool approval).
|
||||
// Both leave the agent idle-but-waiting on a human, so both must render
|
||||
// the same red "needs attention" indicator. Without this mapping the pane
|
||||
// silently stays in `working` and the user has no visual cue that the
|
||||
// agent is waiting on them.
|
||||
// Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`.
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'opencode',
|
||||
buildBody({ hook_event_name: 'AskUserQuestion' }),
|
||||
@@ -4429,10 +4484,7 @@ describe('OpenCode hook normalization', () => {
|
||||
})
|
||||
|
||||
it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => {
|
||||
// Why: plugin builds installed before the throttle/cap fix re-post the
|
||||
// full accumulated reply on every streamed part update. The listener must
|
||||
// bound the text so each event's status compare, IPC fanout, and renderer
|
||||
// store update stay O(cap) instead of O(reply length).
|
||||
// Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap).
|
||||
const assistant = _internals.normalizeHookPayload(
|
||||
'opencode',
|
||||
buildBody({
|
||||
@@ -4444,9 +4496,7 @@ describe('OpenCode hook normalization', () => {
|
||||
)
|
||||
expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000)
|
||||
|
||||
// Why: prompt has always been single-line-capped at 200 by
|
||||
// normalizeAgentStatusObject; this asserts the oversized input still
|
||||
// flows through without blowing past that bound.
|
||||
// Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound.
|
||||
const user = _internals.normalizeHookPayload(
|
||||
'opencode',
|
||||
buildBody({
|
||||
@@ -4571,8 +4621,7 @@ describe('Cursor hook normalization', () => {
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
// Why: keeping toolName set would let the compact sidebar show the tool
|
||||
// instead of the failure text, hiding the error from the user.
|
||||
// Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error.
|
||||
expect(failed?.payload).toMatchObject({
|
||||
state: 'working',
|
||||
lastAssistantMessage: 'file not found'
|
||||
@@ -5108,8 +5157,7 @@ describe('Pi hook normalization', () => {
|
||||
buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }),
|
||||
'production'
|
||||
)
|
||||
// Why: pi captures the user prompt via before_agent_start, not via
|
||||
// message_end. A user-role message_end should not flip lastAssistantMessage.
|
||||
// Why: pi captures the user prompt via before_agent_start, so a user-role message_end must not flip lastAssistantMessage.
|
||||
expect(result?.payload.lastAssistantMessage).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -5129,8 +5177,7 @@ describe('Pi hook normalization', () => {
|
||||
buildBody({ hook_event_name: 'session_shutdown' }),
|
||||
'production'
|
||||
)
|
||||
// Why: Pi also emits shutdown when reloading or replacing its in-process
|
||||
// session while the PTY stays alive; only agent_end proves turn completion.
|
||||
// Why: Pi emits shutdown on reload/replace while the PTY stays alive; only agent_end proves turn completion.
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -5239,8 +5286,7 @@ describe('Copilot hook normalization', () => {
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
// Why: keeping toolName set would let the compact sidebar show the tool
|
||||
// instead of the failure text, hiding the error from the user.
|
||||
// Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error.
|
||||
expect(failed?.payload).toMatchObject({
|
||||
state: 'working',
|
||||
lastAssistantMessage: 'command not found'
|
||||
@@ -5630,9 +5676,7 @@ describe('Endpoint file lifecycle', () => {
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const filePath = server.endpointFilePath!
|
||||
// Why: mask off type/setuid bits so we assert only the rwx octet that
|
||||
// writeFileSync(mode:0o600) sets. A leaky umask at dir-create time can
|
||||
// leave group/other bits on the *parent* dir but not on the file itself.
|
||||
// Why: mask to the rwx octet so we assert only the file's mode:0o600, not umask-leaked bits on the parent dir.
|
||||
const mode = statSync(filePath).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
} finally {
|
||||
@@ -5654,19 +5698,11 @@ describe('Endpoint file lifecycle', () => {
|
||||
const secondToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN
|
||||
// Path is stable (so PTYs stamped before restart can still find the file)
|
||||
expect(secondPath).toBe(firstPath)
|
||||
// But contents are refreshed with the new token (and port) — that is the
|
||||
// whole point of the design: survivors reading a stale-env file reach the
|
||||
// live server. Why token-first: the token is randomUUID()-minted per
|
||||
// start(), so it is guaranteed to differ across restarts. The port comes
|
||||
// from listen(0) and the kernel can legitimately reassign the same
|
||||
// ephemeral port, so asserting port-inequality would be a latent flake.
|
||||
// Contents refresh with a new token so stale-env survivors reach the live server.
|
||||
expect(secondToken).toBeTruthy()
|
||||
expect(secondToken).not.toBe(firstToken)
|
||||
const contents = readFileSync(secondPath!, 'utf8')
|
||||
// Why: token-based content check is the rewrite signal. A strict
|
||||
// "contents does NOT contain firstPort" assertion would flake on the
|
||||
// (rare but legitimate) case where listen(0) reuses the same ephemeral
|
||||
// port across restarts. The token is randomUUID() and cannot collide.
|
||||
// Why: assert on token (randomUUID, can't collide), not port — listen(0) may legitimately reuse the ephemeral port and flake a port check.
|
||||
expect(contents).toContain(`ORCA_AGENT_HOOK_PORT=${secondPort}`)
|
||||
expect(contents).toContain(`ORCA_AGENT_HOOK_TOKEN=${secondToken}`)
|
||||
expect(contents).not.toContain(`ORCA_AGENT_HOOK_TOKEN=${firstToken}`)
|
||||
@@ -5676,13 +5712,7 @@ describe('Endpoint file lifecycle', () => {
|
||||
})
|
||||
|
||||
it('leaves the endpoint file in place on stop()', async () => {
|
||||
// Why: stop() deliberately does NOT unlink the endpoint file. A stale file
|
||||
// points at a dead port — the fail-open path (hook POSTs silently fail,
|
||||
// same as pre-endpoint-file). Unlinking would introduce a TOCTOU race with a
|
||||
// concurrent Orca instance sharing userData that could rewrite the file
|
||||
// between our token check and unlink. The next successful start()
|
||||
// overwrites the file atomically; tmp-file orphan hygiene is handled by
|
||||
// the sweep inside writeEndpointFile().
|
||||
// Why: stop() leaves the file (stale = fail-open); unlinking would race a concurrent Orca instance rewriting it between token-check and unlink (TOCTOU).
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
const filePath = server.endpointFilePath!
|
||||
@@ -5740,9 +5770,7 @@ describe('Endpoint file lifecycle', () => {
|
||||
})
|
||||
|
||||
it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => {
|
||||
// Why: the endpoint file is opt-in via start({ userDataPath }). In tests
|
||||
// and in the packaged main-process path where userData is unset for any
|
||||
// reason, hooks should fall back to the v1 behavior (no ENDPOINT key).
|
||||
// Why: the endpoint file is opt-in via userDataPath; without it, hooks fall back to v1 behavior (no ENDPOINT key).
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
@@ -5761,10 +5789,7 @@ describe('Endpoint file lifecycle', () => {
|
||||
})
|
||||
|
||||
it('sweeps stale .endpoint-*.tmp orphans older than 5 minutes on start', async () => {
|
||||
// Why: writeEndpointFile() writes to a unique tmp path then renames. A crash
|
||||
// between write and rename leaves an orphan tmp; the sweep inside
|
||||
// writeEndpointFile() must drop ones older than 5 min without touching
|
||||
// fresh ones (a concurrent writer's in-flight tmp).
|
||||
// Why: a crash between tmp-write and rename orphans a tmp; sweep must drop stale ones (>5min) but spare a concurrent writer's fresh in-flight tmp.
|
||||
const dir = join(userDataPath, 'agent-hooks')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const staleTmp = join(dir, '.endpoint-999-stale.tmp')
|
||||
@@ -5785,12 +5810,7 @@ describe('Endpoint file lifecycle', () => {
|
||||
})
|
||||
|
||||
it('refuses to write the endpoint file when a value contains shell metacharacters', async () => {
|
||||
// Why: every value written is sourced as shell. The isShellSafeEndpointValue
|
||||
// allowlist must reject a metacharacter-bearing value so a future caller
|
||||
// cannot command-inject via the sourced file. `env` is the only caller-
|
||||
// provided field we can easily poison from a test — feed it a semicolon
|
||||
// and assert the file is not written and buildPtyEnv() omits the ENDPOINT
|
||||
// key (gated on endpointFileWritten).
|
||||
// Why: written values are sourced as shell, so isShellSafeEndpointValue must reject metacharacters to prevent command injection.
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'bad;value', userDataPath })
|
||||
try {
|
||||
@@ -5875,10 +5895,7 @@ describe('Endpoint file lifecycle', () => {
|
||||
try {
|
||||
const filePath = server.endpointFilePath!
|
||||
const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT
|
||||
// Why: sources the file in a subshell and echoes the resulting env var,
|
||||
// exactly as the managed hook script does at runtime. If the file shape
|
||||
// ever drifts from `KEY=VALUE` (e.g. someone adds shell metacharacters
|
||||
// without quoting), this test catches it before users do.
|
||||
// Why: source the file exactly as the managed hook script does, catching drift from the KEY=VALUE shape before users do.
|
||||
const out = execFileSync('/bin/sh', ['-c', `. "${filePath}" && echo "$ORCA_AGENT_HOOK_PORT"`])
|
||||
.toString()
|
||||
.trim()
|
||||
@@ -5904,9 +5921,7 @@ describe('Last-status persistence', () => {
|
||||
return join(userDataPath, 'agent-hooks', 'last-status.json')
|
||||
}
|
||||
|
||||
// Why: hydrate now drops entries older than 7d (HYDRATE_MAX_AGE_MS). Use
|
||||
// a recent-but-not-Date.now() timestamp in fixtures so the tests assert
|
||||
// hydration behavior rather than racing the wall clock.
|
||||
// Why: use a recent-but-not-now timestamp so fixtures survive hydrate's 7d drop (HYDRATE_MAX_AGE_MS) without racing the wall clock.
|
||||
function recentTs(offsetMs = 0): number {
|
||||
return Date.now() - 60 * 60 * 1000 + offsetMs
|
||||
}
|
||||
@@ -6111,6 +6126,68 @@ describe('Last-status persistence', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps SSH status ordering monotonic across hydration and clock rollback', async () => {
|
||||
const now = 1_700_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = now + 1_000
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'ssh-a',
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
payload: { state: 'working', prompt: 'before restart', agentType: 'claude' }
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const clearListener = vi.fn()
|
||||
server.setPaneStatusClearListener(clearListener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, payload: { state: 'working', agentType: 'codex' } },
|
||||
'ssh-a'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]?.receivedAt).toBe(receivedAt + 1)
|
||||
server.clearStatusEntriesForConnection('ssh-a')
|
||||
const clearedAt = receivedAt + 2
|
||||
expect(clearListener).toHaveBeenCalledWith({
|
||||
transient: true,
|
||||
connectionId: 'ssh-a',
|
||||
clearedAt
|
||||
})
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: GOOD_PANE,
|
||||
isReplay: true,
|
||||
payload: { state: 'working', agentType: 'claude' }
|
||||
},
|
||||
'ssh-a'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: GOOD_PANE,
|
||||
connectionId: 'ssh-a',
|
||||
receivedAt: clearedAt + 1
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops persisted idle Claude child rows from hydration replay', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = recentTs()
|
||||
@@ -6158,8 +6235,7 @@ describe('Last-status persistence', () => {
|
||||
subagents: undefined
|
||||
})
|
||||
])
|
||||
// Why: make the migration one-time; otherwise every launch reparses and
|
||||
// re-prunes the same persisted idle rows.
|
||||
// Why: migration must be one-time, else every launch re-prunes the same persisted idle rows.
|
||||
const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
|
||||
expect(persisted.entries[PANE].payload.subagents).toBeUndefined()
|
||||
} finally {
|
||||
@@ -6467,9 +6543,7 @@ describe('Last-status persistence', () => {
|
||||
entries: {
|
||||
[TAB_A_PANE]: {
|
||||
paneKey: TAB_A_PANE,
|
||||
// Why: deliberately divergent — paneKey says tab-A, the entry
|
||||
// claims tab-B. Sanitizer must drop rather than hydrate this
|
||||
// inconsistent row.
|
||||
// Why: paneKey says tab-A but entry claims tab-B; sanitizer must drop the inconsistent row, not hydrate it.
|
||||
tabId: 'tab-B',
|
||||
receivedAt: recentTs(),
|
||||
stateStartedAt: recentTs(-1000),
|
||||
@@ -6529,14 +6603,10 @@ describe('Last-status persistence', () => {
|
||||
server.flushStatusPersistSync()
|
||||
const firstMtime = statSync(lastStatusPath()).mtimeMs
|
||||
|
||||
// Why: a no-op clearPaneState on a paneKey not in the cache is a
|
||||
// mutation site that should NOT trigger a redundant write. (clear was
|
||||
// designed to bail when nothing was evicted.)
|
||||
// Why: clearPaneState on a paneKey not in the cache must not trigger a redundant write (clear bails when nothing was evicted).
|
||||
server.clearPaneState(makePaneKey('non-existent', LEAF_5))
|
||||
server.flushStatusPersistSync()
|
||||
// Touch back to the same mtime would let the test pass spuriously, so
|
||||
// assert no rewrite happened by checking that mtime is unchanged after
|
||||
// a forced sync flush.
|
||||
// Assert no rewrite happened: mtime unchanged after a forced sync flush.
|
||||
const secondMtime = statSync(lastStatusPath()).mtimeMs
|
||||
expect(secondMtime).toBe(firstMtime)
|
||||
} finally {
|
||||
@@ -6559,8 +6629,7 @@ describe('Last-status persistence', () => {
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
// Why: file written even though we never explicitly flushed before stop —
|
||||
// stop() must synchronously drain the pending trailing-debounced timer.
|
||||
// Why: stop() must synchronously drain the pending trailing-debounced timer even though we never explicitly flushed.
|
||||
expect(existsSync(lastStatusPath())).toBe(true)
|
||||
const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
|
||||
expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me')
|
||||
@@ -6967,9 +7036,7 @@ describe('AgentHookServer ingestRemote', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
// Why: bypass parseAgentStatusPayload (which itself rejects bad states) by
|
||||
// constructing an obviously-invalid payload — `ingestRemote` is the trust
|
||||
// boundary we're testing, not the parser.
|
||||
// Why: bypass parseAgentStatusPayload with an invalid payload — ingestRemote is the trust boundary under test, not the parser.
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
@@ -7134,10 +7201,7 @@ describe('AgentHookServer ingestRemote', () => {
|
||||
})
|
||||
|
||||
it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => {
|
||||
// Why: the relay normally normalizes the payload on the wire, but a buggy
|
||||
// or malicious relay could forward an over-cap field. ingestRemote must
|
||||
// re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH
|
||||
// cap (200 chars) is enforced at the trust boundary.
|
||||
// Why: a buggy/malicious relay could forward an over-cap field, so ingestRemote re-runs the normalizer to enforce the AGENT_STATUS_MAX_FIELD_LENGTH cap at the trust boundary.
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
|
||||
+157
-265
@@ -1,15 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */
|
||||
// Why: this module is the Orca-main-process adapter for the shared
|
||||
// agent-hook listener pipeline (`src/shared/agent-hook-listener.ts`). The
|
||||
// listener internals (request parsing, payload normalization, endpoint-file
|
||||
// writing, validation) live in `shared/` so the relay can host the same
|
||||
// pipeline on the remote without dragging Electron in. This file owns:
|
||||
// - the loopback HTTP socket + bearer-token auth
|
||||
// - the IPC fanout (setListener / lastStatusByPaneKey replay)
|
||||
// - the `ingestRemote` entry point that bypasses HTTP for relay-forwarded
|
||||
// events (see docs/design/agent-status-over-ssh.md §5)
|
||||
// - the on-disk last-status cache (`last-status.json`) that survives
|
||||
// Orca restart so retained dashboard rows reappear on relaunch
|
||||
// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron. See docs/design/agent-status-over-ssh.md §5.
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
||||
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
@@ -44,6 +34,7 @@ import {
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusClearIpcPayload,
|
||||
type AgentStatusIpcPayload,
|
||||
type AgentType,
|
||||
type AgentStatusState,
|
||||
@@ -73,14 +64,7 @@ import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn
|
||||
|
||||
export type { AgentHookSource }
|
||||
|
||||
// Why: server-process-only enrichment of the shared event payload. The shared
|
||||
// listener emits `AgentHookEventPayload` (the bare event shape). For
|
||||
// persistence and the dashboard's "did the agent transition since I last
|
||||
// looked?" comparison, we attach `receivedAt` (when the latest event arrived
|
||||
// for this pane) and `stateStartedAt` (when the current state first appeared).
|
||||
// Stored in `state.lastStatusByPaneKey` via assignability — `AgentHookEventPayload`
|
||||
// is the declared map value, and the extra fields ride along untouched because
|
||||
// the shared module only writes/clears, never reads.
|
||||
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
|
||||
type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
||||
receivedAt: number
|
||||
stateStartedAt: number
|
||||
@@ -93,7 +77,7 @@ export type AgentHookStatusChangeEntry = {
|
||||
}
|
||||
|
||||
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
|
||||
type PaneStatusClearListener = (paneKey: string) => void
|
||||
type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void
|
||||
type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void
|
||||
type PaneKeyAliasEntry = {
|
||||
stablePaneKey: string
|
||||
@@ -102,42 +86,24 @@ type PaneKeyAliasEntry = {
|
||||
authorityVerified: boolean
|
||||
}
|
||||
|
||||
// Why: name of the on-disk cache that survives Orca restart. Lives next to
|
||||
// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross-
|
||||
// restart artifacts stay co-located.
|
||||
// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together.
|
||||
const LAST_STATUS_FILE_NAME = 'last-status.json'
|
||||
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
|
||||
const ASSISTANT_MESSAGE_RETRY_MS = 50
|
||||
const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
|
||||
|
||||
// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch
|
||||
// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a
|
||||
// developer who upgrades from an in-flight branch sees an empty hydration
|
||||
// once instead of partially-typed legacy entries. New file format; never
|
||||
// shipped to users at v1. A mismatched version is treated as a corrupt file
|
||||
// (silent empty hydration).
|
||||
// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt).
|
||||
const LAST_STATUS_FILE_VERSION = 2
|
||||
|
||||
// Why: trailing-edge debounce so a burst of hook events from a multi-agent
|
||||
// run produces one disk write instead of N. The latency budget matches other
|
||||
// hook-server batching; quit-time uses flushStatusPersistSync() for the
|
||||
// guaranteed final flush.
|
||||
// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush.
|
||||
const STATUS_PERSIST_DEBOUNCE_MS = 250
|
||||
const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure'])
|
||||
const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
|
||||
|
||||
// Why: bound the on-disk file's growth across many sessions. PTY-teardown
|
||||
// eviction handles closed panes, but daemon-restored PTYs that never re-attach
|
||||
// and crash-recovery paths where teardown never fires can leave entries
|
||||
// pinned forever. 7 days matches the user-visible "still relevant?" horizon —
|
||||
// older entries have almost certainly been resolved or abandoned and should
|
||||
// not resurrect on hydrate.
|
||||
// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate.
|
||||
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// Why: closed-tab suppression only needs to cover recently closed tabs — a
|
||||
// status event for a long-closed tab cannot arrive once its process/hooks are
|
||||
// gone. Bound the set so it can't grow one entry per tab close for the whole
|
||||
// session (it is otherwise only cleared at app quit).
|
||||
// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session.
|
||||
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
|
||||
export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024
|
||||
export const PANE_KEY_ALIASES_MAX = 1024
|
||||
@@ -175,8 +141,7 @@ function equivalentInterruptAgentType(
|
||||
return normalizedActual === normalizedBaseline
|
||||
}
|
||||
|
||||
// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix
|
||||
// at write/hydrate time so legacy numeric rows fail closed.
|
||||
// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed.
|
||||
export function isValidPaneKey(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null
|
||||
@@ -193,16 +158,14 @@ function dropHydratedIdleClaudeSubagents(
|
||||
return payload
|
||||
}
|
||||
const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working')
|
||||
// Why: older builds persisted finished Claude children as idle rows. Prune
|
||||
// them from the replay payload itself so restart cannot resurrect the pile.
|
||||
// Why: older builds persisted finished Claude children as idle rows; prune them so restart can't resurrect the pile.
|
||||
return {
|
||||
...payload,
|
||||
subagents: workingSubagents.length > 0 ? workingSubagents : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the sole gate deciding whether a providerSessionOnly row is trustworthy
|
||||
// enough to keep. Shared so the hydrate and relay-ingest paths can't drift.
|
||||
// Why: the sole gate for keeping a providerSessionOnly row; shared so hydrate and relay-ingest can't drift.
|
||||
function isValidPiProviderSessionOnly(
|
||||
providerSession: AgentProviderSessionMetadata | undefined,
|
||||
agentType: AgentType | undefined
|
||||
@@ -229,9 +192,7 @@ function sanitizeHydratedEntry(
|
||||
if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) {
|
||||
return null
|
||||
}
|
||||
// Why: paneKey is `${tabId}:${leafUuid}`; a stored entry whose tabId field
|
||||
// diverges from the key's tab segment is corruption (renamer bug, manual
|
||||
// edit, future shape drift). Drop instead of hydrating an inconsistent row.
|
||||
// Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row.
|
||||
if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) {
|
||||
return null
|
||||
}
|
||||
@@ -251,8 +212,7 @@ function sanitizeHydratedEntry(
|
||||
) {
|
||||
return null
|
||||
}
|
||||
// Why: connectionId is allowed to be null (local) or string (relay). Any
|
||||
// other shape is rejected so the post-merge typed surface stays honest.
|
||||
// Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest.
|
||||
const connectionIdRaw = record.connectionId
|
||||
let connectionId: string | null
|
||||
if (connectionIdRaw === null || connectionIdRaw === undefined) {
|
||||
@@ -306,10 +266,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
|
||||
}
|
||||
}
|
||||
|
||||
// Why: OSC-only dedupe (ingestTerminalStatus). Deliberately omits `subagents`:
|
||||
// OSC payloads never carry them, and including the field would make every
|
||||
// hook-cached entry with child rows non-equivalent — the OSC ping would then
|
||||
// apply and wipe the roster. Do not reuse this for hook-path comparisons.
|
||||
// Why: OSC-only dedupe; omits `subagents` (OSC never carries them) so an OSC ping can't wipe the hook-cached roster. Don't reuse for hook comparisons.
|
||||
function equivalentParsedAgentStatusPayload(
|
||||
a: ParsedAgentStatusPayload,
|
||||
b: ParsedAgentStatusPayload
|
||||
@@ -344,8 +301,7 @@ function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boole
|
||||
if (next.payload.agentType !== 'claude') {
|
||||
return false
|
||||
}
|
||||
// Why: a same-prompt retry is another UserPromptSubmit, while late Claude
|
||||
// progress after Ctrl+C arrives as tool lifecycle work for the old turn.
|
||||
// Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work.
|
||||
return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName)
|
||||
}
|
||||
|
||||
@@ -377,10 +333,7 @@ function shouldKeepClaudePermissionVisible(
|
||||
if (isClaudePermissionResumingApprovedTool(previous, next)) {
|
||||
return false
|
||||
}
|
||||
// Why: only real permission requests stay sticky across concurrent subagent
|
||||
// activity; interactive questions clear on the next working hook. Newer
|
||||
// Claude reports the AskUserQuestion wait AS a PermissionRequest, so the
|
||||
// tool name — not the event name — decides which rule applies.
|
||||
// Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides.
|
||||
if (isAskUserQuestionTool(previous.payload.toolName)) {
|
||||
return false
|
||||
}
|
||||
@@ -426,10 +379,7 @@ function isClaudePermissionResumingApprovedTool(
|
||||
(next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') &&
|
||||
nextToolUseId !== undefined &&
|
||||
!hasConflictingToolUseId &&
|
||||
// Why: subagents can share `agent_type`; a concrete agent id is the
|
||||
// strongest available signal that the permission owner resumed execution.
|
||||
// Claude's approval path omits identity but preserves the original
|
||||
// tool_use_id on PostToolUse, so that exact id is also a safe clear signal.
|
||||
// Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal.
|
||||
(hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) &&
|
||||
sameToolName &&
|
||||
(sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId)
|
||||
@@ -483,8 +433,7 @@ function attachClaudePermissionToolUseId(
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
// Why: Claude emits PermissionRequest without tool_use_id, then reports the
|
||||
// approved command as PostToolUse with the original PreToolUse id.
|
||||
// Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id.
|
||||
toolUseId: inheritedToolUseId
|
||||
}
|
||||
}
|
||||
@@ -493,44 +442,32 @@ export class AgentHookServer {
|
||||
private server: ReturnType<typeof createServer> | null = null
|
||||
private port = 0
|
||||
private token = ''
|
||||
// Why: identifies this Orca instance so hook scripts can stamp requests and
|
||||
// the server can detect dev vs. prod cross-talk. Set at start() from the
|
||||
// caller's knowledge of whether this is a packaged build.
|
||||
// Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge.
|
||||
private env = 'production'
|
||||
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
|
||||
private onPaneStatusCleared: PaneStatusClearListener | null = null
|
||||
private statusChangeListeners = new Set<StatusChangeListener>()
|
||||
// Why: directory that holds the on-disk endpoint file. Set via start()'s
|
||||
// `userDataPath` option so the class has no direct Electron dependency
|
||||
// (keeps it mockable in the vitest node environment).
|
||||
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
|
||||
private endpointDir: string | null = null
|
||||
private endpointFilePathCache: string | null = null
|
||||
private endpointFileWritten = false
|
||||
// Why: per-instance caches (warn-once Sets, lastPrompt/lastTool/lastStatus
|
||||
// by paneKey). Held on the instance instead of as module-level Maps so
|
||||
// tests can spin up multiple servers without state cross-contamination.
|
||||
// Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
|
||||
private state: HookListenerState = createHookListenerState()
|
||||
// Why: hydrated last-status rows are useful UI continuity, but they are not
|
||||
// evidence of live agent work in this main-process runtime.
|
||||
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
|
||||
private runtimeObservedStatusPaneKeys = new Set<string>()
|
||||
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
|
||||
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
|
||||
// Why: full path to the on-disk last-status cache. Set in start() from
|
||||
// userDataPath. Null when the server runs without a userDataPath (e.g.
|
||||
// tests that skip the userDataPath option) — in that case, persistence is
|
||||
// a no-op and only in-memory replay applies.
|
||||
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
|
||||
private lastStatusFilePath: string | null = null
|
||||
// Why: trailing-edge debounce timer. Captured per-instance so multiple
|
||||
// server instances in the same process (tests) don't share state.
|
||||
// Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state.
|
||||
private statusPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
|
||||
private promptSentHashSalt = randomBytes(16).toString('hex')
|
||||
private closedAgentStatusTabIds = new Set<string>()
|
||||
private closedAgentStatusPaneKeys = new Set<string>()
|
||||
// Why: identity check — skip writes when the JSON-stringified contents
|
||||
// exactly match the last successful disk write. Cheap protection against
|
||||
// re-firing trailing timers when nothing changed.
|
||||
private connectionTimestampWatermarkById = new Map<string, number>()
|
||||
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
|
||||
private lastWrittenJson: string | null = null
|
||||
|
||||
setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void {
|
||||
@@ -538,15 +475,10 @@ export class AgentHookServer {
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
// Why: replay is best-effort per pane so one throwing listener call can't
|
||||
// starve subsequent panes from being replayed.
|
||||
// Why: replay is best-effort per pane so one throwing listener can't starve the rest.
|
||||
for (const payload of this.state.lastStatusByPaneKey.values()) {
|
||||
try {
|
||||
// Why: cache values are stored as enriched payloads (with receivedAt /
|
||||
// stateStartedAt). The map's declared element type from the shared
|
||||
// listener is the bare AgentHookEventPayload because the shared module
|
||||
// never reads from this map; only this class does, and only enriched
|
||||
// values are ever inserted.
|
||||
// Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it.
|
||||
listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true })
|
||||
} catch (err) {
|
||||
console.error('[agent-hooks] replay listener threw', err)
|
||||
@@ -565,10 +497,8 @@ export class AgentHookServer {
|
||||
this.onPaneStatusCleared = listener
|
||||
}
|
||||
|
||||
/** Snapshot of the current cached statuses, in the IPC-shaped form the
|
||||
* renderer consumes. Used by the `agentStatus:getSnapshot` IPC after
|
||||
* workspace tabs have hydrated, so the dashboard catches up on any
|
||||
* hook events that fired during startup. */
|
||||
/** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the
|
||||
* dashboard catches up on hook events that fired during startup. */
|
||||
getStatusSnapshot(): AgentStatusIpcPayload[] {
|
||||
return Array.from(this.state.lastStatusByPaneKey.values(), (entry) =>
|
||||
toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)
|
||||
@@ -593,14 +523,11 @@ export class AgentHookServer {
|
||||
}
|
||||
const payload = existing.payload
|
||||
const agentType: AgentType | undefined = payload.agentType
|
||||
// Why: Droid's Ctrl+C does not interrupt the current turn; repeated Ctrl+C
|
||||
// exits the CLI, which is handled by process/PTY lifecycle cleanup.
|
||||
// Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn.
|
||||
if (agentType === 'droid' && request.intent === 'ctrl-c') {
|
||||
return false
|
||||
}
|
||||
// Why: these agents use the first Escape as a TUI/editor cancel. A single
|
||||
// Escape can leave the turn running, so only a deliberate double Escape
|
||||
// may infer an interrupted turn.
|
||||
// Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt.
|
||||
if (
|
||||
(agentType === 'opencode' || agentType === 'copilot') &&
|
||||
request.intent === 'plain-escape' &&
|
||||
@@ -608,9 +535,7 @@ export class AgentHookServer {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: input-intent inference is a fallback for a missing final hook. A strict
|
||||
// baseline match keeps a delayed timer from overwriting any newer hook,
|
||||
// including same-millisecond prompt or agent identity changes.
|
||||
// Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook.
|
||||
if (
|
||||
payload.state !== 'working' ||
|
||||
!equivalentInterruptAgentType(agentType, request.baselineAgentType) ||
|
||||
@@ -621,17 +546,12 @@ export class AgentHookServer {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: a 'working' pane can be child-driven (lead already idle, background
|
||||
// subagent running). Ctrl+C at the TUI does not stop background children,
|
||||
// so inferring a terminal done here would wrongly retire live child rows;
|
||||
// their own hook events keep the row truthful instead.
|
||||
// Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows.
|
||||
if (payload.subagents?.some((subagent) => subagent.state === 'working')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: keep the listener's Claude lead-turn record in sync — a later
|
||||
// child lifecycle event would otherwise re-emit the stale pre-interrupt
|
||||
// 'working' lead state and resurrect the cancelled pane.
|
||||
// Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane.
|
||||
if (agentType === 'claude') {
|
||||
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
|
||||
}
|
||||
@@ -646,8 +566,7 @@ export class AgentHookServer {
|
||||
prompt: payload.prompt,
|
||||
agentType,
|
||||
interrupted: true,
|
||||
// Why: idle children are display state; dropping them on an inferred
|
||||
// interrupt would blank the child rows a later hook would restore.
|
||||
// Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore.
|
||||
...(payload.subagents ? { subagents: payload.subagents } : {})
|
||||
}
|
||||
})
|
||||
@@ -659,11 +578,8 @@ export class AgentHookServer {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Guarded fallback for a hook Claude never sends: answering AskUserQuestion
|
||||
* produces no event, so the amber wait would otherwise linger until the
|
||||
* agent's next tool or turn end. The renderer reports the submit keystroke;
|
||||
* this re-validates its baseline against the cached status (a racing real
|
||||
* hook wins) and synthesizes the post-answer state. */
|
||||
/** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the
|
||||
* renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */
|
||||
inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean {
|
||||
if (!isValidPaneKey(request.paneKey)) {
|
||||
return false
|
||||
@@ -675,12 +591,7 @@ export class AgentHookServer {
|
||||
return false
|
||||
}
|
||||
const payload = existing.payload
|
||||
// Why: only Claude's interactive question may clear on typed input. The
|
||||
// tool name is the discriminator, not the hook event — Claude versions
|
||||
// differ on whether the AskUserQuestion wait arrives as PreToolUse or
|
||||
// PermissionRequest. Real permission waits (other tools) stay sticky until
|
||||
// the approved tool resumes — a denied or ignored permission must keep
|
||||
// demanding attention even though approving is also a keystroke.
|
||||
// Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky.
|
||||
if (
|
||||
payload.agentType !== 'claude' ||
|
||||
payload.state !== 'waiting' ||
|
||||
@@ -697,9 +608,7 @@ export class AgentHookServer {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: sync the listener's lead-turn record too — a later child lifecycle
|
||||
// event would otherwise re-emit the stale waiting state and resurrect the
|
||||
// dismissed question card.
|
||||
// Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card.
|
||||
const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey)
|
||||
const inferred = this.applyNormalizedStatus({
|
||||
paneKey: existing.paneKey,
|
||||
@@ -752,8 +661,7 @@ export class AgentHookServer {
|
||||
}
|
||||
|
||||
private markTabClosedForAgentStatus(tabId: string): void {
|
||||
// Delete-then-add keeps recently closed tabs most-recent so eviction only
|
||||
// sheds the oldest ids, which can no longer receive status events.
|
||||
// Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids.
|
||||
this.closedAgentStatusTabIds.delete(tabId)
|
||||
this.closedAgentStatusTabIds.add(tabId)
|
||||
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
|
||||
@@ -879,8 +787,7 @@ export class AgentHookServer {
|
||||
promptInteractionKey
|
||||
})
|
||||
try {
|
||||
// Why: hooks prove the user submitted a turn, but do not know which UI
|
||||
// launched the terminal; keep attribution low-cardinality and conservative.
|
||||
// Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality.
|
||||
track('agent_prompt_sent', {
|
||||
agent_kind: agentKind,
|
||||
launch_source: 'unknown',
|
||||
@@ -896,10 +803,16 @@ export class AgentHookServer {
|
||||
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
const now = Date.now()
|
||||
const connectionClearWatermark = payload.connectionId
|
||||
? this.connectionTimestampWatermarkById.get(payload.connectionId)
|
||||
: undefined
|
||||
// Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear.
|
||||
const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1)
|
||||
if (payload.connectionId) {
|
||||
this.connectionTimestampWatermarkById.set(payload.connectionId, now)
|
||||
}
|
||||
if (payload.providerSessionOnly) {
|
||||
// Why: Pi session_start must replace stale turn state and survive snapshot
|
||||
// replay, but it must not emit prompt telemetry or a fabricated status.
|
||||
// Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status.
|
||||
const enriched = this.attachStatusTiming(payload, now)
|
||||
this.clearAssistantMessageRetry(enriched.paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
|
||||
@@ -943,8 +856,7 @@ export class AgentHookServer {
|
||||
if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) {
|
||||
return previous
|
||||
}
|
||||
// Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already
|
||||
// stopped the turn. Do not let that stale same-turn event resurrect the row.
|
||||
// Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row.
|
||||
if (
|
||||
previous?.payload.state === 'done' &&
|
||||
previous.payload.interrupted === true &&
|
||||
@@ -1013,8 +925,7 @@ export class AgentHookServer {
|
||||
if (!discoveryReady) {
|
||||
const discovery = preparePendingGrokResultDiscovery(source, body)
|
||||
if (discovery) {
|
||||
// Why: slug-group discovery can outlive the bounded transcript-flush
|
||||
// timers. Its completion must drive the first retry deterministically.
|
||||
// Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically.
|
||||
void discovery
|
||||
.then(() => {
|
||||
if (this.server) {
|
||||
@@ -1065,8 +976,7 @@ export class AgentHookServer {
|
||||
this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal)
|
||||
return
|
||||
}
|
||||
// Why: some agents POST Stop before their transcript/chat-history line is
|
||||
// flushed. Discovery is event-driven; subsequent content retries stay timed.
|
||||
// Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed.
|
||||
this.applyNormalizedStatus(normalized)
|
||||
}
|
||||
|
||||
@@ -1095,8 +1005,7 @@ export class AgentHookServer {
|
||||
|
||||
private boundPaneKeyAliases(): void {
|
||||
while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) {
|
||||
// Why: renderer-originated aliases are untrusted process-lifetime state;
|
||||
// insertion-order eviction bounds both memory and per-message cleanup.
|
||||
// Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup.
|
||||
const oldestKey = this.legacyPaneKeyAliases.keys().next().value
|
||||
if (!oldestKey) {
|
||||
break
|
||||
@@ -1140,8 +1049,7 @@ export class AgentHookServer {
|
||||
(ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId))
|
||||
)
|
||||
}
|
||||
// Why: hook status is renderer-originated evidence, not PTY ownership.
|
||||
// ID-less moves are safe only after a prior verified transfer minted an alias.
|
||||
// Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias.
|
||||
return alias?.authorityVerified === true
|
||||
}
|
||||
|
||||
@@ -1223,8 +1131,7 @@ export class AgentHookServer {
|
||||
this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe)
|
||||
}
|
||||
this.clearAssistantMessageRetry(previousOwnerPaneKey)
|
||||
// Why: the live process keeps posting the physical source key after detach;
|
||||
// persist one chain-safe mapping to whichever surface currently owns it.
|
||||
// Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner.
|
||||
this.legacyPaneKeyAliases.set(physicalPaneKey, {
|
||||
stablePaneKey: toPaneKey,
|
||||
ptyId: normalizedPtyId,
|
||||
@@ -1288,9 +1195,7 @@ export class AgentHookServer {
|
||||
clearedStatusPaneKeys.add(entry.stablePaneKey)
|
||||
}
|
||||
if (shouldClearStablePaneKey) {
|
||||
// Why: after hydrate, legacy rows are stored under the stable key. If
|
||||
// this PTY is later proven dead before ptyPaneKey is rebuilt, alias
|
||||
// cleanup is the only path that can evict that retained status.
|
||||
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
|
||||
clearPaneCacheState(this.state, entry.stablePaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
|
||||
@@ -1305,7 +1210,7 @@ export class AgentHookServer {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
for (const paneKey of clearedStatusPaneKeys) {
|
||||
this.onPaneStatusCleared?.(paneKey)
|
||||
this.onPaneStatusCleared?.({ paneKey })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1324,8 +1229,7 @@ export class AgentHookServer {
|
||||
if (!stablePaneKey) {
|
||||
return body
|
||||
}
|
||||
// Why: migrated and detached shells keep posting an immutable physical
|
||||
// pane key; normalize both pane and tab identity to the current owner.
|
||||
// Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner.
|
||||
return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId }
|
||||
}
|
||||
|
||||
@@ -1378,8 +1282,7 @@ export class AgentHookServer {
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: OSC terminal status is a runtime/model observation, not a hook
|
||||
// prompt boundary. Keep prompt-sent telemetry tied to native hooks.
|
||||
// Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
|
||||
this.applyNormalizedStatus({
|
||||
paneKey,
|
||||
tabId,
|
||||
@@ -1389,15 +1292,7 @@ export class AgentHookServer {
|
||||
})
|
||||
}
|
||||
|
||||
/** Ingest a payload that arrived over the relay JSON-RPC channel rather
|
||||
* than the local HTTP server. `connectionId` is the SshChannelMultiplexer
|
||||
* identity Orca holds (the wire envelope carries connectionId: null and
|
||||
* Orca stamps the real value here). The relay has already normalized the
|
||||
* payload via the shared listener module, but main is still the SSH trust
|
||||
* boundary: re-run the canonical status normalizer before caching or
|
||||
* persisting anything. The `env`/`version` fields are forwarded verbatim
|
||||
* from the agent CLI's POST body on the remote and validated here so the
|
||||
* warn-once diagnostics fire for real cross-build mismatches. */
|
||||
/** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
|
||||
ingestRemote(
|
||||
envelope: {
|
||||
paneKey: string
|
||||
@@ -1419,9 +1314,7 @@ export class AgentHookServer {
|
||||
},
|
||||
connectionId: string
|
||||
): void {
|
||||
// Why: signature says non-empty, but the wire crosses a trust boundary —
|
||||
// re-check at runtime (and trim) so a whitespace-only or empty
|
||||
// connectionId can't poison caches.
|
||||
// Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches.
|
||||
if (typeof connectionId !== 'string') {
|
||||
return
|
||||
}
|
||||
@@ -1432,9 +1325,7 @@ export class AgentHookServer {
|
||||
if (!envelope || typeof envelope.paneKey !== 'string') {
|
||||
return
|
||||
}
|
||||
// Why: match the listener's HTTP path — `normalizeHookPayload` trims and
|
||||
// length-caps paneKey before caching, so the cache key here must follow
|
||||
// the same rule or remote-vs-local events for the same pane would diverge.
|
||||
// Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge.
|
||||
const physicalPaneKey = envelope.paneKey.trim()
|
||||
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
|
||||
const parsedPaneKey = parsePaneKey(paneKey)
|
||||
@@ -1454,9 +1345,7 @@ export class AgentHookServer {
|
||||
if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') {
|
||||
return
|
||||
}
|
||||
// Why: mirror the HTTP path's `readStringField` behavior — trim and treat
|
||||
// empty-after-trim as undefined rather than letting a literal "" leak
|
||||
// into the event.
|
||||
// Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined.
|
||||
const reportedTabId =
|
||||
envelope.tabId !== undefined && envelope.tabId.trim().length > 0
|
||||
? envelope.tabId.trim()
|
||||
@@ -1498,12 +1387,7 @@ export class AgentHookServer {
|
||||
? envelope.toolAgentType.trim()
|
||||
: undefined
|
||||
const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined
|
||||
// Why: the relay is across a trust boundary; re-run the canonical
|
||||
// normalizer on the inner payload so prompt/agentType/toolName/toolInput
|
||||
// length caps, embedded-newline collapse, and the `interrupted`-only-on-
|
||||
// done invariant are enforced here too. Returns null on malformed input
|
||||
// (including invalid state), which subsumes the prior explicit state
|
||||
// check.
|
||||
// Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed).
|
||||
const normalizedPayload = normalizeAgentStatusPayload(envelope.payload)
|
||||
if (!normalizedPayload) {
|
||||
return
|
||||
@@ -1514,9 +1398,7 @@ export class AgentHookServer {
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: run the same warn-once diagnostics the HTTP path runs (cross-build
|
||||
// version mismatch, dev-vs-prod env mismatch). Use `this.env` as the
|
||||
// expected env so the messages match what the local server produces.
|
||||
// Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected.
|
||||
warnOnHookEnvOrVersionMismatch(this.state, {
|
||||
version: envelope.version,
|
||||
env: envelope.env,
|
||||
@@ -1555,9 +1437,7 @@ export class AgentHookServer {
|
||||
this.env = options.env
|
||||
}
|
||||
if (options?.userDataPath) {
|
||||
// Why: dev builds share one userData path, so callers can namespace the
|
||||
// endpoint file by dev instance while packaged builds keep the stable path
|
||||
// that lets long-lived PTYs reconnect after app restart.
|
||||
// Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect.
|
||||
this.endpointDir = options.endpointNamespace
|
||||
? join(options.userDataPath, 'agent-hooks', options.endpointNamespace)
|
||||
: join(options.userDataPath, 'agent-hooks')
|
||||
@@ -1567,10 +1447,7 @@ export class AgentHookServer {
|
||||
this.token = randomUUID()
|
||||
this.endpointFileWritten = false
|
||||
this.lastWrittenJson = null
|
||||
// Why: hydrate before binding the HTTP listener so any new hook POST
|
||||
// (which goes through state.lastStatusByPaneKey.set) runs against an
|
||||
// already-populated map. The renderer later pulls this map as a snapshot
|
||||
// after workspace tabs are hydrated.
|
||||
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
@@ -1587,9 +1464,7 @@ export class AgentHookServer {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: bound request time so a slow/stalled client cannot hold a socket
|
||||
// open indefinitely (slowloris-style). The hook endpoints are local and
|
||||
// should complete in well under a second.
|
||||
// Why: bound request time so a stalled client can't hold a socket open (slowloris).
|
||||
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
|
||||
req.destroy()
|
||||
})
|
||||
@@ -1615,17 +1490,14 @@ export class AgentHookServer {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch {
|
||||
// Why: agent hooks must fail open. The receiver returns success for
|
||||
// malformed payloads so a newer or broken hook never blocks the agent.
|
||||
// Why: fail open — return success on malformed payloads so a broken hook never blocks the agent.
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Why: swap the startup error handler on success so a later runtime
|
||||
// error (e.g. EADDRINUSE during rebind, socket errors) doesn't reject
|
||||
// an already-settled promise or crash the main process as unhandled.
|
||||
// Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event.
|
||||
const onStartupError = (err: Error): void => {
|
||||
this.server?.off('listening', onListening)
|
||||
reject(err)
|
||||
@@ -1648,11 +1520,7 @@ export class AgentHookServer {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Why: flush any pending debounced write to disk BEFORE we clear the
|
||||
// in-memory map. Quit-time state must be captured even if the trailing
|
||||
// timer was scheduled but had not yet fired; otherwise a multi-agent
|
||||
// run that ended its last hook event <250 ms before quit would lose
|
||||
// that final delta on relaunch.
|
||||
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
|
||||
this.flushStatusPersistSync()
|
||||
this.server?.close()
|
||||
this.server = null
|
||||
@@ -1665,9 +1533,7 @@ export class AgentHookServer {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.assistantMessageRetryTimers.clear()
|
||||
// Why: intentionally do NOT delete the endpoint file on stop(). A stale
|
||||
// file points at a dead port, which matches the fail-open policy. Unlink
|
||||
// would introduce a TOCTOU race vs. a concurrent Orca instance.
|
||||
// Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
|
||||
this.endpointDir = null
|
||||
this.endpointFilePathCache = null
|
||||
this.endpointFileWritten = false
|
||||
@@ -1677,34 +1543,73 @@ export class AgentHookServer {
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
this.connectionTimestampWatermarkById.clear()
|
||||
this.legacyPaneKeyAliases.clear()
|
||||
clearAllListenerCaches(this.state)
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
|
||||
/** Why: invoked from the renderer-driven agentStatus:drop IPC when a user
|
||||
* dismisses a still-active pane's status row. We must NOT wipe
|
||||
* lastPromptByPaneKey or lastToolByPaneKey here — the pane's agent may
|
||||
* still be alive, and the next hook event would otherwise arrive with an
|
||||
* empty prompt and missing tool snapshot until a fresh UserPromptSubmit
|
||||
* lands. clearPaneState (which wipes all three caches) is the right shape
|
||||
* only for PTY-teardown. */
|
||||
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
|
||||
dropStatusEntry(paneKey: string): void {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
if (!this.state.lastStatusByPaneKey.has(resolvedPaneKey)) {
|
||||
if (!this.deleteStatusEntry(paneKey)) {
|
||||
return
|
||||
}
|
||||
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey)
|
||||
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
if (existing?.payload.state === 'done') {
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
|
||||
/** Clear statuses proven to belong to one lost SSH transport. */
|
||||
clearStatusEntriesForConnection(connectionId: string): void {
|
||||
const normalizedConnectionId = connectionId.trim()
|
||||
if (normalizedConnectionId.length === 0) {
|
||||
return
|
||||
}
|
||||
const clearedAt = Math.max(
|
||||
Date.now(),
|
||||
(this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1
|
||||
)
|
||||
this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt)
|
||||
let statusChanged = false
|
||||
for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) {
|
||||
const entry = rawEntry as EnrichedAgentHookEventPayload
|
||||
// Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown.
|
||||
if (entry.connectionId !== normalizedConnectionId) {
|
||||
continue
|
||||
}
|
||||
if (this.deleteStatusEntry(paneKey)) {
|
||||
statusChanged = true
|
||||
}
|
||||
}
|
||||
if (statusChanged) {
|
||||
// Why: persist/notify once — one disconnect can own many panes.
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
// Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row.
|
||||
this.onPaneStatusCleared?.({
|
||||
transient: true,
|
||||
connectionId: normalizedConnectionId,
|
||||
clearedAt
|
||||
})
|
||||
}
|
||||
|
||||
private deleteStatusEntry(paneKey: string): EnrichedAgentHookEventPayload | null {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
if (!existing) {
|
||||
return null
|
||||
}
|
||||
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
if (existing.payload.state === 'done') {
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
dropStatusEntriesByTabPrefix(tabId: string): void {
|
||||
this.markTabClosedForAgentStatus(tabId)
|
||||
const paneKeysToClear = new Set<string>()
|
||||
@@ -1778,10 +1683,7 @@ export class AgentHookServer {
|
||||
|
||||
clearPaneState(paneKey: string): void {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
// Why: only schedule a write when we actually evicted a status entry —
|
||||
// dropping prompt/tool caches for a pane that never produced a hook
|
||||
// event does not change the on-disk file, and skipping the write avoids
|
||||
// re-stat'ing on every dead-pane teardown.
|
||||
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
|
||||
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
clearPaneCacheState(this.state, resolvedPaneKey)
|
||||
@@ -1802,7 +1704,7 @@ export class AgentHookServer {
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
this.onPaneStatusCleared?.(resolvedPaneKey)
|
||||
this.onPaneStatusCleared?.({ paneKey: resolvedPaneKey })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1817,9 +1719,7 @@ export class AgentHookServer {
|
||||
ORCA_AGENT_HOOK_ENV: this.env,
|
||||
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
|
||||
}
|
||||
// Why: managed hooks source this file at invocation time. Packaged builds
|
||||
// use a stable file for restart handoff; dev callers pass a per-instance
|
||||
// namespace so parallel `pnpm dev` runs do not steal each other's hooks.
|
||||
// Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks.
|
||||
if (this.endpointFileWritten && this.endpointFilePathCache) {
|
||||
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache
|
||||
}
|
||||
@@ -1853,17 +1753,13 @@ export class AgentHookServer {
|
||||
if (!this.lastStatusFilePath) {
|
||||
return
|
||||
}
|
||||
// Why: defensive — keeps hydrate idempotent against repeated start()
|
||||
// calls; production callers always have an empty map here, but a future
|
||||
// re-start path must not silently merge prior-session state.
|
||||
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
|
||||
this.state.lastStatusByPaneKey.clear()
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(this.lastStatusFilePath, 'utf8')
|
||||
} catch (err) {
|
||||
// Why: missing file is the common case (first launch).
|
||||
// Other errors (EACCES, etc.) degrade to empty hydration with a single
|
||||
// warn so the dashboard renders normally.
|
||||
// Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn.
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn('[agent-hooks] failed to read last-status file:', err)
|
||||
}
|
||||
@@ -1897,10 +1793,7 @@ export class AgentHookServer {
|
||||
let hydrated = 0
|
||||
let dropped = 0
|
||||
let prunedLegacyClaudeSubagents = 0
|
||||
// Why: bound disk growth — drop anything older than HYDRATE_MAX_AGE_MS so
|
||||
// entries from worktrees archived weeks ago do not pile up forever. Use
|
||||
// Date.now() once to keep the cutoff consistent across all entries this
|
||||
// tick.
|
||||
// Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
|
||||
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
|
||||
for (const [paneKey, rawEntry] of Object.entries(entries)) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
@@ -1917,8 +1810,15 @@ export class AgentHookServer {
|
||||
entry.payload = hydratedPayload
|
||||
}
|
||||
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
|
||||
// Why: preserve only working children across restart. Live activity
|
||||
// confirms them; a later complete inventory may reap stale seeds.
|
||||
if (entry.connectionId) {
|
||||
// Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state.
|
||||
const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId)
|
||||
this.connectionTimestampWatermarkById.set(
|
||||
entry.connectionId,
|
||||
Math.max(previousWatermark ?? -1, entry.receivedAt)
|
||||
)
|
||||
}
|
||||
// Why: seed only working children across restart; a later full inventory reaps stale ones.
|
||||
if (entry.payload.subagents) {
|
||||
seedClaudeSubagentRosterFromSnapshots(
|
||||
this.state,
|
||||
@@ -1937,14 +1837,10 @@ export class AgentHookServer {
|
||||
)
|
||||
}
|
||||
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
|
||||
// Why: persist load-time pruning once so legacy idle rows do not consume
|
||||
// parse/filter work again on every launch.
|
||||
// Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch.
|
||||
this.runStatusPersist()
|
||||
} else if (hydrated > 0) {
|
||||
// Why: prime from the raw on-disk bytes (not a re-serialization) so the
|
||||
// dedup is robust against future shape drift in serializeStatusFile.
|
||||
// Only prime when hydration was lossless — if entries were dropped
|
||||
// during sanitize, the in-memory map diverges from the on-disk bytes.
|
||||
// Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
|
||||
this.lastWrittenJson = raw
|
||||
}
|
||||
}
|
||||
@@ -1952,9 +1848,7 @@ export class AgentHookServer {
|
||||
private serializeStatusFile(): string {
|
||||
const entries: Record<string, EnrichedAgentHookEventPayload> = {}
|
||||
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
|
||||
// Why: defensive — never persist invalid keys even if they slipped
|
||||
// into the in-memory map somehow. Same invariant the hydrate path
|
||||
// enforces.
|
||||
// Why: never persist invalid keys (matches the hydrate-path invariant).
|
||||
if (!isValidPaneKey(paneKey)) {
|
||||
continue
|
||||
}
|
||||
@@ -1969,8 +1863,7 @@ export class AgentHookServer {
|
||||
if (!this.lastStatusFilePath) {
|
||||
return
|
||||
}
|
||||
// Why: each call resets the timer; the disk write fires
|
||||
// STATUS_PERSIST_DEBOUNCE_MS after the LAST event in the burst.
|
||||
// Why: reset the timer each call so the write fires only after the last event in a burst.
|
||||
if (this.statusPersistTimer) {
|
||||
clearTimeout(this.statusPersistTimer)
|
||||
}
|
||||
@@ -1978,9 +1871,7 @@ export class AgentHookServer {
|
||||
this.statusPersistTimer = null
|
||||
this.runStatusPersist()
|
||||
}, STATUS_PERSIST_DEBOUNCE_MS)
|
||||
// Why: don't keep the event loop alive just for a status flush — quit
|
||||
// already triggers flushStatusPersistSync(). On Node 12+ unref() is a
|
||||
// no-op when called on an already-unref'd timer.
|
||||
// Why: don't keep the event loop alive just for a status flush — quit already flushes sync.
|
||||
if (typeof this.statusPersistTimer.unref === 'function') {
|
||||
this.statusPersistTimer.unref()
|
||||
}
|
||||
@@ -2032,10 +1923,7 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only accessor for the per-instance listener state. The `_internals`
|
||||
* shim needs to reach this without exposing `state` on the public surface
|
||||
* to renderer/main callers. AGENTS.md disallows `as unknown as X` escapes,
|
||||
* so we expose a narrow getter rather than casting the private field. */
|
||||
/** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */
|
||||
_getStateForTests(): HookListenerState {
|
||||
return this.state
|
||||
}
|
||||
@@ -2043,14 +1931,17 @@ export class AgentHookServer {
|
||||
_resetPromptSentDedupeForTests(): void {
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
}
|
||||
|
||||
_resetConnectionTimestampWatermarksForTests(): void {
|
||||
this.connectionTimestampWatermarkById.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const agentHookServer = new AgentHookServer()
|
||||
|
||||
// Why: exported for test coverage of the per-agent field extractors.
|
||||
export const _internals = {
|
||||
// Why: bind the test-helper to the singleton's state so existing tests keep
|
||||
// exercising the same caches the live server uses.
|
||||
// Why: bind the test-helper to the singleton's state so tests exercise the live caches.
|
||||
normalizeHookPayload: (
|
||||
source: AgentHookSource,
|
||||
body: unknown,
|
||||
@@ -2061,5 +1952,6 @@ export const _internals = {
|
||||
resetCachesForTests: (): void => {
|
||||
clearAllListenerCaches(agentHookServer._getStateForTests())
|
||||
agentHookServer._resetPromptSentDedupeForTests()
|
||||
agentHookServer._resetConnectionTimestampWatermarksForTests()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import {
|
||||
appendFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
stat,
|
||||
utimes,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ensureSessionParseCacheLoaded,
|
||||
flushSessionParseCachePersistForTests,
|
||||
initSessionParseCachePersistence,
|
||||
resetSessionParseCachePersistenceForTests,
|
||||
scheduleSessionParseCachePersist
|
||||
} from './session-parse-cache-persistence'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import {
|
||||
createSessionParseStats,
|
||||
parseAgentSessionFileCached,
|
||||
resetSessionParseCacheForTests,
|
||||
seedSessionParseCache,
|
||||
type PersistedSessionParseCacheEntry,
|
||||
type SessionParseStats
|
||||
} from './session-scanner-parse-cache'
|
||||
import { isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
|
||||
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
|
||||
|
||||
// Spy-wrap (real implementations still run) so the zero-disk-IO test can
|
||||
// assert the uninitialized module never touches the filesystem.
|
||||
vi.mock('node:fs/promises', { spy: true })
|
||||
|
||||
const APP_VERSION = '1.2.3-test'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
resetSessionParseCacheForTests()
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-parse-cache-persist-'))
|
||||
tempRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function claudeCandidate(path: string): Promise<SessionFileCandidate> {
|
||||
const fileStat = await stat(path)
|
||||
const file: FileWithMtime = {
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
sizeBytes: fileStat.size
|
||||
}
|
||||
return { agent: 'claude', file, codexHome: null }
|
||||
}
|
||||
|
||||
function userRecord(index: number, text: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||
timestamp: new Date(1740000000000 + index * 60_000).toISOString(),
|
||||
cwd: '/repo/app',
|
||||
gitBranch: 'main',
|
||||
message: { role: 'user', content: text }
|
||||
})
|
||||
}
|
||||
|
||||
function assistantRecord(index: number, text: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||
timestamp: new Date(1740000000000 + index * 60_000).toISOString(),
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'claude-fable-5',
|
||||
content: [{ type: 'text', text }],
|
||||
usage: { input_tokens: 100, output_tokens: 40 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function writeTranscript(root: string): Promise<string> {
|
||||
const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl')
|
||||
await writeFile(path, `${userRecord(0, 'first question')}\n${assistantRecord(1, 'answer')}\n`)
|
||||
return path
|
||||
}
|
||||
|
||||
async function parseAndPersist(path: string): Promise<SessionParseStats> {
|
||||
const stats = createSessionParseStats()
|
||||
await ensureSessionParseCacheLoaded()
|
||||
await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats)
|
||||
scheduleSessionParseCachePersist(stats)
|
||||
await flushSessionParseCachePersistForTests()
|
||||
return stats
|
||||
}
|
||||
|
||||
// Clear the in-memory cache and the persistence module's memoized state, then
|
||||
// re-enable persistence against the same file — a fresh launch, same profile.
|
||||
function simulateRestart(cacheFile: string, appVersion = APP_VERSION): void {
|
||||
resetSessionParseCacheForTests()
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion })
|
||||
}
|
||||
|
||||
async function coldParseStats(path: string): Promise<SessionParseStats> {
|
||||
const stats = createSessionParseStats()
|
||||
await ensureSessionParseCacheLoaded()
|
||||
await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats)
|
||||
return stats
|
||||
}
|
||||
|
||||
describe('session parse cache persistence', () => {
|
||||
it('round-trips: a persisted entry is a reused hit after a restart, without reading the transcript', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'vault-state', 'session-parse-cache.json')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
|
||||
const transcript = await writeTranscript(root)
|
||||
const candidate = await claudeCandidate(transcript)
|
||||
const stats = createSessionParseStats()
|
||||
await ensureSessionParseCacheLoaded()
|
||||
const first = await parseAgentSessionFileCached(candidate, process.platform, stats)
|
||||
expect(first).not.toBeNull()
|
||||
expect(stats.fullParses).toBe(1)
|
||||
|
||||
scheduleSessionParseCachePersist(stats)
|
||||
await flushSessionParseCachePersistForTests()
|
||||
expect(existsSync(cacheFile)).toBe(true)
|
||||
|
||||
simulateRestart(cacheFile)
|
||||
await ensureSessionParseCacheLoaded()
|
||||
|
||||
// Deleting the transcript proves the hit needs no transcript read at all.
|
||||
await rm(transcript)
|
||||
const reusedStats = createSessionParseStats()
|
||||
const reused = await parseAgentSessionFileCached(candidate, process.platform, reusedStats)
|
||||
expect(reusedStats.reused).toBe(1)
|
||||
expect(reusedStats.fullParses).toBe(0)
|
||||
expect(reusedStats.incremental).toBe(0)
|
||||
expect(reusedStats.bytesRead).toBe(0)
|
||||
expect(reused).toEqual(first)
|
||||
})
|
||||
|
||||
it('ignores a corrupt cache file and scans cold', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'session-parse-cache.json')
|
||||
await writeFile(cacheFile, 'not json {{{')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
|
||||
const transcript = await writeTranscript(root)
|
||||
const stats = await coldParseStats(transcript)
|
||||
expect(stats.fullParses).toBe(1)
|
||||
expect(stats.reused).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores a cache file with a mismatched schemaVersion', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'session-parse-cache.json')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
const transcript = await writeTranscript(root)
|
||||
await parseAndPersist(transcript)
|
||||
|
||||
const persisted = JSON.parse(await readFile(cacheFile, 'utf-8'))
|
||||
persisted.schemaVersion = 999
|
||||
await writeFile(cacheFile, JSON.stringify(persisted))
|
||||
|
||||
simulateRestart(cacheFile)
|
||||
const stats = await coldParseStats(transcript)
|
||||
expect(stats.fullParses).toBe(1)
|
||||
expect(stats.reused).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores a cache file written by a different app version', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'session-parse-cache.json')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
const transcript = await writeTranscript(root)
|
||||
await parseAndPersist(transcript)
|
||||
|
||||
simulateRestart(cacheFile, '9.9.9-other')
|
||||
const stats = await coldParseStats(transcript)
|
||||
expect(stats.fullParses).toBe(1)
|
||||
expect(stats.reused).toBe(0)
|
||||
})
|
||||
|
||||
it('seeding never clobbers a live in-memory entry', async () => {
|
||||
const root = await makeTempDir()
|
||||
const transcript = await writeTranscript(root)
|
||||
const candidate = await claudeCandidate(transcript)
|
||||
const live = await parseAgentSessionFileCached(candidate, process.platform)
|
||||
expect(live).not.toBeNull()
|
||||
|
||||
// A stale persisted entry for the same path (session: null marker).
|
||||
seedSessionParseCache([
|
||||
[
|
||||
transcript,
|
||||
{
|
||||
mtimeMs: candidate.file.mtimeMs,
|
||||
sizeBytes: candidate.file.sizeBytes ?? null,
|
||||
platform: process.platform,
|
||||
session: null
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
const stats = createSessionParseStats()
|
||||
const after = await parseAgentSessionFileCached(candidate, process.platform, stats)
|
||||
expect(stats.reused).toBe(1)
|
||||
expect(after).toBe(live)
|
||||
})
|
||||
|
||||
it('falls through to a full parse when a seeded file changed while the app was closed', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'session-parse-cache.json')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
const transcript = await writeTranscript(root)
|
||||
await parseAndPersist(transcript)
|
||||
|
||||
simulateRestart(cacheFile)
|
||||
await ensureSessionParseCacheLoaded()
|
||||
|
||||
// Grown while "closed": seeded entries have no resume state, so this is a
|
||||
// full parse (not incremental) whose result matches a cold parse.
|
||||
await appendFile(transcript, `${userRecord(2, 'follow-up')}\n${assistantRecord(3, 'more')}\n`)
|
||||
const stats = createSessionParseStats()
|
||||
const reparsed = await parseAgentSessionFileCached(
|
||||
await claudeCandidate(transcript),
|
||||
process.platform,
|
||||
stats
|
||||
)
|
||||
expect(stats.fullParses).toBe(1)
|
||||
expect(stats.incremental).toBe(0)
|
||||
expect(stats.reused).toBe(0)
|
||||
expect(reparsed).toEqual(await parseClaudeSessionFile((await claudeCandidate(transcript)).file))
|
||||
expect(reparsed?.messageCount).toBe(4)
|
||||
})
|
||||
|
||||
it('performs zero disk IO when never initialized', async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
await ensureSessionParseCacheLoaded()
|
||||
scheduleSessionParseCachePersist({ reused: 0, incremental: 2, fullParses: 5, bytesRead: 10 })
|
||||
await flushSessionParseCachePersistForTests()
|
||||
|
||||
expect(fsPromises.readFile).not.toHaveBeenCalled()
|
||||
expect(fsPromises.writeFile).not.toHaveBeenCalled()
|
||||
expect(fsPromises.mkdir).not.toHaveBeenCalled()
|
||||
expect(fsPromises.rename).not.toHaveBeenCalled()
|
||||
expect(fsPromises.rm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses back-to-back schedules into one write', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'session-parse-cache.json')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
const transcript = await writeTranscript(root)
|
||||
const stats = await coldParseStats(transcript)
|
||||
|
||||
vi.clearAllMocks()
|
||||
scheduleSessionParseCachePersist(stats)
|
||||
scheduleSessionParseCachePersist(stats)
|
||||
await flushSessionParseCachePersistForTests()
|
||||
|
||||
expect(fsPromises.rename).toHaveBeenCalledTimes(1)
|
||||
expect(existsSync(cacheFile)).toBe(true)
|
||||
})
|
||||
|
||||
it('sweeps orphaned temp files from a prior crashed save on load', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'session-parse-cache.json')
|
||||
const orphan = join(root, 'session-parse-cache-12345-99.tmp')
|
||||
await writeFile(orphan, '{"half":"written')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
|
||||
await ensureSessionParseCacheLoaded()
|
||||
expect(existsSync(orphan)).toBe(false)
|
||||
})
|
||||
|
||||
it('scanAiVaultSessions seeds from the persisted cache and persists after parsing', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'session-parse-cache.json')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
const roots = isolatedScanRoots(root)
|
||||
const transcript = join(roots.claudeProjectsDir, 'project', 'scan-session.jsonl')
|
||||
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
|
||||
// Same-length markers so the rewrite below preserves sizeBytes exactly.
|
||||
await writeFile(
|
||||
transcript,
|
||||
`${userRecord(0, 'persisted-scan-marker-AAAA')}\n${assistantRecord(1, 'answer')}\n`
|
||||
)
|
||||
const pinnedMtime = new Date(1740000000000)
|
||||
await utimes(transcript, pinnedMtime, pinnedMtime)
|
||||
|
||||
const first = await scanAiVaultSessions(roots)
|
||||
expect(first.sessions).toHaveLength(1)
|
||||
expect(JSON.stringify(first.sessions[0])).toContain('persisted-scan-marker-AAAA')
|
||||
// The scan itself (not a manual schedule call) must have queued the save.
|
||||
await flushSessionParseCachePersistForTests()
|
||||
expect(existsSync(cacheFile)).toBe(true)
|
||||
|
||||
simulateRestart(cacheFile)
|
||||
// Rewrite with identical length and mtime: only a seeded cache hit can
|
||||
// still return the original marker; a cold re-parse would see BBBB.
|
||||
await writeFile(
|
||||
transcript,
|
||||
`${userRecord(0, 'persisted-scan-marker-BBBB')}\n${assistantRecord(1, 'answer')}\n`
|
||||
)
|
||||
await utimes(transcript, pinnedMtime, pinnedMtime)
|
||||
|
||||
const second = await scanAiVaultSessions(roots)
|
||||
expect(second.sessions).toHaveLength(1)
|
||||
expect(JSON.stringify(second.sessions[0])).toContain('persisted-scan-marker-AAAA')
|
||||
expect(second.sessions[0]).toEqual(first.sessions[0])
|
||||
})
|
||||
|
||||
it('an over-cap seed list keeps the newest tail of the snapshot order', async () => {
|
||||
const root = await makeTempDir()
|
||||
const transcript = await writeTranscript(root)
|
||||
const candidate = await claudeCandidate(transcript)
|
||||
// Snapshot order is oldest→newest, so a foreign over-cap file must keep
|
||||
// its newest (last) entries; the real transcript rides at the very end.
|
||||
const fakes: [string, PersistedSessionParseCacheEntry][] = Array.from(
|
||||
{ length: 4100 },
|
||||
(_, index): [string, PersistedSessionParseCacheEntry] => [
|
||||
`/nonexistent/fake-${index}.jsonl`,
|
||||
{ mtimeMs: index, sizeBytes: 1, platform: process.platform, session: null }
|
||||
]
|
||||
)
|
||||
seedSessionParseCache([
|
||||
...fakes,
|
||||
[
|
||||
transcript,
|
||||
{
|
||||
mtimeMs: candidate.file.mtimeMs,
|
||||
sizeBytes: candidate.file.sizeBytes ?? null,
|
||||
platform: process.platform,
|
||||
session: null
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
const stats = createSessionParseStats()
|
||||
await parseAgentSessionFileCached(candidate, process.platform, stats)
|
||||
expect(stats.reused).toBe(1)
|
||||
expect(stats.fullParses).toBe(0)
|
||||
})
|
||||
|
||||
it('a failing rename cleans up its temp file and keeps the previous snapshot usable', async () => {
|
||||
const root = await makeTempDir()
|
||||
const cacheFile = join(root, 'session-parse-cache.json')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
|
||||
const transcript = await writeTranscript(root)
|
||||
await parseAndPersist(transcript)
|
||||
const previousSnapshot = await readFile(cacheFile, 'utf-8')
|
||||
|
||||
// A second session parsed after the good save; its save's rename is
|
||||
// rejected (the Windows EPERM/EBUSY story: target held open elsewhere).
|
||||
const other = join(root, 'ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl')
|
||||
await writeFile(other, `${userRecord(2, 'second session')}\n${assistantRecord(3, 'reply')}\n`)
|
||||
const stats = createSessionParseStats()
|
||||
await parseAgentSessionFileCached(await claudeCandidate(other), process.platform, stats)
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
vi.mocked(fsPromises.rename).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EPERM: rename blocked'), { code: 'EPERM' })
|
||||
)
|
||||
scheduleSessionParseCachePersist(stats)
|
||||
await expect(flushSessionParseCachePersistForTests()).resolves.toBeUndefined()
|
||||
expect(debugSpy).toHaveBeenCalled()
|
||||
|
||||
// The temp file was written before the rename failed; it must not linger,
|
||||
// and the previous snapshot must be byte-identical (never torn).
|
||||
expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([])
|
||||
expect(await readFile(cacheFile, 'utf-8')).toBe(previousSnapshot)
|
||||
|
||||
// That intact previous snapshot still round-trips on the next launch.
|
||||
simulateRestart(cacheFile)
|
||||
await ensureSessionParseCacheLoaded()
|
||||
const reusedStats = createSessionParseStats()
|
||||
await parseAgentSessionFileCached(
|
||||
await claudeCandidate(transcript),
|
||||
process.platform,
|
||||
reusedStats
|
||||
)
|
||||
expect(reusedStats.reused).toBe(1)
|
||||
debugSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('swallows save failures and leaves scan results unaffected', async () => {
|
||||
const root = await makeTempDir()
|
||||
// A regular file where the cache directory should be makes mkdir fail on
|
||||
// every platform (no chmod tricks, which don't hold on Windows or as root).
|
||||
const blocker = join(root, 'blocker')
|
||||
await writeFile(blocker, 'a file, not a directory')
|
||||
initSessionParseCachePersistence({
|
||||
filePath: join(blocker, 'session-parse-cache.json'),
|
||||
appVersion: APP_VERSION
|
||||
})
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
|
||||
const transcript = await writeTranscript(root)
|
||||
const candidate = await claudeCandidate(transcript)
|
||||
const stats = createSessionParseStats()
|
||||
await ensureSessionParseCacheLoaded()
|
||||
const session = await parseAgentSessionFileCached(candidate, process.platform, stats)
|
||||
expect(session).not.toBeNull()
|
||||
|
||||
scheduleSessionParseCachePersist(stats)
|
||||
await expect(flushSessionParseCachePersistForTests()).resolves.toBeUndefined()
|
||||
expect(debugSpy).toHaveBeenCalled()
|
||||
|
||||
// The in-memory cache still serves hits and no partial files were left behind.
|
||||
const reusedStats = createSessionParseStats()
|
||||
expect(await parseAgentSessionFileCached(candidate, process.platform, reusedStats)).toBe(
|
||||
session
|
||||
)
|
||||
expect(reusedStats.reused).toBe(1)
|
||||
expect(await readdir(root)).toEqual(expect.arrayContaining(['blocker']))
|
||||
expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([])
|
||||
debugSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
// Persists the reusable portion of the AI Vault session parse cache to one
|
||||
// JSON file under userData so a fresh launch reuses prior parse work instead
|
||||
// of re-reading the whole transcript corpus (issue #9210: 6.7 GB / 109 s cold
|
||||
// scans). Disabled unless the composition root calls init; every failure mode
|
||||
// degrades to today's cold-scan behavior.
|
||||
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
seedSessionParseCache,
|
||||
snapshotSessionParseCacheForPersistence,
|
||||
type PersistedSessionParseCacheEntry,
|
||||
type SessionParseStats
|
||||
} from './session-scanner-parse-cache'
|
||||
|
||||
// Bump when the persisted entry layout changes; a mismatched file is discarded whole.
|
||||
const SCHEMA_VERSION = 1
|
||||
// Debounce so back-to-back scans (desktop IPC + runtime RPC) collapse into one write.
|
||||
const SAVE_DEBOUNCE_MS = 1_500
|
||||
// The payload contains transcript-derived preview text; keep it user-only
|
||||
// (mode bits are inert on Windows — the userData ACL grant is the boundary there).
|
||||
const PRIVATE_DIRECTORY_MODE = 0o700
|
||||
const PRIVATE_FILE_MODE = 0o600
|
||||
|
||||
type SessionParseCachePersistenceOptions = {
|
||||
filePath: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
let options: SessionParseCachePersistenceOptions | null = null
|
||||
let loadPromise: Promise<void> | null = null
|
||||
let saveTimer: NodeJS.Timeout | null = null
|
||||
let lastSave: Promise<void> = Promise.resolve()
|
||||
|
||||
/** Enable persistence. Called only from the composition root; every export is a no-op until then. */
|
||||
export function initSessionParseCachePersistence(next: SessionParseCachePersistenceOptions): void {
|
||||
options = next
|
||||
}
|
||||
|
||||
export function resetSessionParseCachePersistenceForTests(): void {
|
||||
options = null
|
||||
loadPromise = null
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
lastSave = Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the in-memory parse cache from disk. Memoized: concurrent scans at
|
||||
* startup all await the same load. Resolves immediately when uninitialized.
|
||||
*/
|
||||
export function ensureSessionParseCacheLoaded(): Promise<void> {
|
||||
if (options === null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
loadPromise ??= loadPersistedEntries(options)
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced snapshot write after a scan that parsed something.
|
||||
* Reused-only scans schedule no write (the file already reflects the cache).
|
||||
*/
|
||||
export function scheduleSessionParseCachePersist(stats: SessionParseStats): void {
|
||||
if (options === null || stats.incremental + stats.fullParses <= 0) {
|
||||
return
|
||||
}
|
||||
const current = options
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null
|
||||
// Chained so a slow write and a rescheduled save can't rename out of order
|
||||
// (an older snapshot landing last); persistSnapshot never rejects.
|
||||
lastSave = lastSave.then(() => persistSnapshot(current))
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
// Why: a pending cache save must not keep a quitting process alive.
|
||||
if (typeof saveTimer.unref === 'function') {
|
||||
saveTimer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
/** Run any pending debounced save immediately and wait for it. Test-only. */
|
||||
export async function flushSessionParseCachePersistForTests(): Promise<void> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
if (options !== null) {
|
||||
const current = options
|
||||
lastSave = lastSave.then(() => persistSnapshot(current))
|
||||
}
|
||||
}
|
||||
await lastSave
|
||||
}
|
||||
|
||||
async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise<void> {
|
||||
await sweepOrphanedTempFiles(current.filePath)
|
||||
try {
|
||||
const raw = await readFile(current.filePath, 'utf-8')
|
||||
const entries = parsePersistedFile(JSON.parse(raw), current.appVersion)
|
||||
if (entries) {
|
||||
seedSessionParseCache(entries)
|
||||
}
|
||||
} catch {
|
||||
// Why: a missing/corrupt/foreign cache file must never fail the scan;
|
||||
// worst case is exactly today's cold scan.
|
||||
}
|
||||
}
|
||||
|
||||
// A death between temp-write and rename orphans a uniquely named .tmp forever;
|
||||
// sweep once per launch so they can't accumulate. Racing another instance's
|
||||
// in-flight save at worst loses that save — the already-accepted rename trade.
|
||||
async function sweepOrphanedTempFiles(filePath: string): Promise<void> {
|
||||
const directory = dirname(filePath)
|
||||
try {
|
||||
const names = await readdir(directory)
|
||||
await Promise.all(
|
||||
names
|
||||
.filter((name) => name.startsWith('session-parse-cache-') && name.endsWith('.tmp'))
|
||||
.map((name) => rm(join(directory, name), { force: true }).catch(() => {}))
|
||||
)
|
||||
} catch {
|
||||
// Directory missing or unreadable — nothing to sweep.
|
||||
}
|
||||
}
|
||||
|
||||
function parsePersistedFile(
|
||||
parsed: unknown,
|
||||
appVersion: string
|
||||
): [string, PersistedSessionParseCacheEntry][] | null {
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return null
|
||||
}
|
||||
const file = parsed as Record<string, unknown>
|
||||
// Why: parser output shape/semantics may change between app versions, so a
|
||||
// cross-version file is discarded — one cold scan per update is the price.
|
||||
if (file.schemaVersion !== SCHEMA_VERSION || file.appVersion !== appVersion) {
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(file.entries)) {
|
||||
return null
|
||||
}
|
||||
const entries: [string, PersistedSessionParseCacheEntry][] = []
|
||||
for (const item of file.entries) {
|
||||
const entry = parsePersistedEntry(item)
|
||||
if (entry === null) {
|
||||
// One malformed entry means the file can't be trusted; discard it whole.
|
||||
return null
|
||||
}
|
||||
entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function parsePersistedEntry(item: unknown): [string, PersistedSessionParseCacheEntry] | null {
|
||||
if (!Array.isArray(item) || item.length !== 2) {
|
||||
return null
|
||||
}
|
||||
const [path, value] = item as [unknown, unknown]
|
||||
if (typeof path !== 'string' || typeof value !== 'object' || value === null) {
|
||||
return null
|
||||
}
|
||||
const entry = value as Record<string, unknown>
|
||||
if (typeof entry.mtimeMs !== 'number') {
|
||||
return null
|
||||
}
|
||||
if (entry.sizeBytes !== null && typeof entry.sizeBytes !== 'number') {
|
||||
return null
|
||||
}
|
||||
if (typeof entry.platform !== 'string') {
|
||||
return null
|
||||
}
|
||||
if (entry.session !== null && typeof entry.session !== 'object') {
|
||||
return null
|
||||
}
|
||||
return [
|
||||
path,
|
||||
{
|
||||
mtimeMs: entry.mtimeMs,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
platform: entry.platform as NodeJS.Platform,
|
||||
session: entry.session as PersistedSessionParseCacheEntry['session']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function persistSnapshot(current: SessionParseCachePersistenceOptions): Promise<void> {
|
||||
const directory = dirname(current.filePath)
|
||||
const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`)
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
appVersion: current.appVersion,
|
||||
entries: snapshotSessionParseCacheForPersistence()
|
||||
})
|
||||
await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE })
|
||||
await writeFile(tempPath, payload, { mode: PRIVATE_FILE_MODE })
|
||||
// Atomic on POSIX; on Windows a rename racing an open handle fails and is
|
||||
// caught below (save lost, never a torn file).
|
||||
await rename(tempPath, current.filePath)
|
||||
} catch (err) {
|
||||
// Why: the save runs from a timer — every error must be swallowed here or
|
||||
// it becomes an unhandled rejection. Worst case is the no-file case.
|
||||
await rm(tempPath, { force: true }).catch(() => {})
|
||||
console.debug('[ai-vault] session parse cache save failed', err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user