Merge branch 'main' of https://github.com/stablyai/orca into auto-e2e-tests-autofix-scheduled-ci-1h-run-28-20260831T0700

This commit is contained in:
Jinjing
2026-08-31 20:59:39 -07:00
567 changed files with 116476 additions and 103174 deletions
+3
View File
@@ -131,6 +131,9 @@ jobs:
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
- name: Enforce ts-nocheck ratchet
run: pnpm run check:ts-nocheck-ratchet
- name: Enforce runtime Electron-import ratchet
run: pnpm run check:runtime-electron-ratchet
+12 -3
View File
@@ -1344,6 +1344,7 @@ jobs:
# otherwise undecodable. The main bundle is platform-independent, so one
# leg publishes the maps for the whole release.
- name: Bundle main-process source maps
id: bundle-main-sourcemaps
if: matrix.platform == 'linux-x64'
shell: bash
env:
@@ -1351,9 +1352,17 @@ jobs:
run: |
set -euo pipefail
if [ -z "$(find out/main -name '*.js.map' -print -quit)" ]; then
echo "::error::No main-process source maps in out/main. Did build.sourcemap regress in electron.vite.config.ts?"
exit 1
# Older cut tags predate the hidden-source-map build setting. They
# are valid legacy releases, but have no map bundle to publish.
if grep -Eq "sourcemap:[[:space:]]*['\"]hidden['\"]" electron.vite.config.ts; then
echo "::error::No main-process source maps in out/main despite build.sourcemap='hidden'."
exit 1
fi
echo "has_maps=false" >>"$GITHUB_OUTPUT"
echo "::notice::Cut ref predates hidden main-process source maps; skipping map publication."
exit 0
fi
echo "has_maps=true" >>"$GITHUB_OUTPUT"
# Why: every entry in electron-builder's `files` is a negation, so
# app-builder prepends `**/*` and packs anything left in the workspace
# root into app.asar. Stage the bundle outside the checkout instead.
@@ -1361,7 +1370,7 @@ jobs:
ls -l "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip"
- name: Publish main-process source maps
if: matrix.platform == 'linux-x64'
if: matrix.platform == 'linux-x64' && steps.bundle-main-sourcemaps.outputs.has_maps == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
-4
View File
@@ -11,10 +11,6 @@ inline src/main/index.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.ts
+230
View File
@@ -0,0 +1,230 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
// Ratchet gate for the `@ts-nocheck` directive.
//
// TypeScript only honours `@ts-nocheck` in a comment before the first statement, and
// once present it disables type checking for the ENTIRE file. PR #17605 split a single
// 43,928-line class into ~172 modules whose linear mixin-inheritance chain cannot yet
// express forward references, so each carries a grandfathered `@ts-nocheck` header. This
// check freezes that set (the baseline) and fails CI when a NEW file adds the directive —
// the existing files are grandfathered; new ones must fix their types instead. The
// baseline may only shrink.
const BASELINE_PATH = 'config/ts-nocheck-baseline.txt'
// These two files legitimately contain the directive text as data (regex, fixtures),
// so scanning them would self-flag. The ratchet does not police itself.
const SELF_FILES = new Set([
'config/scripts/check-ts-nocheck-ratchet.mjs',
'config/scripts/check-ts-nocheck-ratchet.test.mjs'
])
// True if `@ts-nocheck` appears in a comment before the first statement, matching the
// TypeScript rule. Limitation: only the leading run of blank lines / line comments /
// block comments at the top of the file is scanned, so a directive-looking string deeper
// in a block comment that itself starts at the top is still checked — but anything after
// real code (or inside a string literal, which never opens the leading comment run) is not.
export function hasTsNoCheck(sourceText) {
let i = 0
const n = sourceText.length
while (i < n) {
const rest = sourceText.slice(i)
const blank = /^[ \t]*\r?\n/.exec(rest)
if (blank) {
i += blank[0].length
continue
}
if (rest.startsWith('//')) {
const end = sourceText.indexOf('\n', i)
const line = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end)
if (/^\/\/\s*@ts-nocheck\b/.test(line)) {
return true
}
i = end === -1 ? n : end + 1
continue
}
if (rest.startsWith('/*')) {
const end = sourceText.indexOf('*/', i + 2)
const block = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end + 2)
if (/^\/\*\s*@ts-nocheck\b/.test(block)) {
return true
}
i = end === -1 ? n : end + 2
continue
}
break
}
return false
}
export function parseBaseline(text) {
return new Set(
text
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
)
}
export function diffBaseline(current, baseline) {
const cur = new Set(current)
const base = baseline instanceof Set ? baseline : new Set(baseline)
const added = [...cur].filter((e) => !base.has(e)).sort()
const stale = [...base].filter((e) => !cur.has(e)).sort()
return { added, stale }
}
// Collect every currently tracked file that carries a `@ts-nocheck` header.
export function collectCurrentTsNoCheckFiles(root = process.cwd()) {
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mts', '*.cts'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024
})
.split('\n')
.filter(Boolean)
.filter((f) => !SELF_FILES.has(f))
const entries = []
for (const rel of tracked) {
let src
try {
src = fs.readFileSync(path.join(root, rel), 'utf8')
} catch {
continue
}
if (hasTsNoCheck(src)) {
entries.push(rel)
}
}
return entries.sort()
}
function printAddedFailure(added) {
for (const entry of added) {
console.error(`::error::New @ts-nocheck not allowed: ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ❌ ts-nocheck ratchet failed — a NEW file adds a @ts-nocheck directive. │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${added.length} file(s) newly add a \`@ts-nocheck\` header:`)
console.error('')
for (const entry of added) {
console.error(` • ${entry}`)
}
console.error('')
console.error(' `@ts-nocheck` disables ALL type checking for the whole file, not just one line.')
console.error(
' The grandfathered entries exist only because the split runtime mixin chain cannot'
)
console.error(' express forward references yet — that is not a general license to suppress.')
console.error('')
console.error(' ✅ Fix it: fix the types instead of suppressing the whole file.')
console.error('')
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
console.error('')
}
function printStaleFailure(stale) {
for (const entry of stale) {
console.error(`::error::Stale ts-nocheck baseline entry (prune it): ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ⚠️ ts-nocheck baseline is out of date — nice work removing a suppression! │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${stale.length} baseline entr(y/ies) no longer have a @ts-nocheck directive.`)
console.error(
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
)
console.error('')
for (const entry of stale) {
console.error(` • ${entry}`)
}
console.error('')
console.error(` ✅ Fix it (one command): pnpm check:ts-nocheck-ratchet --prune`)
console.error('')
}
export function main(root = process.cwd()) {
const baselineFile = path.join(root, BASELINE_PATH)
if (!fs.existsSync(baselineFile)) {
console.error(
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-ts-nocheck-ratchet.mjs --init`
)
return 1
}
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
const current = collectCurrentTsNoCheckFiles(root)
const { added, stale } = diffBaseline(current, baseline)
if (added.length > 0) {
printAddedFailure(added)
if (stale.length > 0) {
console.error(
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
)
printStaleFailure(stale)
}
return 1
}
if (stale.length > 0) {
printStaleFailure(stale)
return 1
}
console.log(
`ts-nocheck ratchet OK — ${current.length} grandfathered file(s), no new suppressions.`
)
return 0
}
function writeBaseline(root, entries) {
const header = [
'# Files currently allowed to carry a `@ts-nocheck` header.',
'# This is a RATCHET: the list may only SHRINK. These exist only because the split',
'# runtime mixin chain cannot express forward references yet — do NOT add entries to',
'# get CI green; fix the types instead.',
'# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)',
''
].join('\n')
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.cwd()
const arg = process.argv[2]
if (arg === '--init') {
// One-time bootstrap: capture the current @ts-nocheck set as the baseline.
const entries = collectCurrentTsNoCheckFiles(root)
writeBaseline(root, entries)
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
process.exit(0)
}
if (arg === '--prune') {
// Remove baseline entries whose @ts-nocheck is gone (shrink only; never adds).
const current = new Set(collectCurrentTsNoCheckFiles(root))
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
const kept = [...baseline].filter((e) => current.has(e)).sort()
const newlyAdded = [...current].filter((e) => !baseline.has(e))
writeBaseline(root, kept)
console.log(
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
)
if (newlyAdded.length > 0) {
console.error(
`::error::--prune does not add entries; ${newlyAdded.length} new suppression(s) remain — fix those files' types.`
)
process.exit(1)
}
process.exit(0)
}
process.exit(main(root))
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { diffBaseline, hasTsNoCheck, parseBaseline } from './check-ts-nocheck-ratchet.mjs'
describe('hasTsNoCheck', () => {
it('detects a line-comment form', () => {
expect(hasTsNoCheck('// @ts-nocheck\nexport const a = 1\n')).toBe(true)
})
it('detects a block-comment form', () => {
expect(hasTsNoCheck('/* @ts-nocheck */\nexport const a = 1\n')).toBe(true)
})
it('detects the no-space form', () => {
expect(hasTsNoCheck('//@ts-nocheck\nexport const a = 1\n')).toBe(true)
})
it('detects a directive with a -- Why reason', () => {
expect(
hasTsNoCheck(
'// @ts-nocheck -- Why: mechanically split, covered by AST tests.\nimport x from "y"\n'
)
).toBe(true)
})
it('allows blank lines and other leading comments before the directive', () => {
const src =
'\n// Copyright notice.\n\n/* another leading comment */\n// @ts-nocheck\nexport const a = 1\n'
expect(hasTsNoCheck(src)).toBe(true)
})
it('does not match once a statement has started', () => {
const src = 'export const a = 1\n// @ts-nocheck\n'
expect(hasTsNoCheck(src)).toBe(false)
})
it('does not match inside a string literal', () => {
const src = 'export const a = "// @ts-nocheck"\n'
expect(hasTsNoCheck(src)).toBe(false)
})
it('returns false for ordinary source', () => {
expect(hasTsNoCheck('export function f() {\n return 42\n}\n')).toBe(false)
})
})
describe('parseBaseline', () => {
it('drops comments and blank lines', () => {
const b = parseBaseline('# header\n\nsrc/a.ts\nsrc/b.ts\n')
expect(b).toEqual(new Set(['src/a.ts', 'src/b.ts']))
})
})
describe('diffBaseline', () => {
it('reports added and stale entries', () => {
const { added, stale } = diffBaseline(
['src/b.ts', 'src/c.ts'],
new Set(['src/a.ts', 'src/b.ts'])
)
expect(added).toEqual(['src/c.ts']) // new suppression
expect(stale).toEqual(['src/a.ts']) // suppression removed
})
it('is clean when current matches baseline', () => {
const { added, stale } = diffBaseline(['src/a.ts'], new Set(['src/a.ts']))
expect(added).toEqual([])
expect(stale).toEqual([])
})
})
+16
View File
@@ -0,0 +1,16 @@
/** Where the Electron executable sits inside a `dist` tree, relative to it. */
export function getElectronPlatformPath(targetPlatform) {
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
@@ -0,0 +1,210 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
const sourceScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
/** Matches the fake package version and the platform/arch runInstallScript installs for. */
export const sharedEntryName = '41.5.0-linux-x64'
export const sharedEntryNameFor = (version) => `${version}-linux-x64`
export function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
return projectDir
}
export function runInstallScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
ELECTRON_CACHE: undefined,
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
...extraEnv
}
})
}
export function writeFakeElectronPackage(
projectDir,
{ lazyRequireMarker = null, version = '41.5.0' } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(electronDir, { recursive: true })
writeFileSync(join(electronDir, 'package.json'), JSON.stringify({ name: 'electron', version }))
writeFileSync(join(electronDir, 'checksums.json'), '{}')
writeFileSync(
join(electronDir, 'index.js'),
`
const fs = require('node:fs')
const path = require('node:path')
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
const pathFile = path.join(__dirname, 'path.txt')
if (!fs.existsSync(pathFile)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
}
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
`
)
}
export function writeFakeElectronDist(
projectDir,
{ version = 'v41.5.0', executableContents = '', pathContents } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(join(electronDir, 'dist'), { recursive: true })
writeFileSync(join(electronDir, 'dist/version'), version)
writeFileSync(join(electronDir, 'dist/electron'), executableContents)
if (pathContents !== undefined) {
writeFileSync(join(electronDir, 'path.txt'), pathContents)
}
}
export function writeFakeElectronGet(
projectDir,
{
downloadNeverSettles = false,
downloadFailures = 0,
downloadErrorCode = 'ECONNRESET',
downloadHttpStatus = null
} = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
join(getDir, 'index.js'),
`
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
let downloadAttempt = 0
exports.downloadArtifact = async function downloadArtifact(details) {
downloadAttempt += 1
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
if (${JSON.stringify(downloadHttpStatus)} != null) {
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
throw error
}
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
throw Object.assign(new TypeError('fetch failed'), { cause })
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
return artifactPath
}
`
)
}
export function writeFakeExtractor(projectDir, { createExecutable, version = '41.5.0' }) {
writeFileSync(
join(projectDir, 'fake-extractor.cjs'),
`
const { appendFileSync, mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const extractDir = process.argv[3]
appendFileSync(join(__dirname, 'fake-extractor.log'), extractDir + '\\n')
mkdirSync(join(extractDir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(extractDir, 'electron'), '')
writeFileSync(join(extractDir, 'electron.exe'), '')
writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types')
writeFileSync(join(extractDir, 'version'), ${JSON.stringify(`v${version}`)})
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
}
}
`
)
}
export function writeTypeDefPublishFailurePreload(projectDir) {
const preloadPath = join(projectDir, 'type-def-publish-failure.cjs')
writeFileSync(
preloadPath,
`
const fs = require('node:fs')
const { syncBuiltinESMExports } = require('node:module')
const { basename, dirname } = require('node:path')
const renameSync = fs.renameSync
fs.renameSync = (source, target) => {
if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') {
const error = new Error('injected Electron type definition publish failure')
error.code = 'EACCES'
throw error
}
return renameSync(source, target)
}
syncBuiltinESMExports()
`
)
return preloadPath
}
export function initGitRepo(projectDir) {
runGit(projectDir, ['init', '--quiet', '--initial-branch=main'])
runGit(projectDir, ['config', 'user.email', 'orca-test@example.com'])
runGit(projectDir, ['config', 'user.name', 'Orca Test'])
runGit(projectDir, ['commit', '--quiet', '--allow-empty', '-m', 'init'])
}
export function addSiblingWorktree(projectDir, siblingDir) {
runGit(projectDir, ['worktree', 'add', '--quiet', '-b', 'sibling', siblingDir])
copyScriptWithLocalModules(sourceScriptPath, join(siblingDir, 'config', 'scripts'))
return siblingDir
}
function runGit(projectDir, args) {
execFileSync('git', ['-C', projectDir, ...args], { stdio: 'ignore' })
}
export function sharedCacheRoot(repoDir) {
return join(repoDir, '.git', 'orca-cache', 'electron')
}
export function readSharedDistMarker(projectDir) {
try {
return readFileSync(join(projectDir, 'node_modules/electron/.orca-shared-dist'), 'utf8')
} catch {
return null
}
}
export function readExtractorCallCount(projectDir) {
try {
return readFileSync(join(projectDir, 'fake-extractor.log'), 'utf8').trim().split('\n').length
} catch {
return 0
}
}
export function writeNonDarwinPlatformPreload(projectDir) {
const preloadPath = join(projectDir, 'non-darwin-platform.cjs')
writeFileSync(
preloadPath,
`
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
`
)
return preloadPath
}
@@ -15,6 +15,14 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { platform as osPlatform, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { getElectronPlatformPath } from './electron-platform-path.mjs'
import {
shareElectronDistFromCache,
hasAdoptedSharedElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry
} from './shared-electron-dist-cache.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
@@ -54,7 +62,18 @@ try {
async function main() {
repairElectronPathFile()
const sharedEntry = resolveSharedElectronDistEntry({
repoRoot: projectDir,
electronPackageDir,
version: electronVersion,
targetPlatform,
targetArch
})
if (electronPackageIsUsable()) {
if (sharedEntry !== null && !hasAdoptedSharedElectronDist(sharedEntry)) {
shareExistingElectronDist(sharedEntry)
}
return
}
@@ -62,7 +81,7 @@ async function main() {
// Node. Install only Electron's npm package binary here; do not run the full
// Electron native-module rebuild path, which would undo the Node ABI rebuild.
console.log('[electron-package] Electron package binary is missing; running Electron install.')
await installElectronPackageBinary()
await installElectronPackageBinary(sharedEntry)
repairElectronPathFile()
@@ -122,8 +141,11 @@ function repairElectronPathFile() {
}
}
async function installElectronPackageBinary() {
async function installElectronPackageBinary(sharedEntry) {
const electronDistDir = resolve(electronPackageDir, 'dist')
if (sharedEntry !== null && adoptSharedElectronDist(sharedEntry, electronDistDir)) {
return
}
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
const persistentCacheRoot =
process.env.ORCA_ELECTRON_PACKAGE_CACHE_ROOT || process.env.ELECTRON_CACHE || null
@@ -158,11 +180,73 @@ async function installElectronPackageBinary() {
}
moveExtractedElectronDist(extractDir, electronDistDir)
if (sharedEntry !== null) {
publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir)
}
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
}
/**
* Point this worktree's dist at the copy its siblings already share, so the ~295MB tree costs one
* allocation per repository instead of one per worktree.
*
* Staged inside node_modules/electron on purpose: clonefile only shares blocks within a volume, and
* staging elsewhere would silently downgrade the publish rename to a cross-device byte copy.
*/
function adoptSharedElectronDist(sharedEntry, electronDistDir) {
const stageRoot = mkdtempSync(resolve(electronPackageDir, '.dist-clone-'))
try {
const stagePath = join(stageRoot, 'dist')
if (
!shareElectronDistFromCache(sharedEntry, stagePath, {
version: electronVersion,
platformPath
})
) {
return false
}
moveExtractedElectronDist(stagePath, electronDistDir)
recordAdoptedSharedElectronDist(sharedEntry, writeFileSync)
console.log(
`[electron-package] Shared Electron ${electronVersion} from ${sharedEntry.entryPath}`
)
return true
} catch (error) {
// The download path below is always a correct fallback, so sharing never fails an install.
console.warn(`[electron-package] Shared Electron dist unavailable: ${formatShareError(error)}`)
return false
} finally {
rmSync(stageRoot, { recursive: true, force: true })
}
}
/** An already-installed dist joins the cache: clone from it if it exists, seed it otherwise. */
function shareExistingElectronDist(sharedEntry) {
const electronDistDir = resolve(electronPackageDir, 'dist')
if (!adoptSharedElectronDist(sharedEntry, electronDistDir)) {
publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir)
}
}
function publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir) {
const published = publishSharedElectronDist(electronDistDir, sharedEntry, {
version: electronVersion,
platformPath
})
if (published) {
console.log(
`[electron-package] Published Electron ${electronVersion} to ${sharedEntry.entryPath}`
)
recordAdoptedSharedElectronDist(sharedEntry, writeFileSync)
}
}
function formatShareError(error) {
return error instanceof Error ? error.message : String(error)
}
async function downloadElectronArtifactWithRetry(downloadOptions, { cacheRootIsPersistent }) {
const retryDelays = getDownloadRetryDelays()
@@ -438,19 +522,3 @@ function getElectronTargetPlatform() {
function getElectronTargetArch() {
return process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || process.arch
}
function getElectronPlatformPath(targetPlatform) {
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
@@ -1,22 +1,32 @@
import {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
import {
addSiblingWorktree,
initGitRepo,
mkTempProject,
readExtractorCallCount,
readSharedDistMarker,
runInstallScript,
sharedCacheRoot,
sharedEntryName,
sharedEntryNameFor,
writeFakeElectronDist,
writeFakeElectronGet,
writeFakeElectronPackage,
writeFakeExtractor,
writeNonDarwinPlatformPreload,
writeTypeDefPublishFailurePreload
} from './install-electron-package-binary-test-fixtures.mjs'
describe('install-electron-package-binary', () => {
it('installs Electron from an isolated cache and repairs path.txt', () => {
@@ -403,6 +413,199 @@ describe('install-electron-package-binary', () => {
}
})
// The shared cache is macOS-only: it exists to avoid a second copy via APFS clonefile.
it('publishes a shared Electron dist entry after a fresh download', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, { CI: '' })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
expect(result.status, result.stderr).toBe(0)
expect(lstatSync(entryPath).isDirectory()).toBe(true)
expect(lstatSync(entryPath).isSymbolicLink()).toBe(false)
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(existsSync(join(entryPath, 'electron'))).toBe(true)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(result.stdout).toMatch(/Published Electron 41\.5\.0 to .*41\.5\.0-linux-x64$/m)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('shares the Electron dist into a sibling worktree without downloading', () => {
const projectDir = mkTempProject()
const siblingDir = `${projectDir}-sibling`
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
expect(runInstallScript(projectDir, { CI: '' }).status).toBe(0)
addSiblingWorktree(projectDir, siblingDir)
writeFakeElectronPackage(siblingDir)
writeFakeElectronGet(siblingDir)
writeFakeExtractor(siblingDir, { createExecutable: true })
const result = runInstallScript(siblingDir, { CI: '' })
const siblingDistDir = join(siblingDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readExtractorCallCount(siblingDir)).toBe(0)
expect(existsSync(join(siblingDir, 'electron-get.log'))).toBe(false)
expect(lstatSync(siblingDistDir).isDirectory()).toBe(true)
expect(lstatSync(siblingDistDir).isSymbolicLink()).toBe(false)
expect(readFileSync(join(siblingDistDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(existsSync(join(siblingDistDir, 'electron'))).toBe(true)
expect(readFileSync(join(siblingDir, 'node_modules/electron/path.txt'), 'utf8')).toBe(
'electron'
)
expect(readSharedDistMarker(siblingDir)).toBe(sharedEntryName)
expect(result.stdout).toContain('Shared Electron 41.5.0 from')
} finally {
rmSync(siblingDir, { recursive: true, force: true })
rmSync(projectDir, { recursive: true, force: true })
}
})
it('publishes an already installed Electron dist that predates the shared cache', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
writeFakeElectronDist(projectDir, {
executableContents: 'existing executable',
pathContents: 'electron'
})
const result = runInstallScript(projectDir, { CI: '' })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readExtractorCallCount(projectDir)).toBe(0)
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(entryPath, 'electron'), 'utf8')).toBe('existing executable')
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(readFileSync(join(distDir, 'electron'), 'utf8')).toBe('existing executable')
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('replaces a corrupt shared Electron dist entry instead of re-downloading forever', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
mkdirSync(entryPath, { recursive: true })
writeFileSync(join(entryPath, 'version'), 'v40.0.0')
writeFileSync(join(entryPath, 'electron'), 'stale executable')
const result = runInstallScript(projectDir, { CI: '' })
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Failed to install Electron package binary')
expect(readExtractorCallCount(projectDir)).toBe(1)
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe(
'electron'
)
// Why not just fall back: an entry left corrupt makes every sibling worktree download again.
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(readdirSync(sharedCacheRoot(projectDir))).toEqual([sharedEntryName])
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('hardlinks the shared Electron dist on a host without copy-on-write', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const preloadPath = writeNonDarwinPlatformPreload(projectDir)
const nonDarwinEnv = {
CI: '',
NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${preloadPath}`]
.filter(Boolean)
.join(' ')
}
const result = runInstallScript(projectDir, nonDarwinEnv)
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
// Why read-only: these are the same inodes, so an extract over dist would otherwise rewrite
// the cache and every sibling worktree at once.
expect(statSync(join(entryPath, 'electron')).mode & 0o222).toBe(0)
expect(statSync(join(entryPath, 'electron')).ino).toBe(
statSync(join(distDir, 'electron')).ino
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('gives an Electron upgrade its own cache entry and leaves the old one for other branches', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
expect(runInstallScript(projectDir, { CI: '' }).status).toBe(0)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryNameFor('41.5.0'))
// Upgrade the pinned Electron, exactly as a branch bumping the dependency would.
writeFakeElectronPackage(projectDir, { version: '42.0.0' })
writeFakeExtractor(projectDir, { createExecutable: true, version: '42.0.0' })
const upgraded = runInstallScript(projectDir, { CI: '' })
const cacheRoot = sharedCacheRoot(projectDir)
expect(upgraded.status, upgraded.stderr).toBe(0)
expect(readFileSync(join(projectDir, 'node_modules/electron/dist/version'), 'utf8')).toBe(
'v42.0.0'
)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryNameFor('42.0.0'))
// Why the old entry stays: sibling worktrees on the previous branch still share it.
expect(readdirSync(cacheRoot).sort()).toEqual([
sharedEntryNameFor('41.5.0'),
sharedEntryNameFor('42.0.0')
])
expect(readFileSync(join(cacheRoot, sharedEntryNameFor('41.5.0'), 'version'), 'utf8')).toBe(
'v41.5.0'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not exit successfully when Electron download never settles', () => {
const projectDir = mkTempProject()
@@ -421,155 +624,3 @@ describe('install-electron-package-binary', () => {
}
})
})
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(
sourceScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
return projectDir
}
function runInstallScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
ELECTRON_CACHE: undefined,
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
...extraEnv
}
})
}
function writeFakeElectronPackage(projectDir, { lazyRequireMarker = null } = {}) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(electronDir, { recursive: true })
writeFileSync(
join(electronDir, 'package.json'),
JSON.stringify({ name: 'electron', version: '41.5.0' })
)
writeFileSync(join(electronDir, 'checksums.json'), '{}')
writeFileSync(
join(electronDir, 'index.js'),
`
const fs = require('node:fs')
const path = require('node:path')
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
const pathFile = path.join(__dirname, 'path.txt')
if (!fs.existsSync(pathFile)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
}
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
`
)
}
function writeFakeElectronDist(
projectDir,
{ version = 'v41.5.0', executableContents = '', pathContents } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(join(electronDir, 'dist'), { recursive: true })
writeFileSync(join(electronDir, 'dist/version'), version)
writeFileSync(join(electronDir, 'dist/electron'), executableContents)
if (pathContents !== undefined) {
writeFileSync(join(electronDir, 'path.txt'), pathContents)
}
}
function writeFakeElectronGet(
projectDir,
{
downloadNeverSettles = false,
downloadFailures = 0,
downloadErrorCode = 'ECONNRESET',
downloadHttpStatus = null
} = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
join(getDir, 'index.js'),
`
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
let downloadAttempt = 0
exports.downloadArtifact = async function downloadArtifact(details) {
downloadAttempt += 1
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
if (${JSON.stringify(downloadHttpStatus)} != null) {
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
throw error
}
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
throw Object.assign(new TypeError('fetch failed'), { cause })
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
return artifactPath
}
`
)
}
function writeFakeExtractor(projectDir, { createExecutable }) {
writeFileSync(
join(projectDir, 'fake-extractor.cjs'),
`
const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const extractDir = process.argv[3]
mkdirSync(join(extractDir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(extractDir, 'electron'), '')
writeFileSync(join(extractDir, 'electron.exe'), '')
writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types')
writeFileSync(join(extractDir, 'version'), 'v41.5.0')
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
}
}
`
)
}
function writeTypeDefPublishFailurePreload(projectDir) {
const preloadPath = join(projectDir, 'type-def-publish-failure.cjs')
writeFileSync(
preloadPath,
`
const fs = require('node:fs')
const { syncBuiltinESMExports } = require('node:module')
const { basename, dirname } = require('node:path')
const renameSync = fs.renameSync
fs.renameSync = (source, target) => {
if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') {
const error = new Error('injected Electron type definition publish failure')
error.code = 'EACCES'
throw error
}
return renameSync(source, target)
}
syncBuiltinESMExports()
`
)
return preloadPath
}
@@ -3,6 +3,7 @@ import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from '
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url))
const sourceInstallScriptPath = fileURLToPath(
@@ -19,10 +20,7 @@ export function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs'))
copyFileSync(
sourceInstallScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
copyScriptWithLocalModules(sourceInstallScriptPath, join(projectDir, 'config', 'scripts'))
copyFileSync(
sourceNodePtyJobOwnershipPath,
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
// Converts worktrees that already have their own Electron dist over to the shared cache.
// A normal install only shares when Electron is (re)installed, and an existing healthy worktree
// never reaches that path -- so without this, sharing only arrives at the next Electron upgrade.
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import {
hasAdoptedSharedElectronDist,
isUsableElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry,
shareElectronDistFromCache
} from './shared-electron-dist-cache.mjs'
import { getElectronPlatformPath } from './electron-platform-path.mjs'
const apply = process.argv.includes('--apply')
const repoRoot = process.argv.includes('--repo')
? path.resolve(process.argv[process.argv.indexOf('--repo') + 1])
: process.cwd()
function listWorktrees(root) {
const raw = execFileSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], {
encoding: 'utf8'
})
return raw
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => line.slice('worktree '.length).trim())
}
function measure(distPath) {
try {
return (
Number(execFileSync('du', ['-sk', distPath], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024
)
} catch {
return 0
}
}
/** Swap in a shared copy behind a rename, so an interrupted run never leaves a partial dist. */
function adoptInto(distPath, entry, identity) {
const stagePath = `${distPath}.reclaim-${process.pid}-${randomUUID()}`
if (!shareElectronDistFromCache(entry, stagePath, identity)) {
rmSync(stagePath, { recursive: true, force: true })
return false
}
const previousPath = `${distPath}.previous-${process.pid}-${randomUUID()}`
renameSync(distPath, previousPath)
try {
renameSync(stagePath, distPath)
} catch (error) {
renameSync(previousPath, distPath)
rmSync(stagePath, { recursive: true, force: true })
throw error
}
rmSync(previousPath, { recursive: true, force: true })
return true
}
let reclaimed = 0
let converted = 0
let skipped = 0
for (const worktree of listWorktrees(repoRoot)) {
const electronPackageDir = path.join(worktree, 'node_modules', 'electron')
const distPath = path.join(electronPackageDir, 'dist')
if (!existsSync(path.join(electronPackageDir, 'package.json')) || !existsSync(distPath)) {
continue
}
if (statSync(distPath, { throwIfNoEntry: false })?.isDirectory() !== true) {
continue
}
let version
try {
version = JSON.parse(
readFileSync(path.join(electronPackageDir, 'package.json'), 'utf8')
).version
} catch {
continue
}
const targetPlatform = process.platform
const targetArch = process.arch
let platformPath
try {
platformPath = getElectronPlatformPath(targetPlatform)
} catch {
continue
}
if (!isUsableElectronDist(distPath, version, platformPath)) {
console.log(`skip ${worktree} (dist is not a complete Electron ${version})`)
skipped += 1
continue
}
const entry = resolveSharedElectronDistEntry({
repoRoot: worktree,
electronPackageDir,
version,
targetPlatform,
targetArch
})
if (entry === null) {
continue
}
if (hasAdoptedSharedElectronDist(entry)) {
continue
}
const size = measure(distPath)
if (!apply) {
console.log(`would share ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB (${version})`)
reclaimed += size
converted += 1
continue
}
try {
if (!existsSync(entry.entryPath)) {
if (publishSharedElectronDist(distPath, entry, { version, platformPath })) {
recordAdoptedSharedElectronDist(entry, writeFileSync)
console.log(`seeded ${worktree} -> ${entry.entryPath}`)
converted += 1
}
continue
}
if (adoptInto(distPath, entry, { version, platformPath })) {
recordAdoptedSharedElectronDist(entry, writeFileSync)
reclaimed += size
converted += 1
console.log(`shared ${worktree} reclaimed ${(size / 1024 ** 3).toFixed(2)} GiB`)
}
} catch (error) {
// A worktree that fails is left exactly as it was; it still has its own working dist.
console.warn(`skip ${worktree} (${error instanceof Error ? error.message : String(error)})`)
skipped += 1
}
}
console.log(
`\n${apply ? 'Shared' : 'Would share'} ${converted} worktree(s); ` +
`${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` +
`${skipped > 0 ? `; skipped ${skipped}` : ''}` +
`${apply ? '' : '\nRe-run with --apply to do it.'}`
)
@@ -41,14 +41,24 @@ describe('release-cut source map publication', () => {
expect(publish.with.command).toContain('runner.temp')
})
it('fails the release when no source maps were emitted', () => {
// Why: a silent regression of build.sourcemap would ship an undecodable
// release rather than an obviously broken one.
it('fails only when a map-enabled cut emits no source maps', () => {
// Why: legacy tags predate source-map publication, but a newer tag that
// enables hidden maps must still fail loudly if the build regresses.
const bundle = buildSteps[stepIndex('Bundle main-process source maps')]
expect(bundle.id).toBe('bundle-main-sourcemaps')
expect(bundle.run).toContain('grep -Eq')
expect(bundle.run).toContain('sourcemap:[[:space:]]*')
expect(bundle.run).toContain('has_maps=false')
expect(bundle.run).toContain('has_maps=true')
expect(bundle.run).toContain('::error::')
expect(bundle.run).toContain('exit 1')
})
it('skips publication for legacy cut refs without hidden source maps', () => {
const publish = buildSteps[stepIndex('Publish main-process source maps')]
expect(publish.if).toContain("steps.bundle-main-sourcemaps.outputs.has_maps == 'true'")
})
it('bundles maps after the build and before packaging strips them', () => {
const bundle = stepIndex('Bundle main-process source maps')
expect(bundle).toBeGreaterThan(stepIndex('Build app'))
+5 -4
View File
@@ -1,7 +1,6 @@
import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
@@ -16,8 +15,10 @@ import {
import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
import { isDevBundleInUse, selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs'
import { copyPrivateTree } from './space-sharing-copy.mjs'
import {
DEV_BUNDLE_ID,
getDevBundlePlistPatches,
@@ -298,9 +299,9 @@ function prepareMacDevElectronApp() {
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
// Why: Electron.framework uses relative symlinks for its bundle resources;
// resolving them to pnpm-store absolutes breaks Chromium's bundle lookup.
cpSync(sourceAppPath, appPath, { recursive: true, verbatimSymlinks: true })
// Why clone-first: this ~280MB copy is made per branch title x Electron version, and only the
// plist/helper/codesign bytes patched below ever diverge from the source.
copyPrivateTree(sourceAppPath, appPath)
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
@@ -0,0 +1,26 @@
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
/**
* Copy a script and every co-located module it imports into a fixture's `config/scripts`.
*
* Walked rather than listed: a module the script needs but the fixture never copied fails every
* test in the suite with a module-resolution error that looks nothing like the defect it hides.
*/
export function copyScriptWithLocalModules(sourceScriptPath, destinationScriptsDir) {
mkdirSync(destinationScriptsDir, { recursive: true })
for (const modulePath of collectScriptModules(sourceScriptPath)) {
copyFileSync(modulePath, join(destinationScriptsDir, basename(modulePath)))
}
}
function collectScriptModules(scriptPath, seen = new Set()) {
if (seen.has(scriptPath)) {
return seen
}
seen.add(scriptPath)
for (const [, specifier] of readFileSync(scriptPath, 'utf8').matchAll(/from '(\.\/[^']+)'/g)) {
collectScriptModules(join(dirname(scriptPath), specifier), seen)
}
return seen
}
@@ -0,0 +1,189 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
import path from 'node:path'
import { makeTreeReadOnly, shareTree } from './space-sharing-copy.mjs'
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
// Sibling of path.txt, never inside dist: an install replaces dist wholesale.
const MARKER_FILENAME = '.orca-shared-dist'
/**
* Where sibling worktrees of one repository keep their shared extracted Electron.
*
* Null means "install normally" -- every caller treats a missing entry as "do what you did before".
*/
export function resolveSharedElectronDistEntry(options) {
const { repoRoot, version, targetPlatform, targetArch } = options
const env = options.env ?? process.env
// Packaging jobs get a fresh checkout per run, so a cache only adds a failure mode.
if (env.CI === '1' || env.CI === 'true') {
return null
}
if (![version, targetPlatform, targetArch].every((part) => IDENTITY_PATTERN.test(part ?? ''))) {
return null
}
let gitCommonDir
try {
gitCommonDir = resolveGitCommonDir(repoRoot, options.execFile ?? execFileSync)
} catch {
return null // Folder workspace, or no Git on PATH.
}
const cacheRoot = path.join(gitCommonDir, 'orca-cache', 'electron')
return {
cacheRoot,
entryPath: path.join(cacheRoot, `${version}-${targetPlatform}-${targetArch}`),
markerPath: path.join(options.electronPackageDir, MARKER_FILENAME)
}
}
export function resolveGitCommonDir(repoRoot, execFile = execFileSync) {
const rawPath = execFile('git', ['-C', repoRoot, 'rev-parse', '--git-common-dir'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
if (!rawPath) {
throw new Error('Git returned an empty common directory')
}
return path.resolve(repoRoot, rawPath)
}
/** True once this worktree's dist is already a clone of the current cache entry. */
export function hasAdoptedSharedElectronDist(entry) {
try {
return readFileSync(entry.markerPath, 'utf8') === path.basename(entry.entryPath)
} catch {
return false
}
}
export function recordAdoptedSharedElectronDist(entry, write) {
try {
write(entry.markerPath, path.basename(entry.entryPath))
} catch {
// The marker is only an optimization: a missing one costs one extra clone.
}
}
/**
* Share a validated cache entry into `stagePath`. Deliberately never falls back to a byte copy --
* with no storage to share the caller is better off with its normal install, which this must not
* slow down.
*/
export function shareElectronDistFromCache(entry, stagePath, options) {
const { version, platformPath } = options
if (!isUsableElectronDist(entry.entryPath, version, platformPath)) {
return false
}
try {
;(options.share ?? shareTree)(entry.entryPath, stagePath)
} catch {
return false
}
return isUsableElectronDist(stagePath, version, platformPath)
}
/**
* Publish this worktree's dist as the shared entry, best effort.
*
* No lock: staging names are unique and `rename` onto a populated directory fails with ENOTEMPTY,
* so a concurrent publisher either wins the rename or cleans up its own staging tree. Neither can
* observe a half-written entry, and a usable entry already in place is never overwritten.
*/
export function publishSharedElectronDist(distPath, entry, options = {}) {
const { version, platformPath } = options
const uuid = options.uuid ?? randomUUID
const canValidate = Boolean(version) && Boolean(platformPath)
// Without an identity to check against, "unusable" is unknowable -- never discard on a guess.
if (existsSync(entry.entryPath) && (!canValidate || isUsable(entry, version, platformPath))) {
return false
}
const stagePath = `${entry.entryPath}.staging-${process.pid}-${uuid()}`
try {
mkdirSync(entry.cacheRoot, { recursive: true })
;(options.share ?? shareTree)(distPath, stagePath)
// Before publishing, not after: an entry is visible the instant the rename lands, and under
// hardlink sharing this is the only thing standing between a stray write and every worktree.
;(options.protect ?? makeTreeReadOnly)(stagePath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
return false
}
return swapInElectronDistEntry(entry, stagePath, {
canValidate,
version,
platformPath,
uuid,
rename: options.rename ?? renameSync
})
}
/**
* Replace the entry with a staged tree, re-checking first.
*
* Sharing the tree above takes seconds, and a sibling worktree can publish a perfectly good entry
* in that time. Re-validating here, immediately before the destructive rename, keeps us from
* discarding that entry -- and restoring the quarantine on a failed swap keeps a losing publisher
* from leaving the cache empty.
*/
function swapInElectronDistEntry(entry, stagePath, options) {
const { canValidate, version, platformPath, uuid, rename } = options
let quarantinePath = null
if (existsSync(entry.entryPath)) {
// Same rule as before staging: an entry we cannot judge, or one that is good, is never
// displaced. Both mean another worktree got there first, so keep theirs.
if (!canValidate || isUsable(entry, version, platformPath)) {
rmSync(stagePath, { recursive: true, force: true })
return false
}
quarantinePath = `${entry.entryPath}.unusable-${process.pid}-${uuid()}`
try {
renameSync(entry.entryPath, quarantinePath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
return false // Another worktree is already replacing it.
}
}
try {
rename(stagePath, entry.entryPath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
if (quarantinePath !== null) {
// Put it back rather than leave no entry at all; a bad entry still beats an empty cache,
// because the next publisher re-validates and replaces it.
try {
renameSync(quarantinePath, entry.entryPath)
return false
} catch {
rmSync(quarantinePath, { recursive: true, force: true })
}
}
return false
}
if (quarantinePath !== null) {
rmSync(quarantinePath, { recursive: true, force: true })
}
return true
}
function isUsable(entry, version, platformPath) {
return isUsableElectronDist(entry.entryPath, version, platformPath)
}
export function isUsableElectronDist(distPath, version, platformPath) {
try {
if (!lstatSync(distPath).isDirectory()) {
return false
}
const installedVersion = readFileSync(path.join(distPath, 'version'), 'utf8')
.trim()
.replace(/^v/, '')
return installedVersion === version && existsSync(path.join(distPath, platformPath))
} catch {
return false
}
}
@@ -0,0 +1,358 @@
import type { execFileSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
shareElectronDistFromCache,
hasAdoptedSharedElectronDist,
isUsableElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry
} from './shared-electron-dist-cache.mjs'
import { makeTreeReadOnly } from './space-sharing-copy.mjs'
const VERSION = '43.4.1'
const PLATFORM_PATH = path.join('Electron.app', 'Contents', 'MacOS', 'Electron')
const identity = { version: VERSION, platformPath: PLATFORM_PATH }
const roots: string[] = []
afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop()!, { recursive: true, force: true })
}
})
function makeRoot(): string {
const root = mkdtempSync(path.join(tmpdir(), 'orca-shared-electron-'))
roots.push(root)
return root
}
function writeDist(distPath: string, version = VERSION): string {
mkdirSync(path.join(distPath, path.dirname(PLATFORM_PATH)), { recursive: true })
writeFileSync(path.join(distPath, 'version'), `v${version}\n`)
writeFileSync(path.join(distPath, PLATFORM_PATH), 'electron')
return distPath
}
function makeEntry(root: string, entryName = `${VERSION}-darwin-arm64`) {
const cacheRoot = path.join(root, 'cache')
return {
cacheRoot,
entryPath: path.join(cacheRoot, entryName),
markerPath: path.join(root, '.orca-shared-dist')
}
}
const baseOptions = {
repoRoot: '/repo',
electronPackageDir: '/repo/node_modules/electron',
version: VERSION,
targetPlatform: 'darwin',
targetArch: 'arm64',
hostPlatform: 'darwin' as const,
env: {} as NodeJS.ProcessEnv,
execFile: (() => '/repo/.git\n') as unknown as typeof execFileSync
}
describe('resolveSharedElectronDistEntry', () => {
it('keys the entry by version, platform, and arch under the git common dir', () => {
const entry = resolveSharedElectronDistEntry(baseOptions)
expect(entry?.cacheRoot).toBe(path.join('/repo/.git', 'orca-cache', 'electron'))
expect(entry?.entryPath).toBe(
path.join('/repo/.git', 'orca-cache', 'electron', '43.4.1-darwin-arm64')
)
expect(entry?.markerPath).toBe(path.join('/repo/node_modules/electron', '.orca-shared-dist'))
})
it('offers an entry on every platform a worktree is developed on', () => {
for (const hostPlatform of ['darwin', 'linux', 'win32']) {
expect(resolveSharedElectronDistEntry({ ...baseOptions, hostPlatform })).not.toBeNull()
}
})
it('declines on CI, where every job gets a fresh checkout', () => {
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: '1' } })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: 'true' } })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: 'false' } })).not.toBeNull()
})
it('declines outside a Git worktree so folder workspaces install normally', () => {
const execFile = (() => {
throw new Error('not a git repository')
}) as unknown as typeof execFileSync
expect(resolveSharedElectronDistEntry({ ...baseOptions, execFile })).toBeNull()
})
it('declines an identity that would not be a single safe path segment', () => {
expect(resolveSharedElectronDistEntry({ ...baseOptions, targetArch: '../escape' })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, targetPlatform: 'dar/win' })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, version: '' })).toBeNull()
})
})
describe('isUsableElectronDist', () => {
it('accepts a complete dist and tolerates the leading v in the version file', () => {
const root = makeRoot()
expect(isUsableElectronDist(writeDist(path.join(root, 'dist')), VERSION, PLATFORM_PATH)).toBe(
true
)
})
it('rejects a version mismatch, a missing executable, and a missing directory', () => {
const root = makeRoot()
expect(
isUsableElectronDist(writeDist(path.join(root, 'a'), '40.0.0'), VERSION, PLATFORM_PATH)
).toBe(false)
const partial = path.join(root, 'b')
mkdirSync(partial, { recursive: true })
writeFileSync(path.join(partial, 'version'), `v${VERSION}`)
expect(isUsableElectronDist(partial, VERSION, PLATFORM_PATH)).toBe(false)
expect(isUsableElectronDist(path.join(root, 'missing'), VERSION, PLATFORM_PATH)).toBe(false)
})
it('rejects a symlink so a redirected entry is never treated as cache content', () => {
const root = makeRoot()
writeDist(path.join(root, 'real'))
symlinkSync(path.join(root, 'real'), path.join(root, 'link'), 'dir')
expect(isUsableElectronDist(path.join(root, 'link'), VERSION, PLATFORM_PATH)).toBe(false)
})
})
describe('publishSharedElectronDist', () => {
it('publishes through a staging directory and an atomic rename', () => {
const root = makeRoot()
const entry = makeEntry(root)
const dist = writeDist(path.join(root, 'dist'))
const share = vi.fn((source: string, destination: string) => {
expect(path.basename(destination)).toMatch(/^43\.4\.1-darwin-arm64\.staging-/)
writeDist(destination)
expect(source).toBe(dist)
})
expect(publishSharedElectronDist(dist, entry, { share, ...identity })).toBe(true)
expect(isUsableElectronDist(entry.entryPath, VERSION, PLATFORM_PATH)).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('publishes the entry read-only, before it is reachable under its final name', () => {
const root = makeRoot()
const entry = makeEntry(root)
const dist = writeDist(path.join(root, 'dist'))
const protectedPaths: string[] = []
const share = (source: string, destination: string) => {
writeDist(destination)
expect(source).toBe(dist)
}
const protect = (target: string) => {
// Why order matters: a reader can clone the entry the instant the rename lands.
expect(existsSync(entry.entryPath)).toBe(false)
protectedPaths.push(target)
makeTreeReadOnly(target)
}
expect(publishSharedElectronDist(dist, entry, { share, protect, ...identity })).toBe(true)
expect(protectedPaths).toHaveLength(1)
expect(statSync(path.join(entry.entryPath, 'version')).mode & 0o222).toBe(0)
// Entry directories stay removable, which is what the install transaction actually needs.
expect(() => rmSync(entry.entryPath, { recursive: true })).not.toThrow()
})
it('never overwrites an entry another worktree already published', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'first-writer')
const share = vi.fn()
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(share).not.toHaveBeenCalled()
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
})
it('loses a publish race without clobbering the winner or leaking staging', () => {
const root = makeRoot()
const entry = makeEntry(root)
const share = (_source: string, destination: string) => {
writeDist(destination)
// The winner lands between our existence check and our rename.
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'winner')
}
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('keeps a good entry a sibling published while this one was still sharing', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0') // Unusable, so this worktree intends to replace it.
const share = (_source: string, destination: string) => {
writeDist(destination)
// A sibling replaces the bad entry with a good one while this share is still running.
rmSync(entry.entryPath, { recursive: true, force: true })
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'sibling')
}
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share, ...identity })
).toBe(false)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('restores the quarantined entry rather than leaving the cache empty', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
writeFileSync(path.join(entry.entryPath, 'marker'), 'stale')
const share = (_source: string, destination: string) => writeDist(destination)
// The swap itself fails; a bad entry still beats no entry, since the next publisher replaces it.
const failingRename = () => {
throw new Error('rename failed')
}
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, {
share,
rename: failingRename,
...identity
})
).toBe(false)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('replaces an entry that fails validation instead of stranding every worktree', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const share = (_source: string, destination: string) => writeDist(destination)
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share, ...identity })
).toBe(true)
expect(isUsableElectronDist(entry.entryPath, VERSION, PLATFORM_PATH)).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('never discards an entry it was given no identity to check', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const share = vi.fn()
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(share).not.toHaveBeenCalled()
expect(existsSync(path.join(entry.entryPath, 'version'))).toBe(true)
})
it('leaves no entry and no staging tree when sharing fails', () => {
const root = makeRoot()
const entry = makeEntry(root)
const share = (_source: string, destination: string) => {
writeDist(destination)
throw new Error('no shareable storage')
}
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(existsSync(entry.entryPath)).toBe(false)
expect(readdirSync(entry.cacheRoot)).toEqual([])
})
})
describe('shareElectronDistFromCache', () => {
it('shares a validated entry with real filesystem semantics', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
const stagePath = path.join(root, 'stage')
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH
})
).toBe(true)
expect(isUsableElectronDist(stagePath, VERSION, PLATFORM_PATH)).toBe(true)
})
it('refuses an entry that fails validation instead of installing it', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const stagePath = path.join(root, 'stage')
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH
})
).toBe(false)
expect(existsSync(stagePath)).toBe(false)
})
it('reports failure rather than falling back to a full copy', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
// Injected rather than provoked: what counts as an unshareable destination differs per
// mechanism, and a byte-copy fallback here would defeat the point of the cache.
const stagePath = path.join(root, 'stage')
const share = () => {
throw new Error('no shareable storage')
}
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH,
share
})
).toBe(false)
expect(existsSync(stagePath)).toBe(false)
})
})
describe('shared dist marker', () => {
it('reports adoption only for the entry the marker names', () => {
const root = makeRoot()
const entry = makeEntry(root)
expect(hasAdoptedSharedElectronDist(entry)).toBe(false)
recordAdoptedSharedElectronDist(entry, writeFileSync)
expect(hasAdoptedSharedElectronDist(entry)).toBe(true)
expect(
hasAdoptedSharedElectronDist({
...entry,
entryPath: path.join(entry.cacheRoot, '44.0.0-darwin-arm64')
})
).toBe(false)
})
it('swallows a marker write failure, which only costs one extra share', () => {
const entry = makeEntry(makeRoot())
expect(() =>
recordAdoptedSharedElectronDist(entry, () => {
throw new Error('read-only node_modules')
})
).not.toThrow()
})
})
+143
View File
@@ -0,0 +1,143 @@
import { execFileSync } from 'node:child_process'
import {
chmodSync,
cpSync,
linkSync,
mkdirSync,
readdirSync,
readlinkSync,
rmSync,
symlinkSync
} from 'node:fs'
import { join } from 'node:path'
// -c asks for clonefile(2). -P keeps Electron.framework's relative symlinks as symlinks; resolving
// them breaks Chromium's bundle lookup.
export const MACOS_CLONE_ARGS = Object.freeze(['-c', '-R', '-P'])
// -a implies -d (no symlink following) and preserves mode. --reflink=always fails loudly on a
// filesystem without reflinks rather than silently writing a second full copy.
export const LINUX_REFLINK_ARGS = Object.freeze(['--reflink=always', '-a'])
/**
* Copy a directory tree so the destination costs no new storage.
*
* Three mechanisms, strongest isolation first. Clone and reflink are copy-on-write, so the
* destination is genuinely private. Hardlinks are not: the two trees share inodes, and a write
* through either mutates both. That is only sound for a tree nothing writes to, which is why
* `makeTreeReadOnly` exists and why the caller must apply it.
*
* Throws when no mechanism is available, so a caller can fall back to installing normally rather
* than silently paying for a second full copy.
*/
export function shareTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const errors = []
for (const mechanism of getShareMechanisms(platform)) {
try {
;(options[mechanism] ?? shareMechanisms[mechanism])(sourcePath, destinationPath)
return mechanism
} catch (error) {
errors.push(error)
// A mechanism can fail part-way through a tree; the next one needs a clean destination.
rmSync(destinationPath, { recursive: true, force: true })
}
}
throw new AggregateError(errors, `Could not share storage for ${destinationPath}`)
}
function getShareMechanisms(platform) {
switch (platform) {
case 'darwin':
// APFS only. HFS+ has no clonefile, and hardlinking a 585-entry bundle buys little.
return ['clone']
case 'linux':
// reflink covers btrfs/XFS/bcachefs/ZFS; ext4 has none, which is most developers.
return ['reflink', 'hardlink']
case 'win32':
// Block cloning is ReFS-only, so NTFS gets hardlinks or nothing.
return ['hardlink']
default:
return []
}
}
const shareMechanisms = {
clone: (sourcePath, destinationPath) =>
execFileSync('/bin/cp', [...MACOS_CLONE_ARGS, sourcePath, destinationPath], {
stdio: 'ignore'
}),
reflink: (sourcePath, destinationPath) =>
execFileSync('cp', [...LINUX_REFLINK_ARGS, sourcePath, destinationPath], { stdio: 'ignore' }),
hardlink: hardlinkTree
}
export function hardlinkTree(sourcePath, destinationPath) {
mkdirSync(destinationPath, { recursive: true })
for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
const from = join(sourcePath, entry.name)
const to = join(destinationPath, entry.name)
if (entry.isDirectory()) {
hardlinkTree(from, to)
} else if (entry.isSymbolicLink()) {
symlinkSync(readlinkSync(from), to)
} else {
linkSync(from, to)
}
}
}
/**
* Drop write permission across a tree.
*
* This is what makes hardlink sharing safe: Electron's own install.js extracts over an existing
* dist with O_TRUNC, which through a hardlink would rewrite every sibling worktree and the cache at
* once. Read-only turns that into EPERM. Directories stay writable because unlink needs a writable
* parent, not a writable file, so the install transaction's renames still work.
*/
export function makeTreeReadOnly(targetPath, chmod = chmodSync) {
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
const entryPath = join(targetPath, entry.name)
if (entry.isDirectory()) {
makeTreeReadOnly(entryPath, chmod)
} else if (!entry.isSymbolicLink()) {
chmod(entryPath, 0o555)
}
}
chmod(targetPath, 0o755)
}
/**
* Share storage when possible, otherwise copy the bytes.
*
* Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write
* through into the source.
*/
export function copyPrivateTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const copy = options.copy ?? copyTreeVerbatim
const privateMechanisms = new Set(['clone', 'reflink'])
if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) {
try {
const mechanism = shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
})
return { mechanism, copyError: null }
} catch (copyError) {
copy(sourcePath, destinationPath)
return { mechanism: null, copyError }
}
}
copy(sourcePath, destinationPath)
return { mechanism: null, copyError: null }
}
function copyTreeVerbatim(sourcePath, destinationPath) {
cpSync(sourcePath, destinationPath, {
recursive: true,
dereference: false,
verbatimSymlinks: true
})
}
+210
View File
@@ -0,0 +1,210 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
LINUX_REFLINK_ARGS,
MACOS_CLONE_ARGS,
copyPrivateTree,
hardlinkTree,
makeTreeReadOnly,
shareTree
} from './space-sharing-copy.mjs'
const roots: string[] = []
afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop()!, { recursive: true, force: true })
}
})
function makeTree(): { root: string; source: string } {
const root = mkdtempSync(path.join(tmpdir(), 'orca-share-'))
roots.push(root)
const source = path.join(root, 'source')
mkdirSync(path.join(source, 'nested'), { recursive: true })
writeFileSync(path.join(source, 'nested', 'file'), 'contents')
symlinkSync(path.join('nested', 'file'), path.join(source, 'relative-link'))
return { root, source }
}
describe('shareTree', () => {
// Mechanism selection is asserted with stubs, because the real mechanisms only exist on the host
// that owns them: /bin/cp -c is macOS-only and `cp --reflink` is GNU-only.
it('prefers the strongest isolation each platform offers', () => {
const stub = () =>
vi.fn((_source: string, target: string) => mkdirSync(target, { recursive: true }))
const stubs = { clone: stub(), reflink: stub(), hardlink: stub() }
const { root, source } = makeTree()
expect(shareTree(source, path.join(root, 'a'), { platform: 'darwin', ...stubs })).toBe('clone')
expect(shareTree(source, path.join(root, 'b'), { platform: 'linux', ...stubs })).toBe('reflink')
expect(shareTree(source, path.join(root, 'c'), { platform: 'win32', ...stubs })).toBe(
'hardlink'
)
})
it('keeps relative symlinks unresolved on whatever this host supports', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
expect(shareTree(source, destination)).toBeTruthy()
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('falls from reflink to hardlink on Linux, where ext4 has no reflinks', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
const reflink = vi.fn(() => {
throw new Error('failed to clone: Invalid cross-device link')
})
expect(shareTree(source, destination, { platform: 'linux', reflink })).toBe('hardlink')
expect(reflink).toHaveBeenCalledOnce()
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
})
it('hardlinks on Windows, the only mechanism NTFS offers', () => {
const { root, source } = makeTree()
expect(shareTree(source, path.join(root, 'shared'), { platform: 'win32' })).toBe('hardlink')
})
it('clears a part-way tree before trying the next mechanism', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
const reflink = (_source: string, target: string) => {
mkdirSync(target, { recursive: true })
writeFileSync(path.join(target, 'half-written'), 'partial')
throw new Error('reflink failed midway')
}
expect(shareTree(source, destination, { platform: 'linux', reflink })).toBe('hardlink')
expect(existsSync(path.join(destination, 'half-written'))).toBe(false)
})
it('throws rather than silently paying for a second full copy', () => {
const { root, source } = makeTree()
expect(() => shareTree(source, path.join(root, 'shared'), { platform: 'freebsd' })).toThrow(
/Could not share storage/
)
})
it('fails loudly instead of degrading, on both copy-out mechanisms', () => {
expect(MACOS_CLONE_ARGS).toContain('-P')
expect(LINUX_REFLINK_ARGS).toContain('--reflink=always')
})
})
describe('hardlinkTree', () => {
it('shares inodes for files but recreates symlinks as their own entries', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('propagates a write through the shared inode, which is why callers must protect it', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
writeFileSync(path.join(destination, 'nested', 'file'), 'mutated')
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('mutated')
})
})
describe('makeTreeReadOnly', () => {
it('drops write permission on files while leaving directories traversable and unlinkable', () => {
const { source } = makeTree()
makeTreeReadOnly(source)
expect(statSync(path.join(source, 'nested', 'file')).mode & 0o222).toBe(0)
// Asserted as behavior, not mode bits: Windows maps chmod onto the read-only attribute alone,
// so a directory there never reports 0o755. What has to hold everywhere is that the install
// transaction can still rename dist aside and remove it.
expect(() => rmSync(path.join(source, 'nested'), { recursive: true })).not.toThrow()
})
it('turns an extract-over-dist write into an error instead of silent shared corruption', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
makeTreeReadOnly(destination)
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'mutated')).toThrow()
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
})
it.runIf(process.platform !== 'win32')(
'keeps the executable bit, which Electron needs to launch',
() => {
const { source } = makeTree()
const executable = path.join(source, 'electron')
writeFileSync(executable, 'binary', { mode: 0o755 })
makeTreeReadOnly(source)
// Verified on real ext4: 0o555. Windows has no execute bit -- the read-only attribute does
// not gate execution there, confirmed by running a read-only hardlinked .exe on NTFS.
expect(statSync(executable).mode & 0o111).toBe(0o111)
}
)
})
describe('copyPrivateTree', () => {
it('never hardlinks, because the caller patches what it gets back', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const hardlink = vi.fn()
const result = copyPrivateTree(source, destination, { platform: 'linux', hardlink })
expect(hardlink).not.toHaveBeenCalled()
expect(statSync(path.join(destination, 'nested', 'file')).ino).not.toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
expect(result.mechanism === 'reflink' || result.mechanism === null).toBe(true)
})
it('copies bytes on a platform with no private mechanism at all', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const hardlink = vi.fn()
expect(copyPrivateTree(source, destination, { platform: 'win32', hardlink })).toEqual({
mechanism: null,
copyError: null
})
expect(hardlink).not.toHaveBeenCalled()
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('reports the private mechanism it used', () => {
const { root, source } = makeTree()
const clone = vi.fn((_source: string, target: string) => mkdirSync(target, { recursive: true }))
expect(
copyPrivateTree(source, path.join(root, 'private'), { platform: 'darwin', clone })
).toEqual({
mechanism: 'clone',
copyError: null
})
})
it('falls back to a byte copy when the private mechanism fails', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const clone = () => {
throw new Error('clonefile unsupported')
}
const result = copyPrivateTree(source, destination, { platform: 'darwin', clone })
expect(result.mechanism).toBeNull()
expect(result.copyError).toBeInstanceOf(Error)
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
})
})
+176
View File
@@ -0,0 +1,176 @@
# Files currently allowed to carry a `@ts-nocheck` header.
# This is a RATCHET: the list may only SHRINK. These exist only because the split
# runtime mixin chain cannot express forward references yet — do NOT add entries to
# get CI green; fix the types instead.
# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)
src/main/runtime/orca-runtime-activate-managed-worktree.ts
src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
src/main/runtime/orca-runtime-agent-teams-launch-plan.ts
src/main/runtime/orca-runtime-apply-layout.ts
src/main/runtime/orca-runtime-apply-mobile-display-mode.ts
src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
src/main/runtime/orca-runtime-apply-tracked-pty-title.ts
src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts
src/main/runtime/orca-runtime-attach-window.ts
src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts
src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
src/main/runtime/orca-runtime-build-pty-terminal-summary.ts
src/main/runtime/orca-runtime-capture-provider-terminal-buffer.ts
src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
src/main/runtime/orca-runtime-close-mobile-session-tab.ts
src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts
src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts
src/main/runtime/orca-runtime-core.ts
src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts
src/main/runtime/orca-runtime-create-agent-session.ts
src/main/runtime/orca-runtime-create-managed-remote-worktree.ts
src/main/runtime/orca-runtime-create-managed-worktree.ts
src/main/runtime/orca-runtime-create-mobile-session-terminal.ts
src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts
src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts
src/main/runtime/orca-runtime-create-terminal-desktop.ts
src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts
src/main/runtime/orca-runtime-create-terminal.ts
src/main/runtime/orca-runtime-deliver-pending-messages.ts
src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts
src/main/runtime/orca-runtime-fence-automation-owner.ts
src/main/runtime/orca-runtime-file-commands.ts
src/main/runtime/orca-runtime-fit-override-listeners.ts
src/main/runtime/orca-runtime-focus-terminal.ts
src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts
src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts
src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts
src/main/runtime/orca-runtime-get-runtime-id.ts
src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts
src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts
src/main/runtime/orca-runtime-get-worktree-ps.ts
src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts
src/main/runtime/orca-runtime-handle-mobile-subscribe-internal.ts
src/main/runtime/orca-runtime-handle-mobile-subscribe.ts
src/main/runtime/orca-runtime-handle-mobile-unsubscribe.ts
src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts
src/main/runtime/orca-runtime-has-live-or-persisted-serve-or-ssh-owned-pty-binding.ts
src/main/runtime/orca-runtime-has-recent-terminal-output-path.ts
src/main/runtime/orca-runtime-has-terminals-for-worktree.ts
src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts
src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts
src/main/runtime/orca-runtime-linear-commands.ts
src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts
src/main/runtime/orca-runtime-list-managed-worktrees.ts
src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts
src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts
src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts
src/main/runtime/orca-runtime-mobile-took-floor.ts
src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts
src/main/runtime/orca-runtime-notify-ssh-state-changed.ts
src/main/runtime/orca-runtime-on-client-disconnected.ts
src/main/runtime/orca-runtime-on-pty-data.ts
src/main/runtime/orca-runtime-on-pty-exit.ts
src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts
src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts
src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
src/main/runtime/orca-runtime-pick-most-recent-actor.ts
src/main/runtime/orca-runtime-postlude.ts
src/main/runtime/orca-runtime-prepare-pty-execution-context.ts
src/main/runtime/orca-runtime-preserved-branch-cleanup.ts
src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts
src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts
src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts
src/main/runtime/orca-runtime-reclaim-terminal-for-desktop.ts
src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts
src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts
src/main/runtime/orca-runtime-record-pty-worktree.ts
src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts
src/main/runtime/orca-runtime-refresh-pty-worktree-records-from-controller.ts
src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts
src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts
src/main/runtime/orca-runtime-refuse-unattributed-mobile-session-tab-close.ts
src/main/runtime/orca-runtime-register-pty.ts
src/main/runtime/orca-runtime-remove-managed-worktree.ts
src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts
src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts
src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts
src/main/runtime/orca-runtime-resolve-exit-waiters.ts
src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts
src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts
src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts
src/main/runtime/orca-runtime-resolve-terminal-pane.ts
src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts
src/main/runtime/orca-runtime-resolve-waiter.ts
src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts
src/main/runtime/orca-runtime-resolve-worktree-selector.ts
src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts
src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts
src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts
src/main/runtime/orca-runtime-runtime-id.ts
src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts
src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts
src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts
src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts
src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts
src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts
src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts
src/main/runtime/orca-runtime-split-pty-backed-terminal.ts
src/main/runtime/orca-runtime-split-terminal.ts
src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts
src/main/runtime/orca-runtime-state-fields.ts
src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts
src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
src/main/runtime/orca-runtime-stop-requested-pty-ids.ts
src/main/runtime/orca-runtime-stop-structured-session-process.ts
src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts
src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts
src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts
src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts
src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts
src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts
src/main/runtime/orca-runtime-sync-window-graph.ts
src/main/runtime/orca-runtime-terminal-create-deduplication.ts
src/main/runtime/orca-runtime-terminal-drivers.ts
src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts
src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts
src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts
src/main/runtime/orca-runtime-visible-snapshot-preview.ts
src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts
src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts
src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts
src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts
src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts
src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts
src/main/runtime/runtime-browser-commands-browser-clear.ts
src/main/runtime/runtime-browser-commands-browser-click.ts
src/main/runtime/runtime-browser-commands-browser-command-target-params.ts
src/main/runtime/runtime-browser-commands-browser-network-log.ts
src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts
src/main/runtime/runtime-browser-commands-browser-screencast.ts
src/main/runtime/runtime-browser-commands-browser-set-headers.ts
src/main/runtime/runtime-browser-commands-browser-tab-close.ts
src/main/runtime/runtime-browser-commands-browser-tab-create.ts
src/main/runtime/runtime-browser-commands-browser-tab-list.ts
src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts
src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts
src/main/runtime/runtime-browser-commands-state.ts
src/main/runtime/runtime-file-command-host.ts
src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts
src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts
src/main/runtime/runtime-file-commands-constructor.ts
src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts
src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts
src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts
src/main/runtime/runtime-file-commands-read-mobile-file.ts
src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts
src/main/runtime/runtime-file-commands-resolve-terminal-path.ts
src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts
src/main/runtime/runtime-file-commands-search-local-runtime-files.ts
src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts
src/main/runtime/runtime-file-commands-search-runtime-files.ts
src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.ts
src/main/runtime/runtime-file-commands-terminal-artifact-access.ts
src/main/runtime/runtime-file-commands-terminal-file-paths.ts
src/main/runtime/runtime-file-commands-write-file-explorer-file.ts
src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts
src/main/runtime/runtime-file-watcher-leases.ts
+3 -1
View File
@@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -29,6 +29,7 @@
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"build:orcad": "node config/scripts/build-orcad.mjs",
"build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs",
@@ -85,6 +86,7 @@
"build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs",
"rebuild:node": "pnpm rebuild node-pty",
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",
@@ -174,6 +174,52 @@ describe('browserManager', () => {
}
)
it('refuses devtools for an offscreen guest instead of opening it on the host display', async () => {
const guest = {
id: 138,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'window'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
expect(
browserManager.registerOffscreenGuest({
browserPageId: 'offscreen-devtools',
webContentsId: guest.id
})
).toBe(true)
await expect(browserManager.openDevTools('offscreen-devtools')).resolves.toBe(false)
expect(guestOpenDevToolsMock).not.toHaveBeenCalled()
})
it('keeps devtools available for a desktop webview guest', async () => {
const guest = {
id: 139,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'desktop-devtools',
webContentsId: guest.id,
rendererWebContentsId
})
await expect(browserManager.openDevTools('desktop-devtools')).resolves.toBe(true)
expect(guestOpenDevToolsMock).toHaveBeenCalledWith({ mode: 'detach' })
})
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
it('refuses unregisterGuest for a page the document registry holds', () => {
+5
View File
@@ -1926,6 +1926,11 @@ export class BrowserManager {
this.unregisterGuest(browserTabId)
return false
}
// Offscreen guests have no visible window on this desktop; detaching DevTools would open it
// on the host display with no route back to the remote client.
if (this.offscreenGuestIds.has(webContentsId)) {
return false
}
guest.openDevTools({ mode: 'detach' })
return true
}
+2 -28
View File
@@ -1,22 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import {
hasCommitObjectViaGitExec,
hasLocalCommitObject,
isFullGitObjectId
} from './commit-object-ref'
import { describe, expect, it, vi } from 'vitest'
import { hasCommitObjectViaGitExec, isFullGitObjectId } from './commit-object-ref'
describe('commit object refs', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('recognizes only complete git object IDs', () => {
expect(isFullGitObjectId('a'.repeat(40))).toBe(true)
expect(isFullGitObjectId('A'.repeat(40))).toBe(true)
@@ -49,15 +34,4 @@ describe('commit object refs', () => {
expect(gitExec).not.toHaveBeenCalled()
})
it('checks local commit objects in the target repo path', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'a'.repeat(40), stderr: '' })
await expect(hasLocalCommitObject('/repo', 'a'.repeat(40))).resolves.toBe(true)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--verify', '--quiet', `${'a'.repeat(40)}^{commit}`],
{ cwd: '/repo' }
)
})
})
-6
View File
@@ -1,5 +1,3 @@
import { gitExecFileAsync } from './runner'
type GitExec = (args: string[]) => Promise<unknown>
const FULL_GIT_OBJECT_ID_PATTERN = /^[0-9a-f]{40}$/i
@@ -20,7 +18,3 @@ export async function hasCommitObjectViaGitExec(gitExec: GitExec, ref: string):
return false
}
}
export function hasLocalCommitObject(repoPath: string, ref: string): Promise<boolean> {
return hasCommitObjectViaGitExec((args) => gitExecFileAsync(args, { cwd: repoPath }), ref)
}
+2 -15
View File
@@ -1,8 +1,7 @@
import { readFileSync, realpathSync, statSync } from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { dirname, isAbsolute, join, relative } from 'node:path'
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWindowsWslPath } from '../wsl'
import { resolveGitMetadataPath } from '../../shared/git-metadata-path'
export type GitMarkerScanResult =
| { status: 'valid'; rootPath: string }
@@ -136,18 +135,6 @@ function parseGitdirFile(basePath: string, content: string): string | null {
return resolveGitMetadataPath(basePath, match[1])
}
function resolveGitMetadataPath(basePath: string, rawPath: string): string | null {
const value = rawPath.trim()
if (!value) {
return null
}
const baseWsl = parseWslUncPath(basePath)
if (baseWsl && value.startsWith('/')) {
return toWindowsWslPath(value, baseWsl.distro)
}
return isAbsolute(value) ? value : resolve(basePath, value)
}
function hasValidGitDirectorySync(gitDir: string): boolean {
return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir)
}
+12 -5
View File
@@ -6,6 +6,7 @@ import type {
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
import {
getLocalBaseRefUpdateSuggestionForWorktreeCreate,
refreshLocalBaseRefForWorktreeCreate
@@ -200,11 +201,17 @@ async function performAddWorktree(
args.push(effectiveBase)
}
}
await gitExecFileAsync(args, {
...gitExecOptions(repoPath, options),
// Why: resolve per call — hoisting this to a module const would freeze the override at import.
timeout: resolveWorktreeAddTimeoutMs()
})
try {
await gitExecFileAsync(args, {
...gitExecOptions(repoPath, options),
// Why: resolve per call — hoisting this to a module const would freeze the override at import.
timeout: resolveWorktreeAddTimeoutMs()
})
} finally {
// Git may have written the target's `.git` marker even when it reports a late
// failure, so drop any pre-create route before the follow-up commands route.
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
if (options.checkoutExistingBranch) {
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
+51 -2
View File
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { probeWorktreeBaseRefPresence } from './worktree-base-ref-probe'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const gitExecFileAsync = vi.hoisted(() => vi.fn())
vi.mock('./runner', () => ({ gitExecFileAsync }))
import { hasLocalWorktreeBaseRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe'
describe('probeWorktreeBaseRefPresence', () => {
it('uses an exact show-ref probe and reports a present ref', async () => {
@@ -52,3 +57,47 @@ describe('probeWorktreeBaseRefPresence', () => {
expect(runGit).not.toHaveBeenCalled()
})
})
describe('hasLocalWorktreeBaseRef', () => {
const repoPath = String.raw`C:\workspace\repo`
function resolveOnly(present: string[]): void {
gitExecFileAsync.mockImplementation(async (args: string[]) => ({
stdout: present.includes(args.at(-1)?.replace('^{commit}', '') ?? '') ? 'f'.repeat(40) : '',
stderr: ''
}))
}
beforeEach(() => {
gitExecFileAsync.mockReset()
})
it('prefers the remote namespace for a slashed short name', async () => {
resolveOnly(['refs/remotes/origin/main'])
await expect(hasLocalWorktreeBaseRef(repoPath, 'origin/main')).resolves.toBe(true)
expect(gitExecFileAsync).toHaveBeenCalledWith(
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
{ cwd: repoPath }
)
})
it('probes a bare commit id as an object, not as a ref', async () => {
const sha = 'a'.repeat(40)
resolveOnly([sha])
await expect(hasLocalWorktreeBaseRef(repoPath, sha, { wslDistro: 'Ubuntu' })).resolves.toBe(
true
)
expect(gitExecFileAsync).toHaveBeenCalledWith(
['rev-parse', '--verify', '--quiet', `${sha}^{commit}`],
{ cwd: repoPath, wslDistro: 'Ubuntu' }
)
})
it('reports a base no namespace resolves as absent', async () => {
resolveOnly([])
await expect(hasLocalWorktreeBaseRef(repoPath, 'feature/topic')).resolves.toBe(false)
})
})
+30
View File
@@ -1,6 +1,8 @@
import { gitExecFileAsync } from './runner'
import { isShowRefNoMatchError } from './exact-ref-probe'
import { hasCommitObjectViaGitExec } from './commit-object-ref'
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
type GitExecOptions = {
wslDistro?: string
@@ -40,6 +42,34 @@ export async function hasWorktreeBaseCommitRef(
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
}
/**
* Whether a worktree base — a qualified ref, a short branch or remote name, or a
* full commit id — already resolves in this repo's own object/ref store.
*
* Single copy on purpose: the create path, the speculative create prefetch and
* the remote-repo create path must agree on what counts as a local base, or the
* warm-up prepares a checkout create then rejects.
*/
export async function hasLocalWorktreeBaseRef(
repoPath: string,
baseRef: string,
options: GitExecOptions = {}
): Promise<boolean> {
const refExists = (qualifiedRef: string) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
if (resolvedBaseRef !== baseRef) {
return true
}
if (baseRef.startsWith('refs/')) {
return refExists(baseRef)
}
return hasCommitObjectViaGitExec(
(gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }),
baseRef
)
}
export type WorktreeBaseRefPresence = 'present' | 'absent' | 'unknown'
/**
+31 -18
View File
@@ -12,6 +12,7 @@ import {
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
function gitExecOptions(
cwd: string,
@@ -50,10 +51,14 @@ async function performDiscardPreparedWorktree(
} catch {
// It may be unlocked already or only partially registered.
}
await gitExecFileAsync(
[...windowsLongPathGitArgs(repoPath), 'worktree', 'remove', '--force', worktreePath],
cleanupGitOptions
)
try {
await gitExecFileAsync(
[...windowsLongPathGitArgs(repoPath), 'worktree', 'remove', '--force', worktreePath],
cleanupGitOptions
)
} finally {
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
}
export async function prepareWorktreeCreateCheckout(
@@ -81,6 +86,8 @@ export async function prepareWorktreeCreateCheckout(
],
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
)
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
invalidateWslLinkedWorktreeGitRouting(worktreePath)
// Why: reset materializes files without running user post-checkout hooks before submit.
await gitExecFileAsync(
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase],
@@ -213,20 +220,26 @@ export async function finalizePreparedWorktree(
let moved = false
try {
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'move',
'-f',
'-f',
preparedPath,
worktreePath
],
gitExecOptions(repoPath, finalizeGitOptions)
)
moved = true
// Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25).
try {
// Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25).
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'move',
'-f',
'-f',
preparedPath,
worktreePath
],
gitExecOptions(repoPath, finalizeGitOptions)
)
moved = true
} finally {
// The move rewrites both `.git` markers, and a failure can have rewritten one.
invalidateWslLinkedWorktreeGitRouting(preparedPath)
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
await gitExecFileAsync(
[
...windowsLongPathGitArgs(worktreePath),
+4
View File
@@ -1,5 +1,6 @@
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
import { bumpWorktreeScanGeneration } from './worktree-scan-cache'
/**
@@ -18,6 +19,9 @@ export async function moveWorktree(
gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
)
} finally {
// A failed move can still have rewritten one `.git` marker, so re-probe both paths.
invalidateWslLinkedWorktreeGitRouting(oldPath)
invalidateWslLinkedWorktreeGitRouting(newPath)
bumpWorktreeScanGeneration(repoPath)
}
}
@@ -0,0 +1,177 @@
// Worktree add/move/remove/rollback rewrite the `.git` marker the WSL Git route was derived from.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreeModule from './worktree'
const {
gitExecFileAsyncMock,
gitExecFileSyncMock,
translateWslOutputPathsMock,
listWorktreesMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output),
listWorktreesMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: translateWslOutputPathsMock
}))
vi.mock('./status', () => ({
resolveGitDir: vi.fn(),
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
}))
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
vi.mock('../worktree-trash', () => ({
moveWorktreeDirectoryToTrash: vi.fn().mockResolvedValue(undefined),
restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true),
scheduleWorktreeTrashDeletion: vi.fn()
}))
vi.mock('./worktree', async (importOriginal) => ({
...(await importOriginal<typeof WorktreeModule>()),
resolveWorktreeAddBaseContext: vi.fn(async () => ({ effectiveBase: 'origin/main' })),
persistWorktreeCreationBase: vi.fn(),
configurePushAutoSetupRemote: vi.fn(),
notifyPreparedWorktreeMutation: vi.fn()
}))
vi.mock('./worktree-scan-cache', () => ({
bumpWorktreeScanGeneration: vi.fn(),
listWorktrees: listWorktreesMock
}))
import { addWorktree } from './worktree-add'
import {
discardPreparedWorktree,
finalizePreparedWorktree,
prepareWorktreeCreateCheckout
} from './worktree-create-preparation'
import { moveWorktree } from './worktree-move'
import { removeWorktree } from './worktree-removal'
import {
resetWslLinkedWorktreeGitRoutingForTests,
seedWslLinkedWorktreeGitRoutingForTests,
usesHostGitForWslLinkedWorktree
} from './wsl-linked-worktree-git-routing'
const REPO = String.raw`C:\repo`
const LINKED = String.raw`C:\ws\linked`
const MOVED = String.raw`C:\ws\moved`
const PREPARED = String.raw`C:\ws\.orca-preparing\wt`
function hasCachedHostRoute(path: string): boolean {
return usesHostGitForWslLinkedWorktree(path, 'Ubuntu', 'win32')
}
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
listWorktreesMock.mockReset()
listWorktreesMock.mockResolvedValue([])
})
afterEach(() => resetWslLinkedWorktreeGitRoutingForTests())
describe('worktree mutations invalidate the WSL linked-worktree Git route', () => {
it('drops the target route after `git worktree add`', async () => {
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
await addWorktree(REPO, LINKED, 'feature')
expect(hasCachedHostRoute(LINKED)).toBe(false)
})
it('drops the target route even when `git worktree add` fails late', async () => {
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: could not create leading dirs'))
await expect(addWorktree(REPO, LINKED, 'feature')).rejects.toThrow('leading dirs')
expect(hasCachedHostRoute(LINKED)).toBe(false)
})
it('drops both routes after `git worktree move`', async () => {
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
seedWslLinkedWorktreeGitRoutingForTests(MOVED)
await moveWorktree(REPO, LINKED, MOVED)
expect(hasCachedHostRoute(LINKED)).toBe(false)
expect(hasCachedHostRoute(MOVED)).toBe(false)
})
it('drops both routes when `git worktree move` fails', async () => {
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
seedWslLinkedWorktreeGitRoutingForTests(MOVED)
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: destination exists'))
await expect(moveWorktree(REPO, LINKED, MOVED)).rejects.toThrow('destination exists')
expect(hasCachedHostRoute(LINKED)).toBe(false)
expect(hasCachedHostRoute(MOVED)).toBe(false)
})
it('drops the route after `git worktree remove`', async () => {
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
await removeWorktree(REPO, LINKED, true)
expect(hasCachedHostRoute(LINKED)).toBe(false)
})
it('drops the route after a prepared worktree is discarded', async () => {
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
await discardPreparedWorktree(REPO, LINKED)
expect(hasCachedHostRoute(LINKED)).toBe(false)
})
it('drops the target route after the prepared checkout is added', async () => {
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
await prepareWorktreeCreateCheckout(REPO, PREPARED, 'origin/main', 'orca preparation')
expect(hasCachedHostRoute(PREPARED)).toBe(false)
})
it('drops both routes after the prepared checkout is moved into place', async () => {
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
await finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main')
expect(hasCachedHostRoute(PREPARED)).toBe(false)
expect(hasCachedHostRoute(LINKED)).toBe(false)
})
it('drops both routes when the finalize move fails', async () => {
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
gitExecFileAsyncMock.mockImplementation(async (args: string[]) =>
args.includes('move')
? Promise.reject(new Error('fatal: destination exists'))
: { stdout: '', stderr: '' }
)
await expect(
finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main')
).rejects.toThrow('destination exists')
expect(hasCachedHostRoute(PREPARED)).toBe(false)
expect(hasCachedHostRoute(LINKED)).toBe(false)
})
it('leaves an unrelated worktree route cached', async () => {
seedWslLinkedWorktreeGitRoutingForTests(MOVED)
await removeWorktree(REPO, LINKED, true)
expect(hasCachedHostRoute(MOVED)).toBe(true)
})
})
+2
View File
@@ -13,6 +13,7 @@ import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { deleteBranchAfterWorktreeRemoval } from './worktree-branch-removal'
import { listWorktreesStrict } from './worktree-listing'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
import type { RemoveWorktreeOptions } from './worktree-operation-options'
import {
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS,
@@ -38,6 +39,7 @@ export async function removeWorktree(
performRemoveWorktree(repoPath, worktreePath, force, options)
)
} finally {
invalidateWslLinkedWorktreeGitRouting(worktreePath)
bumpWorktreeScanGeneration(repoPath)
}
}
@@ -0,0 +1,83 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { findExistingWorktreeSymlinkPaths, getSafeRelativePath } from './worktree-symlink-detection'
describe('getSafeRelativePath', () => {
// The only case in this file that binds the production change: every one of
// these is admitted by at least one host's `path.isAbsolute` — the
// drive-relative spellings are admitted by *every* host's, Windows included.
it('rejects Windows drive-qualified entries, including drive-relative, on any host', () => {
expect(getSafeRelativePath('C:\\Windows\\Temp')).toEqual({ safe: false })
expect(getSafeRelativePath('C:/Windows/Temp')).toEqual({ safe: false })
expect(getSafeRelativePath(' d:\\payload ')).toEqual({ safe: false })
// Drive-RELATIVE: `win32.resolve('D:\\wt', 'C:payload')` discards the
// worktree root and lands under C:'s current directory.
expect(getSafeRelativePath('C:payload')).toEqual({ safe: false })
expect(getSafeRelativePath('c:')).toEqual({ safe: false })
})
it('keeps colon-bearing paths that are not drive-qualified', () => {
expect(getSafeRelativePath('build:out')).toEqual({ safe: true, rel: 'build:out' })
expect(getSafeRelativePath('logs/2026-08-31T10:00.txt')).toEqual({
safe: true,
rel: 'logs/2026-08-31T10:00.txt'
})
})
// These pin the rest of the guard expression, which this commit rewrote:
// both `isAbsolute` calls were deleted as provably subsumed by the strip
// above plus the drive check, so their inputs must still be judged the same.
it('still strips leading separators of either flavour and keeps the remainder relative', () => {
expect(getSafeRelativePath('node_modules')).toEqual({ safe: true, rel: 'node_modules' })
expect(getSafeRelativePath(' .env ')).toEqual({ safe: true, rel: '.env' })
expect(getSafeRelativePath('/.env')).toEqual({ safe: true, rel: '.env' })
expect(getSafeRelativePath('\\.env')).toEqual({ safe: true, rel: '.env' })
expect(getSafeRelativePath('//srv/share/x')).toEqual({ safe: true, rel: 'srv/share/x' })
})
it('still rejects empty, whitespace-only, and parent-directory entries', () => {
expect(getSafeRelativePath('')).toEqual({ safe: false })
expect(getSafeRelativePath(' ')).toEqual({ safe: false })
expect(getSafeRelativePath('../secrets')).toEqual({ safe: false })
expect(getSafeRelativePath('safe/../../escape')).toEqual({ safe: false })
expect(getSafeRelativePath('..\\escape')).toEqual({ safe: false })
expect(getSafeRelativePath('foo\\..\\..\\escape')).toEqual({ safe: false })
})
})
describe('findExistingWorktreeSymlinkPaths', () => {
let root: string
let primary: string
let worktree: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-symlink-detection-'))
primary = join(root, 'primary')
worktree = join(root, 'worktree')
mkdirSync(primary)
mkdirSync(worktree)
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
// Why skipped on Windows rather than made platform-independent: the fixture
// needs a real on-disk directory named `C:`, which only POSIX allows. The
// Windows half of this guard is bound by the unit tests above, which run
// everywhere because they never touch the filesystem.
const posixIt = process.platform === 'win32' ? it.skip : it
posixIt('does not report a drive-qualified entry even when the literal path exists', async () => {
mkdirSync(join(worktree, 'C:'))
writeFileSync(join(primary, 'payload'), 'X=1\n')
symlinkSync(join(primary, 'payload'), join(worktree, 'C:', 'payload'))
symlinkSync(join(primary, 'payload'), join(worktree, 'C:payload'))
await expect(
findExistingWorktreeSymlinkPaths(worktree, ['C:/payload', 'C:\\payload', 'C:payload'])
).resolves.toEqual([])
})
})
+13 -4
View File
@@ -1,5 +1,5 @@
import { lstat } from 'node:fs/promises'
import { isAbsolute, resolve } from 'node:path'
import { resolve } from 'node:path'
// Why this is a leaf module rather than part of ipc/worktree-symlinks: status
// and review-creation need only the read-only "is this a symlink" question, and
@@ -8,16 +8,25 @@ import { isAbsolute, resolve } from 'node:path'
export type SafeRelativePathResult = { safe: true; rel: string } | { safe: false }
// Why a regex rather than `path.isAbsolute`: the strip below already removed
// every leading `/` and `\`, so the only rooted spelling that can still reach
// the guard is a Windows drive designator — and `win32.isAbsolute` misses the
// drive-RELATIVE form (`C:foo`), which `win32.resolve` still resolves against
// that drive's current directory instead of the worktree root.
const WINDOWS_DRIVE_DESIGNATOR = /^[a-zA-Z]:/
export function getSafeRelativePath(rawPath: string): SafeRelativePathResult {
// Why: strip leading separators (both `/` and `\`) before the guard so
// Windows-style input like `\foo` is normalized the same way POSIX `/foo`
// is, and the traversal check below sees the already-relative form.
const rel = rawPath.trim().replace(/^[\\/]+/, '')
// Why: split on both separators so a Windows-authored `..\escape` is
// rejected the same way POSIX `../escape` is. `path.isAbsolute` catches
// drive-letter absolutes (`C:\...`); the split catches relative
// rejected the same way POSIX `../escape` is; the split catches relative
// backslash traversal that `.split('/')` would otherwise miss.
if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
// Why the drive check runs on every host: the same entry — per-user Shared
// Paths setting or repo `orca.yaml` — is evaluated on every host Orca runs
// on, so the verdict must not depend on which one is asking.
if (!rel || WINDOWS_DRIVE_DESIGNATOR.test(rel) || rel.split(/[\\/]/).includes('..')) {
return { safe: false }
}
return { safe: true, rel }
@@ -0,0 +1,111 @@
// Route state dropped when a worktree mutation rewrites the `.git` marker it was derived from.
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
invalidateWslLinkedWorktreeGitRouting,
prepareWslLinkedWorktreeGitRouting,
resetWslLinkedWorktreeGitRoutingForTests,
type WslLinkedWorktreeRoutingFileSystem
} from './wsl-linked-worktree-git-routing'
afterEach(() => resetWslLinkedWorktreeGitRoutingForTests())
const CWD = String.raw`C:\repo`
const fileMarker = { isDirectory: () => false, isFile: () => true }
const directoryMarker = { isDirectory: () => true, isFile: () => false }
const hostGitdir = 'gitdir: C:/main/.git/worktrees/linked\n'
function prepare(fileSystem: WslLinkedWorktreeRoutingFileSystem): Promise<boolean> {
return prepareWslLinkedWorktreeGitRouting(CWD, 'Ubuntu', { platform: 'win32', fileSystem })
}
describe('invalidateWslLinkedWorktreeGitRouting', () => {
it('re-probes a settled route after the worktree marker is rewritten', async () => {
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi
.fn<WslLinkedWorktreeRoutingFileSystem['stat']>()
.mockResolvedValueOnce(directoryMarker)
.mockResolvedValueOnce(fileMarker),
readFile: vi.fn(async () => hostGitdir)
}
await expect(prepare(fileSystem)).resolves.toBe(false)
invalidateWslLinkedWorktreeGitRouting(CWD)
await expect(prepare(fileSystem)).resolves.toBe(true)
expect(fileSystem.stat).toHaveBeenCalledTimes(2)
})
it('clears the retry backoff so a marker that just appeared is probed immediately', async () => {
const currentTime = 1_000
let markerExists = false
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi.fn(async () => {
if (!markerExists) {
throw Object.assign(new Error('device unavailable'), { code: 'EIO' })
}
return fileMarker
}),
readFile: vi.fn(async () => hostGitdir)
}
const probe = (): Promise<boolean> =>
prepareWslLinkedWorktreeGitRouting(CWD, 'Ubuntu', {
platform: 'win32',
fileSystem,
now: () => currentTime
})
// The second miss enters the exponential retry window, so the third never probes.
await expect(probe()).resolves.toBe(false)
await expect(probe()).resolves.toBe(false)
await expect(probe()).resolves.toBe(false)
expect(fileSystem.stat).toHaveBeenCalledTimes(2)
markerExists = true
invalidateWslLinkedWorktreeGitRouting(CWD)
await expect(probe()).resolves.toBe(true)
expect(fileSystem.stat).toHaveBeenCalledTimes(3)
})
it('drops routes cached for paths inside the mutated worktree', async () => {
const submodule = String.raw`C:\repo\sub`
const sibling = String.raw`C:\repo-other`
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi.fn(async () => fileMarker),
readFile: vi.fn(async () => hostGitdir)
}
const probe = (cwd: string): Promise<boolean> =>
prepareWslLinkedWorktreeGitRouting(cwd, 'Ubuntu', { platform: 'win32', fileSystem })
await expect(probe(submodule)).resolves.toBe(true)
await expect(probe(sibling)).resolves.toBe(true)
invalidateWslLinkedWorktreeGitRouting(CWD)
// Prefix match must not reach `C:\repo-other`, which shares the string prefix.
await expect(probe(submodule)).resolves.toBe(true)
await expect(probe(sibling)).resolves.toBe(true)
expect(fileSystem.stat).toHaveBeenCalledTimes(3)
})
it('leaves a probe that is already in flight alone', async () => {
// Scope control: invalidation must not retire in-flight probes. Doing so leaves
// callers waiting on them with no cached route, which `resolveGitCommand` reads
// as "route through wsl.exe git" — the misroute this module exists to prevent.
let releaseMarker: ((marker: typeof fileMarker) => void) | undefined
const inFlight = new Promise<typeof fileMarker>((resolve) => {
releaseMarker = resolve
})
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi.fn<WslLinkedWorktreeRoutingFileSystem['stat']>().mockReturnValueOnce(inFlight),
readFile: vi.fn(async () => hostGitdir)
}
const first = prepare(fileSystem)
invalidateWslLinkedWorktreeGitRouting(CWD)
const joined = prepare(fileSystem)
releaseMarker?.(fileMarker)
await expect(first).resolves.toBe(true)
await expect(joined).resolves.toBe(true)
expect(fileSystem.stat).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,65 @@
import * as fsPromises from 'node:fs/promises'
import { win32 } from 'node:path'
import type { Stats } from 'node:fs'
export type WslLinkedWorktreeRoutingFileSystem = {
stat(path: string): Promise<Pick<Stats, 'isDirectory' | 'isFile'>>
readFile(path: string): Promise<string>
}
/** `known: false` means the marker exists but cannot be classified — what a half-written `.git` file looks like mid `worktree add`. */
export type WslLinkedWorktreeGitRouteProbeResult = {
usesHostGit: boolean
known: boolean
}
const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[/\\]/
function parseLinkedGitdir(content: string): string | null {
const firstLine = content.split(/\r?\n/, 1)[0] ?? ''
return firstLine.match(/^gitdir:\s*(\S.*?)\s*$/i)?.[1] ?? null
}
export function parseWindowsLinkedGitdir(content: string): string | null {
const gitdir = parseLinkedGitdir(content)
return gitdir !== null && WINDOWS_DRIVE_PATH.test(gitdir) ? gitdir : null
}
export const defaultWslLinkedWorktreeRoutingFileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: (path) => fsPromises.stat(path),
readFile: (path) => fsPromises.readFile(path, 'utf8')
}
/** Walk parent directories until Git's worktree marker identifies which Git owns this checkout. */
export async function probeWslLinkedWorktreeGitRoute(
cwd: string,
fileSystem: WslLinkedWorktreeRoutingFileSystem
): Promise<WslLinkedWorktreeGitRouteProbeResult> {
let candidate = cwd
const driveRoot = win32.parse(candidate).root
while (true) {
const markerPath = win32.join(candidate, '.git')
try {
const marker = await fileSystem.stat(markerPath)
if (!marker.isFile()) {
// A `.git` directory (or anything that is not a file) is a normal main checkout.
return { usesHostGit: false, known: true }
}
const gitdir = parseLinkedGitdir(await fileSystem.readFile(markerPath))
// A POSIX gitdir is a settled answer too: the distro owns this checkout.
return gitdir === null
? { usesHostGit: false, known: false }
: { usesHostGit: WINDOWS_DRIVE_PATH.test(gitdir), known: true }
} catch (error) {
const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : null
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
throw error
}
}
if (candidate === driveRoot) {
// No marker up to the drive root: not a worktree at all, and stable enough to cache.
return { usesHostGit: false, known: true }
}
candidate = win32.dirname(candidate)
}
}
@@ -123,6 +123,25 @@ describe('prepareWslLinkedWorktreeGitRouting', () => {
expect(fileSystem.readFile).not.toHaveBeenCalled()
})
it('re-probes a half-written marker rather than caching it as distro-owned', async () => {
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi.fn(async () => fileMarker),
readFile: vi
.fn<WslLinkedWorktreeRoutingFileSystem['readFile']>()
.mockResolvedValueOnce('')
.mockResolvedValueOnce('gitdir: C:/main/.git/worktrees/linked\n')
}
const prepare = (): Promise<boolean> =>
prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', {
platform: 'win32',
fileSystem
})
await expect(prepare()).resolves.toBe(false)
await expect(prepare()).resolves.toBe(true)
expect(fileSystem.stat).toHaveBeenCalledTimes(2)
})
it('fails closed without caching a marker read error', async () => {
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: vi.fn(async () => fileMarker),
+40 -53
View File
@@ -1,6 +1,14 @@
import * as fsPromises from 'node:fs/promises'
import { win32 } from 'node:path'
import type { Stats } from 'node:fs'
import {
defaultWslLinkedWorktreeRoutingFileSystem,
probeWslLinkedWorktreeGitRoute,
type WslLinkedWorktreeRoutingFileSystem
} from './wsl-linked-worktree-git-route-probe'
export {
parseWindowsLinkedGitdir,
type WslLinkedWorktreeRoutingFileSystem
} from './wsl-linked-worktree-git-route-probe'
type CachedRoute = { usesHostGit: boolean; expiresAt: number }
type TransientRouteFailure = { count: number; retryAfter: number }
@@ -46,16 +54,6 @@ function cachedRoute(cwd: string, now: number): boolean | undefined {
return exact.usesHostGit
}
export function parseWindowsLinkedGitdir(content: string): string | null {
const firstLine = content.split(/\r?\n/, 1)[0] ?? ''
return firstLine.match(/^gitdir:\s*([A-Za-z]:[/\\].*?)\s*$/i)?.[1] ?? null
}
export type WslLinkedWorktreeRoutingFileSystem = {
stat(path: string): Promise<Pick<Stats, 'isDirectory' | 'isFile'>>
readFile(path: string): Promise<string>
}
export type WslLinkedWorktreeRoutingOptions = {
platform?: NodeJS.Platform
fileSystem?: WslLinkedWorktreeRoutingFileSystem
@@ -63,41 +61,6 @@ export type WslLinkedWorktreeRoutingOptions = {
signal?: AbortSignal
}
const defaultFileSystem: WslLinkedWorktreeRoutingFileSystem = {
stat: (path) => fsPromises.stat(path),
readFile: (path) => fsPromises.readFile(path, 'utf8')
}
async function shouldUseHostGit(
cwd: string,
fileSystem: WslLinkedWorktreeRoutingFileSystem
): Promise<boolean> {
let candidate = cwd
const driveRoot = win32.parse(candidate).root
while (true) {
const markerPath = win32.join(candidate, '.git')
try {
const marker = await fileSystem.stat(markerPath)
if (marker.isDirectory()) {
return false
}
if (marker.isFile()) {
return parseWindowsLinkedGitdir(await fileSystem.readFile(markerPath)) !== null
}
return false
} catch (error) {
const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : null
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
throw error
}
}
if (candidate === driveRoot) {
return false
}
candidate = win32.dirname(candidate)
}
}
function rememberRoute(cwd: string, usesHostGit: boolean, now: number): boolean {
const expiresAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS
if (
@@ -119,6 +82,30 @@ function routingAbortError(): Error {
return Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
}
function forgetPathAndDescendants<T>(entries: Map<string, T>, root: string): void {
const prefix = root.endsWith(win32.sep) ? root : root + win32.sep
for (const path of entries.keys()) {
if (path === root || path.startsWith(prefix)) {
entries.delete(path)
}
}
}
/**
* Drop the cached route and retry backoff after Git adds, moves, or removes a
* worktree at `cwd`, so the next command re-reads the rewritten `.git` marker.
* Descendants go too: a submodule under the worktree resolved its route from the
* same marker walk.
*
* A probe already in flight is deliberately left alone: it still answers and still
* caches, so a mutation that lands mid-probe is no worse off than the 30s TTL.
*/
export function invalidateWslLinkedWorktreeGitRouting(cwd: string): void {
const normalizedCwd = normalize(cwd)
forgetPathAndDescendants(routeByCwd, normalizedCwd)
forgetPathAndDescendants(transientFailuresByCwd, normalizedCwd)
}
function rememberTransientFailure(cwd: string, now: number): void {
const count = (transientFailuresByCwd.get(cwd)?.count ?? 0) + 1
const retryDelay =
@@ -205,7 +192,7 @@ function discoverRoute(
return new Promise((resolve) => {
const generation = routeProbeGeneration
let settled = false
const finish = (usesHostGit: boolean, cacheable: boolean): void => {
const finish = (usesHostGit: boolean, known: boolean): void => {
if (settled) {
return
}
@@ -215,7 +202,7 @@ function discoverRoute(
resolve(false)
return
}
if (cacheable) {
if (known) {
transientFailuresByCwd.delete(cwd)
resolve(rememberRoute(cwd, usesHostGit, now()))
} else {
@@ -226,10 +213,10 @@ function discoverRoute(
const timer = setTimeout(() => finish(false, false), WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS)
timer.unref()
const releaseProbe = trackRouteProbe(cwd)
void shouldUseHostGit(cwd, fileSystem).then(
(usesHostGit) => {
void probeWslLinkedWorktreeGitRoute(cwd, fileSystem).then(
({ usesHostGit, known }) => {
releaseProbe()
finish(usesHostGit, true)
finish(usesHostGit, known)
},
() => {
releaseProbe()
@@ -245,7 +232,7 @@ export async function prepareWslLinkedWorktreeGitRouting(
options: WslLinkedWorktreeRoutingOptions = {}
): Promise<boolean> {
const platform = options.platform ?? process.platform
const fileSystem = options.fileSystem ?? defaultFileSystem
const fileSystem = options.fileSystem ?? defaultWslLinkedWorktreeRoutingFileSystem
const now = options.now ?? Date.now
if (!isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform)) {
return false
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WslModule from '../wsl'
const { execFileMock, execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
getDefaultWslDistroMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock,
spawn: spawnMock
}))
vi.mock('../wsl', async (importOriginal) => ({
...(await importOriginal<typeof WslModule>()),
getDefaultWslDistro: getDefaultWslDistroMock
}))
import { setDefaultWslDistroOverride } from '../git/runner'
import { _resetKnownHostsCache, getGlabKnownHosts } from './gitlab-known-host-probe'
describe('glab known-hosts probe on Windows', () => {
const originalPlatform = process.platform
const hostGlabMissingWslLoggedIn = (): void => {
execFileMock.mockImplementation((binary, _args, _options, callback) => {
if (binary === 'wsl.exe') {
callback(null, { stdout: 'Logged in to gitlab.wsl.test as user', stderr: '' })
return
}
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
})
}
beforeEach(() => {
execFileMock.mockReset()
spawnMock.mockReset()
getDefaultWslDistroMock.mockReset()
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
setDefaultWslDistroOverride(null)
_resetKnownHostsCache()
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
})
afterEach(() => {
setDefaultWslDistroOverride(null)
_resetKnownHostsCache()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
it('does not wake the default WSL distro for the native execution key', async () => {
hostGlabMissingWslLoggedIn()
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock).toHaveBeenCalledWith(
'glab',
['auth', 'status'],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
)
})
// Why: glab has no SSH/relay dispatch, so a connection-keyed probe and the
// `glab api` calls it gates both run the local CLI with no cwd. Suppressing the
// fallback here would make the probe disagree with those calls.
it('keeps the default-distro fallback for a connection execution key', async () => {
hostGlabMissingWslLoggedIn()
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'gitlab.wsl.test'])
expect(execFileMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
)
})
})
@@ -0,0 +1,293 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { glabExecFileAsyncMock } = vi.hoisted(() => ({ glabExecFileAsyncMock: vi.fn() }))
vi.mock('../git/runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock }))
import {
_resetKnownHostsCache,
getGlabKnownHosts,
rememberGlabKnownHost,
rememberGlabKnownHosts
} from './gitlab-known-host-probe'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('getGlabKnownHosts', () => {
// A locally-keyed probe must never answer from the default WSL distro.
const LOCAL_PROBE_OPTIONS = { timeout: 10_000, allowDefaultWslFallback: false }
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
_resetKnownHostsCache()
})
it('returns gitlab.com plus auth-status hosts, deduped', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as user\n✓ Logged in to gitlab.example.com as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com'])
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], LOCAL_PROBE_OPTIONS)
})
it('preserves WSL and background admission on the cold auth-status probe', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
await getGlabKnownHosts(undefined, {
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
...LOCAL_PROBE_OPTIONS,
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
})
it('falls back to default when glab auth status fails', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated'))
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
})
it('caches the result across calls', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as user\n',
stderr: ''
})
await getGlabKnownHosts()
await getGlabKnownHosts()
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('coalesces many simultaneous callers in one execution context', async () => {
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
glabExecFileAsyncMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveProbe = resolve
})
)
const probes = Array.from({ length: 64 }, () => getGlabKnownHosts())
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
resolveProbe({ stdout: 'Logged in to gitlab.concurrent.test as user\n', stderr: '' })
const results = await Promise.all(probes)
expect(results.every((result) => result === results[0])).toBe(true)
expect(results[0]).toEqual(['gitlab.com', 'gitlab.concurrent.test'])
})
it('keeps simultaneous native, WSL distro, and connection probes isolated', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'Logged in to ubuntu.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to debian.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
const [ubuntu, ubuntuAgain, debian, native, ssh] = await Promise.all([
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
getGlabKnownHosts(undefined, { wslDistro: 'Debian' }),
getGlabKnownHosts(),
getGlabKnownHosts('conn-1')
])
expect(ubuntuAgain).toBe(ubuntu)
expect(ubuntu).toEqual(['gitlab.com', 'ubuntu.test'])
expect(debian).toEqual(['gitlab.com', 'debian.test'])
expect(native).toEqual(['gitlab.com', 'native.test'])
expect(ssh).toEqual(['gitlab.com', 'ssh.test'])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4)
expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], {
...LOCAL_PROBE_OPTIONS,
wslDistro: 'Ubuntu'
})
expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], {
...LOCAL_PROBE_OPTIONS,
wslDistro: 'Debian'
})
})
it('preserves a native auth refresh while an older native probe is in flight', async () => {
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
glabExecFileAsyncMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveProbe = resolve
})
)
const staleProbe = getGlabKnownHosts()
rememberGlabKnownHost('gitlab.refreshed.test')
resolveProbe({ stdout: 'Logged in to gitlab.com as user\n', stderr: '' })
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
})
it('preserves a native auth refresh when an older native probe fails', async () => {
let rejectProbe!: (error: Error) => void
glabExecFileAsyncMock.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectProbe = reject
})
)
const staleProbe = getGlabKnownHosts()
rememberGlabKnownHost('gitlab.refreshed.test')
rejectProbe(new Error('stale auth probe failed'))
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
})
it('keeps a remembered native host out of WSL and SSH caches', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to wsl.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
await Promise.all([
getGlabKnownHosts(),
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
getGlabKnownHosts('conn-1')
])
rememberGlabKnownHost('gitlab.refreshed.test')
await expect(getGlabKnownHosts()).resolves.toEqual([
'gitlab.com',
'native.test',
'gitlab.refreshed.test'
])
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
'gitlab.com',
'wsl.test'
])
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'ssh.test'])
})
it('batch-normalizes and deduplicates hosts in first-seen order per execution context', async () => {
rememberGlabKnownHosts([' Native-B.test ', 'native-a.test', 'NATIVE-B.TEST'])
rememberGlabKnownHosts(['WSL-B.test', ' wsl-a.test ', 'wsl-b.test'], undefined, {
wslDistro: 'Ubuntu'
})
rememberGlabKnownHosts(['SSH-B.test', 'ssh-a.test', ' ssh-b.test '], 'conn-batch')
await expect(getGlabKnownHosts()).resolves.toEqual([
'gitlab.com',
'native-b.test',
'native-a.test'
])
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
'gitlab.com',
'wsl-b.test',
'wsl-a.test'
])
await expect(getGlabKnownHosts('conn-batch')).resolves.toEqual([
'gitlab.com',
'ssh-b.test',
'ssh-a.test'
])
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('recognizes a self-hosted host on a non-default port', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
})
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
// A second probe for the same connection is served from cache.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
glabExecFileAsyncMock
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
// First probe fails → canonical default, NOT cached.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
// Re-probe (e.g. after tunnel comes up) discovers the real host.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('removes a timed-out probe from in-flight state so a later call retries', async () => {
let rejectProbe!: (error: Error) => void
glabExecFileAsyncMock
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectProbe = reject
})
)
.mockResolvedValueOnce({ stdout: 'Logged in to recovered.test as user\n', stderr: '' })
const first = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
const concurrent = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
rejectProbe(new Error('wsl.exe timed out.'))
await expect(Promise.all([first, concurrent])).resolves.toEqual([
['gitlab.com'],
['gitlab.com']
])
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
'gitlab.com',
'recovered.test'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not reuse a successful result after an SSH provider reconnects', async () => {
const connectionId = 'conn-reconnected'
registerSshGitProvider(connectionId, {} as never)
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' })
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
'gitlab.com',
'old-tunnel.test'
])
registerSshGitProvider(connectionId, {} as never)
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
'gitlab.com',
'new-tunnel.test'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
unregisterSshGitProvider(connectionId)
})
})
@@ -150,6 +150,10 @@ async function probeGlabKnownHosts(
// or reconnected SSH/relay results, and bound an otherwise global probe.
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], {
timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS,
// Why: a local probe would cache the default distro's auth under 'native'
// and wake an idle VM. A connection-keyed probe keeps the retry — glab never
// runs over SSH/relay, so the calls it gates take that same fallback.
...(connectionId ? {} : { allowDefaultWslFallback: false }),
...(!connectionId && localGitOptions.wslDistro
? { wslDistro: localGitOptions.wslDistro }
: {}),
-280
View File
@@ -13,7 +13,6 @@ vi.mock('../git/runner', () => ({
import {
_getProjectRefCacheSize,
_resetKnownHostsCache,
_resetProjectRefCache,
classifyGlabError,
classifyJobLogError,
@@ -26,7 +25,6 @@ import {
parseGlabJsonList,
parseGlabPaginationHeader,
isMissingJobLogError,
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
parseGlabApiResponse,
@@ -34,7 +32,6 @@ import {
resolveIssueSource
} from './gl-utils'
import { GlabNonListResponseError } from './glab-api-response'
import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
@@ -647,283 +644,6 @@ describe('parseGlabApiResponse', () => {
})
})
describe('getGlabKnownHosts', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
_resetKnownHostsCache()
})
it('returns gitlab.com plus auth-status hosts, deduped', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as user\n✓ Logged in to gitlab.example.com as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com'])
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 })
})
it('preserves WSL and background admission on the cold auth-status probe', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
await getGlabKnownHosts(undefined, {
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
timeout: 10_000,
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
})
it('falls back to default when glab auth status fails', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated'))
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
})
it('caches the result across calls', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as user\n',
stderr: ''
})
await getGlabKnownHosts()
await getGlabKnownHosts()
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('coalesces many simultaneous callers in one execution context', async () => {
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
glabExecFileAsyncMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveProbe = resolve
})
)
const probes = Array.from({ length: 64 }, () => getGlabKnownHosts())
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
resolveProbe({ stdout: 'Logged in to gitlab.concurrent.test as user\n', stderr: '' })
const results = await Promise.all(probes)
expect(results.every((result) => result === results[0])).toBe(true)
expect(results[0]).toEqual(['gitlab.com', 'gitlab.concurrent.test'])
})
it('keeps simultaneous native, WSL distro, and connection probes isolated', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'Logged in to ubuntu.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to debian.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
const [ubuntu, ubuntuAgain, debian, native, ssh] = await Promise.all([
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
getGlabKnownHosts(undefined, { wslDistro: 'Debian' }),
getGlabKnownHosts(),
getGlabKnownHosts('conn-1')
])
expect(ubuntuAgain).toBe(ubuntu)
expect(ubuntu).toEqual(['gitlab.com', 'ubuntu.test'])
expect(debian).toEqual(['gitlab.com', 'debian.test'])
expect(native).toEqual(['gitlab.com', 'native.test'])
expect(ssh).toEqual(['gitlab.com', 'ssh.test'])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4)
expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], {
timeout: 10_000,
wslDistro: 'Ubuntu'
})
expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], {
timeout: 10_000,
wslDistro: 'Debian'
})
})
it('preserves a native auth refresh while an older native probe is in flight', async () => {
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
glabExecFileAsyncMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveProbe = resolve
})
)
const staleProbe = getGlabKnownHosts()
rememberGlabKnownHost('gitlab.refreshed.test')
resolveProbe({ stdout: 'Logged in to gitlab.com as user\n', stderr: '' })
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
})
it('preserves a native auth refresh when an older native probe fails', async () => {
let rejectProbe!: (error: Error) => void
glabExecFileAsyncMock.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectProbe = reject
})
)
const staleProbe = getGlabKnownHosts()
rememberGlabKnownHost('gitlab.refreshed.test')
rejectProbe(new Error('stale auth probe failed'))
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
})
it('keeps a remembered native host out of WSL and SSH caches', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to wsl.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
await Promise.all([
getGlabKnownHosts(),
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
getGlabKnownHosts('conn-1')
])
rememberGlabKnownHost('gitlab.refreshed.test')
await expect(getGlabKnownHosts()).resolves.toEqual([
'gitlab.com',
'native.test',
'gitlab.refreshed.test'
])
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
'gitlab.com',
'wsl.test'
])
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'ssh.test'])
})
it('batch-normalizes and deduplicates hosts in first-seen order per execution context', async () => {
rememberGlabKnownHosts([' Native-B.test ', 'native-a.test', 'NATIVE-B.TEST'])
rememberGlabKnownHosts(['WSL-B.test', ' wsl-a.test ', 'wsl-b.test'], undefined, {
wslDistro: 'Ubuntu'
})
rememberGlabKnownHosts(['SSH-B.test', 'ssh-a.test', ' ssh-b.test '], 'conn-batch')
await expect(getGlabKnownHosts()).resolves.toEqual([
'gitlab.com',
'native-b.test',
'native-a.test'
])
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
'gitlab.com',
'wsl-b.test',
'wsl-a.test'
])
await expect(getGlabKnownHosts('conn-batch')).resolves.toEqual([
'gitlab.com',
'ssh-b.test',
'ssh-a.test'
])
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('recognizes a self-hosted host on a non-default port', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
})
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
// A second probe for the same connection is served from cache.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
glabExecFileAsyncMock
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
// First probe fails → canonical default, NOT cached.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
// Re-probe (e.g. after tunnel comes up) discovers the real host.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('removes a timed-out probe from in-flight state so a later call retries', async () => {
let rejectProbe!: (error: Error) => void
glabExecFileAsyncMock
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectProbe = reject
})
)
.mockResolvedValueOnce({ stdout: 'Logged in to recovered.test as user\n', stderr: '' })
const first = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
const concurrent = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
rejectProbe(new Error('wsl.exe timed out.'))
await expect(Promise.all([first, concurrent])).resolves.toEqual([
['gitlab.com'],
['gitlab.com']
])
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
'gitlab.com',
'recovered.test'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not reuse a successful result after an SSH provider reconnects', async () => {
const connectionId = 'conn-reconnected'
registerSshGitProvider(connectionId, {} as never)
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' })
.mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' })
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
'gitlab.com',
'old-tunnel.test'
])
registerSshGitProvider(connectionId, {} as never)
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
'gitlab.com',
'new-tunnel.test'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
unregisterSshGitProvider(connectionId)
})
})
describe('parseGlabPaginationHeader', () => {
it('reads a usable header value', () => {
expect(parseGlabPaginationHeader('25', 1)).toBe(25)
+5
View File
@@ -10,6 +10,11 @@ import {
isRegisteredWorktreePath
} from './registered-worktree-roots-cache'
// Compatibility exports for runtime command modules that historically imported these seams from
// filesystem-auth. The implementations remain owned by their focused modules.
export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
export { isENOENT } from './filesystem-path-containment'
export const PATH_ACCESS_DENIED_MESSAGE =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating.
@@ -361,17 +361,13 @@ describe('registerPtyHandlers', () => {
// resolved-worktree cache so this headless fixture resolves offline.
const worktreeResolutionInternals = runtime as unknown as {
buildResolvedWorktreeFromId(id: string): unknown
resolvedWorktreeCache: {
worktrees: unknown[]
platformByRepoId: Map<string, NodeJS.Platform>
expiresAt: number
} | null
resolvedWorktrees: object
}
worktreeResolutionInternals.resolvedWorktreeCache = {
Reflect.set(worktreeResolutionInternals.resolvedWorktrees, 'resolved', {
worktrees: [worktreeResolutionInternals.buildResolvedWorktreeFromId(worktreeId)],
platformByRepoId: new Map([[repo.id, process.platform]]),
expiresAt: Date.now() + 60_000
}
})
setLocalPtyProvider({
spawn: vi.fn(async () => ({
id: ptyId,
+10 -59
View File
@@ -38,7 +38,10 @@ import {
import { getBranchConflictKindViaExec } from '../git/repo-branch-conflict'
import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe'
import {
hasLocalWorktreeBaseRef,
probeWorktreeBaseRefPresence
} from '../git/worktree-base-ref-probe'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
@@ -718,46 +721,6 @@ function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean {
return Object.keys(gitOptions).length > 0
}
function hasLocalCommitObjectWithOptions(
repoPath: string,
ref: string,
gitOptions: { wslDistro?: string }
): Promise<boolean> {
return hasCommitObjectViaGitExec(
(gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...gitOptions }),
ref
)
}
async function hasLocalWorktreeBaseRefWithOptions(
repoPath: string,
baseRef: string,
gitOptions: { wslDistro?: string }
): Promise<boolean> {
const refExists = async (qualifiedRef: string) => {
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
{
cwd: repoPath,
...gitOptions
}
)
return stdout.trim().length > 0
} catch {
return false
}
}
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
if (resolvedBaseRef !== baseRef) {
return true
}
if (baseRef.startsWith('refs/')) {
return refExists(baseRef)
}
return hasLocalCommitObjectWithOptions(repoPath, baseRef, gitOptions)
}
function getLocalGitHubPrForBranch(
repoPath: string,
branchName: string,
@@ -2066,14 +2029,10 @@ export async function createLocalWorktree(
) {
return true
}
return hasLocalWorktreeBaseRefWithOptions(
repo.path,
baseBranchCandidate,
localGitExecOptions
)
return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate, localGitExecOptions)
}
}
return hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranchCandidate, localGitExecOptions)
return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate, localGitExecOptions)
}
})
const [username, resolvedBaseBranch] = await Promise.all([usernamePromise, baseBranchPromise])
@@ -2103,15 +2062,11 @@ export async function createLocalWorktree(
if (remoteTrackingBase) {
const [hasRemoteTrackingBaseRef, hasNamedLocalBaseRef] = await Promise.all([
runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs),
hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions)
hasLocalWorktreeBaseRef(repo.path, baseBranch, localGitExecOptions)
])
const hasFallbackLocalBaseRef =
!hasNamedLocalBaseRef &&
(await hasLocalWorktreeBaseRefWithOptions(
repo.path,
remoteTrackingBase.branch,
localGitExecOptions
))
(await hasLocalWorktreeBaseRef(repo.path, remoteTrackingBase.branch, localGitExecOptions))
const hasLocalBaseRef =
hasRemoteTrackingBaseRef || hasNamedLocalBaseRef || hasFallbackLocalBaseRef
if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) {
@@ -2136,9 +2091,7 @@ export async function createLocalWorktree(
)
}
}
} else if (
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
) {
} else if (!(await hasLocalWorktreeBaseRef(repo.path, baseBranch, localWorktreeGitOptions))) {
// Why: non-remote-prefix bases (plain main/master/local) keep the legacy best-effort fetch; verified PR SHA bases already have the object.
legacyFetchPromise = runtime
.fetchRemoteWithCache(repo.path, 'origin', ...localWorktreeGitOptionArgs)
@@ -2147,9 +2100,7 @@ export async function createLocalWorktree(
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
} else {
if (
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
) {
if (!(await hasLocalWorktreeBaseRef(repo.path, baseBranch, localWorktreeGitOptions))) {
legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], {
...localGitExecOptions,
timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS
+11 -9
View File
@@ -76,6 +76,15 @@ export {
type HandlerMap
} from './worktrees-test-ipc-surface'
/** The single repo every worktree harness test resolves; exported so a test can vary one field. */
export const harnessRepo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
/** Registers worktree IPC handlers against freshly reset shared mocks and returns the runtime stub. */
export function setupWorktreeHandlers(): WorktreeRuntimeStub {
delete (store as typeof store & { getAllWorktreeMetaForHost?: (...args: unknown[]) => unknown })
@@ -185,15 +194,8 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
handlers[channel] = handler
})
const repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null })
store.getRepos.mockReturnValue([harnessRepo])
store.getRepo.mockReturnValue({ ...harnessRepo, worktreeBaseRef: null })
store.getProjects.mockReturnValue([])
store.getSparsePresets.mockReturnValue([])
const settings = {
@@ -13,9 +13,11 @@ import {
createSetupRunnerScriptMock,
getEffectiveHooksFromConfigMock,
shouldRunSetupForCreateMock,
getBaseRefDefaultMock,
gitExecFileAsyncMock
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { handlers, harnessRepo, setupWorktreeHandlers, store } from './worktrees-test-harness'
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
import {
createdWorktreeList,
mockKnownFeatureWorktree,
@@ -104,9 +106,65 @@ vi.mock('../runtime/worktree-teardown', async () =>
)
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
/** Every create-path git call, including the base-ref probe now shared with the
* speculative prefetch, must read the distro's ref store rather than host git's. */
function expectEveryGitCallRoutedTo(wslDistro: string): void {
const callDistros = new Set(
gitExecFileAsyncMock.mock.calls.map(
([, options]) => (options as { wslDistro?: string } | undefined)?.wslDistro
)
)
expect(callDistros).toEqual(new Set([wslDistro]))
}
describe('registerWorktreeHandlers', () => {
let runtimeStub: WorktreeRuntimeStub
beforeEach(() => {
setupWorktreeHandlers()
runtimeStub = setupWorktreeHandlers()
})
it('routes the speculative create-base prefetch through the selected WSL project runtime', async () => {
mockSelectedWslProjectRuntime()
const remoteTrackingBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteTrackingBase)
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
)
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
'/workspace/repo',
'origin/main',
{ wslDistro: 'Ubuntu' }
)
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
'/workspace/repo',
remoteTrackingBase,
{ wslDistro: 'Ubuntu' }
)
})
it('routes the prefetch remote-fetch fallback through the selected WSL project runtime', async () => {
mockSelectedWslProjectRuntime()
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
await handlers['worktrees:prefetchCreateBase'](null, {
repoId: 'repo-1',
baseBranch: 'feature/topic'
})
expect(runtimeStub.fetchRemoteWithCache).toHaveBeenCalledWith('/workspace/repo', 'origin', {
wslDistro: 'Ubuntu'
})
})
it('routes local worktree creation through the selected WSL project runtime', async () => {
@@ -153,6 +211,36 @@ describe('registerWorktreeHandlers', () => {
{ wslDistro: 'Ubuntu' }
)
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
expectEveryGitCallRoutedTo('Ubuntu')
})
it('routes local worktree creation with a remote tracking base through the selected WSL project runtime', async () => {
mockSelectedWslProjectRuntime()
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue({
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
})
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'abc123\n', stderr: '' })
// A persisted base that differs from the detected default also drives the usability probe.
store.getRepo.mockReturnValue({ ...harnessRepo, worktreeBaseRef: 'custom-base' })
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
expectEveryGitCallRoutedTo('Ubuntu')
})
it('routes fork push target setup through the selected WSL project runtime', async () => {
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch'
import { prepareWorktreeCreateForRepo } from '../../../worktree-create-preparation'
import { getWorktreeCreatePrefetchGitOptions } from '../../../project-runtime-git-options'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void {
@@ -17,7 +18,8 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo
const baseBranch = await prefetchWorktreeCreateBase({
repo,
baseBranch: args.baseBranch,
runtime
runtime,
gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo)
})
if (baseBranch) {
await prepareWorktreeCreateForRepo(store, repo, baseBranch)
+51 -1
View File
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Store } from './persistence'
import type { Project } from '../shared/project-types'
import type { Repo } from '../shared/repo-types'
import {
getLocalProjectGitExecOptions,
getWorktreeCreatePrefetchGitOptions,
getWorktreeMirrorDistro,
resolveLocalProjectRuntimeForRepo
} from './project-runtime-git-options'
@@ -176,6 +177,55 @@ describe('project runtime git options', () => {
})
})
describe('getWorktreeCreatePrefetchGitOptions', () => {
it('routes the warm-up through the distro a resolved WSL project runs in', () => {
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
const project = makeProject({
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
})
expect(
withPlatform('win32', () =>
getWorktreeCreatePrefetchGitOptions(makeStore(project), makeRepo())
)
).toEqual({ wslDistro: 'Ubuntu' })
})
// A speculative warm-up must degrade to the host Git it used before routing
// existed, never surface the repair state git execution raises.
it('falls back to host git instead of throwing when the runtime needs repair', () => {
_setWslCachesForTests({ available: true, distros: ['Debian'] })
const project = makeProject({
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
})
expect(
withPlatform('win32', () =>
getWorktreeCreatePrefetchGitOptions(makeStore(project), makeRepo())
)
).toEqual({})
})
it('does not resolve a project runtime for folder workspaces', () => {
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
const project = makeProject({
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
})
const store = makeStore(project)
const getProjects = vi.fn(store.getProjects)
expect(
withPlatform('win32', () =>
getWorktreeCreatePrefetchGitOptions(
{ ...store, getProjects } as unknown as Store,
makeRepo({ kind: 'folder' })
)
)
).toEqual({})
expect(getProjects).not.toHaveBeenCalled()
})
})
it('does not apply local Windows runtime routing to runtime-owned repos', () => {
const project = makeProject({
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
+23
View File
@@ -1,5 +1,6 @@
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { isFolderRepo } from '../shared/repo-kind'
import {
resolveLocalProjectRuntimeForRepo,
type ProjectRuntimeResolutionStore
@@ -58,6 +59,28 @@ export function getLocalProjectWorktreeGitOptions(
return wslDistro ? { wslDistro } : {}
}
/**
* Git routing for the speculative worktree-create warm-up.
*
* Deliberately non-throwing where `getLocalProjectWorktreeGitOptions` throws: an
* optimistic prefetch must not report a repair-required runtime as a failure, so
* an unresolved runtime falls back to the host Git the warm-up used before
* routing existed.
*/
export function getWorktreeCreatePrefetchGitOptions(
store: Store,
repo: Repo
): LocalProjectWorktreeGitOptions {
if (isFolderRepo(repo)) {
return {}
}
const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo)
if (!projectRuntime || projectRuntime.status !== 'resolved') {
return {}
}
return getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime)
}
export function getLocalProjectWorktreeGitOptionsForRuntime(
repo: Repo,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
@@ -177,4 +177,25 @@ describe('scoped automationsChanged publication', () => {
)
expect(published).toEqual([{ reason: 'definition', selector: { kind: 'self' } }])
})
// Why: an unnameable destination is exactly when scoping is unsafe — a subscriber scoped
// elsewhere would never hear about the row it is still rendering. The publication has to
// degrade to one unscoped authority event rather than name only the stale source.
it('degrades to an unscoped event when the store cannot name the destination', async () => {
const { store, runtime, published } = await makeRuntime()
const selector = store.automationChangeSelector.bind(store)
let updated = false
vi.spyOn(store, 'automationChangeSelector').mockImplementation((id: string) =>
updated ? null : selector(id)
)
const update = runtime.updateAutomation(
'local-1',
{ enabled: false },
{ expectedOwner: { selector: { kind: 'self' } } }
)
updated = true
await update
expect(published).toEqual([{ reason: 'definition' }])
})
})
@@ -1,6 +1,6 @@
// Every browser-tab placement must publish the created tab through one seam. A placement that
// hand-rolls its own bookkeeping is how a client-placed page once lost its targetGroupId.
import { readFileSync } from 'node:fs'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
@@ -91,7 +91,11 @@ function createPublicationHost(registeredTabs: readonly [string, number][] = [['
}
function browserCommandsSource(): string {
return readFileSync(join(__dirname, 'orca-runtime-browser.ts'), 'utf8')
return readdirSync(__dirname)
.filter((name) => /^runtime-browser-commands-.*\.ts$/.test(name))
.sort()
.map((name) => readFileSync(join(__dirname, name), 'utf8'))
.join('\n')
}
describe('publishCreatedBrowserSessionTab', () => {
@@ -38,7 +38,7 @@ const LIST_CALL = /\.listClientHostedBrowserRows\(/g
describe('client-hosted row hydration caller census', () => {
it('keeps the hydration delivery to its one runtime caller', async () => {
expect(await countCallSites(DELIVER_CALL)).toEqual({
'src/main/runtime/orca-runtime.ts': 1
'src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts': 1
})
})
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
ClientHostedPageReconciliationWindow,
@@ -132,7 +133,7 @@ describe('holdFor', () => {
// answer, which is invisible to any behavioral test that does not happen to cover that call site.
describe('session-tabs projection census', () => {
it('routes every client projection in orca-runtime through the per-client seam', () => {
const source = readFileSync(new URL('./orca-runtime.ts', import.meta.url), 'utf8')
const source = readOrcaRuntimeSourceFamily()
const direct = source.match(/this\.clientSessionTabSelections\.project\(/g) ?? []
// Exactly two: inside `projectMobileSessionTabsForClient` itself, and the removed-worktree
@@ -142,8 +143,24 @@ describe('session-tabs projection census', () => {
})
it('keeps the unreconciled flag out of every other runtime publication site', () => {
const source = readFileSync(new URL('./orca-runtime.ts', import.meta.url), 'utf8')
const source = readOrcaRuntimeSourceFamily()
expect(source).not.toContain('clientHostedPagesUnreconciled')
})
})
function readOrcaRuntimeSourceFamily(): string {
return readdirSync(import.meta.dirname)
.filter(
(name) =>
(name === 'orca-runtime.ts' || name.startsWith('orca-runtime-')) &&
name.endsWith('.ts') &&
!name.includes('.test.') &&
!name.endsWith('-fixtures.ts') &&
!name.endsWith('-test-harness.ts') &&
!name.endsWith('-mock-registry.ts')
)
.sort()
.map((name) => readFileSync(join(import.meta.dirname, name), 'utf8'))
.join('\n')
}
@@ -11,6 +11,7 @@ import type {
} from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { OrcaRuntimeService } from './orca-runtime'
import * as mobileSessionTerminalProjection from './mobile-session-terminal-projection'
// Freshness predicate of shouldApplyWebSessionTabsSnapshot in
// src/renderer/src/runtime/web-session-tabs-sync.ts, copied as a literal
@@ -147,7 +148,6 @@ function makeRendererSnapshot(args: {
}
type RuntimeInternals = {
buildHeadlessMobileSessionTerminalTabs: (...args: unknown[]) => unknown[]
offscreenBrowserBackend: unknown
agentBrowserBridge: unknown
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
@@ -210,13 +210,13 @@ describe('graph-sync mobile snapshot gating', () => {
})
it('skips the serve-only hydrate rebuild and emits nothing when no serve ptys and no browser backend', () => {
const { runtime, events, sync } = createRuntime(
const { events, sync } = createRuntime(
makeSession({
tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] }
})
)
const buildSpy = vi.spyOn(
runtime as unknown as RuntimeInternals,
mobileSessionTerminalProjection,
'buildHeadlessMobileSessionTerminalTabs'
)
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'
import type { MobileNotificationEvent } from './orca-runtime'
import type { MobileNotificationEvent } from './runtime-mobile-notification-controller'
// Why: when a mobile client's socket is reaped (background/sleep, or a warm
// proxy that delays the heartbeat reap), notifications dispatched in that
@@ -0,0 +1,80 @@
import type { RuntimeMobileSessionBrowserTab } from '../../shared/runtime-types'
import { sameRuntimeBrowserPlacement } from '../../shared/runtime-browser-placement'
// Why: change detection for headless browser tabs. Compares the fields that
// actually vary (a JSON.stringify equality was order-sensitive and silently
// dropped `undefined` keys, so it only worked while both sides shared one
// construction path).
export function headlessBrowserTabsUnchanged(
live: RuntimeMobileSessionBrowserTab[],
existing: RuntimeMobileSessionBrowserTab[]
): boolean {
if (live.length !== existing.length) {
return false
}
return live.every((tab, index) => {
const prev = existing[index]
return (
tab.id === prev.id &&
tab.title === prev.title &&
tab.url === prev.url &&
tab.loading === prev.loading &&
tab.canGoBack === prev.canGoBack &&
tab.canGoForward === prev.canGoForward &&
tab.browserProfileId === prev.browserProfileId &&
tab.executionHostKey === prev.executionHostKey &&
((tab.placement === undefined && prev.placement === undefined) ||
(tab.placement !== undefined &&
prev.placement !== undefined &&
sameRuntimeBrowserPlacement(tab.placement, prev.placement))) &&
tab.isActive === prev.isActive &&
(tab.isPinned ?? false) === (prev.isPinned ?? false) &&
(tab.color ?? null) === (prev.color ?? null) &&
browserLoadErrorsEqual(tab.loadError, prev.loadError) &&
browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure)
)
})
}
export function browserLoadErrorsEqual(
a: RuntimeMobileSessionBrowserTab['loadError'],
b: RuntimeMobileSessionBrowserTab['loadError']
): boolean {
const left = a ?? null
const right = b ?? null
if (left === right) {
return true
}
if (!left || !right) {
return false
}
return (
left.code === right.code &&
left.description === right.description &&
left.validatedUrl === right.validatedUrl
)
}
export function browserCertificateFailuresEqual(
a: RuntimeMobileSessionBrowserTab['certificateFailure'],
b: RuntimeMobileSessionBrowserTab['certificateFailure']
): boolean {
const left = a ?? null
const right = b ?? null
if (left === right) {
return true
}
if (!left || !right) {
return false
}
return (
left.challengeId === right.challengeId &&
left.browserPageId === right.browserPageId &&
left.errorCode === right.errorCode &&
left.error === right.error &&
left.origin === right.origin &&
left.displayHost === right.displayHost &&
left.canProceed === right.canProceed &&
left.observedAt === right.observedAt
)
}
@@ -0,0 +1,66 @@
import type { RuntimeMobileSessionTabGroup } from '../../shared/runtime-types'
// Why: browser session tabs have no parentTabId so the terminal-only group
// builder drops them from tabOrder; this re-adds their ids to a group.
// Browser tabs are live-only (no persisted session entry), but their GROUP
// membership must still survive snapshot rebuilds like terminals'. The
// passed-in groups already encode each browser's group (carried from the prior
// snapshot / persisted tabGroups), so keep each existing browser id where it
// is; only a genuinely-new browser id goes to its create-target group (when
// that group exists) and otherwise to the first group. Previously every
// browser was force-pushed into group[0], so opening a browser in the right
// split group always snapped it back to the left on the next rebuild.
export function appendBrowserTabOrder(
groups: readonly RuntimeMobileSessionTabGroup[],
browserTabIds: readonly string[],
newTabAssignment?: { tabId: string; groupId: string },
// browserPageId -> groupId from the prior/persisted groups. The terminal
// distributor rebuilds tabOrder from terminal ids only and drops browser
// ids, so this carries each browser's group across rebuilds.
priorGroupByBrowserId?: ReadonlyMap<string, string>
): RuntimeMobileSessionTabGroup[] {
if (browserTabIds.length === 0) {
return [...groups]
}
const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
if (next.length === 0) {
return next
}
const groupById = new Map(next.map((group) => [group.id, group]))
const ownerGroupByTabId = new Map<string, RuntimeMobileSessionTabGroup>()
for (const group of next) {
for (const id of group.tabOrder) {
ownerGroupByTabId.set(id, group)
}
}
for (const id of browserTabIds) {
if (ownerGroupByTabId.has(id)) {
continue
}
const priorGroupId = priorGroupByBrowserId?.get(id)
const targetGroup =
(newTabAssignment?.tabId === id ? groupById.get(newTabAssignment.groupId) : undefined) ??
(priorGroupId ? groupById.get(priorGroupId) : undefined) ??
next[0]!
targetGroup.tabOrder.push(id)
}
return next
}
// browserPageId -> groupId from a set of groups (the persisted/prior layout),
// so a browser stays in its group across rebuilds that drop browser ids.
export function collectBrowserGroupAssignment(
groups: readonly RuntimeMobileSessionTabGroup[] | undefined,
browserTabIds: readonly string[]
): Map<string, string> {
const browserIdSet = new Set(browserTabIds)
const assignment = new Map<string, string>()
for (const group of groups ?? []) {
for (const id of group.tabOrder) {
if (browserIdSet.has(id)) {
assignment.set(id, group.id)
}
}
}
return assignment
}
@@ -0,0 +1,284 @@
import { createHash, randomUUID } from 'node:crypto'
import { isTerminalLeafId } from '../../shared/stable-pane-id'
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout'
export function collectPersistedTerminalLeafIds(
layout: TerminalLayoutSnapshot | undefined
): string[] {
if (!layout) {
return []
}
const leafIds = new Set<string>()
const visit = (node: TerminalLayoutSnapshot['root']): void => {
if (!node) {
return
}
if (node.type === 'leaf') {
if (isTerminalLeafId(node.leafId)) {
leafIds.add(node.leafId)
}
return
}
visit(node.first)
visit(node.second)
}
visit(layout.root)
if (layout.activeLeafId && isTerminalLeafId(layout.activeLeafId)) {
leafIds.add(layout.activeLeafId)
}
for (const leafId of Object.keys(layout.ptyIdsByLeafId ?? {})) {
if (isTerminalLeafId(leafId)) {
leafIds.add(leafId)
}
}
return [...leafIds]
}
export function deriveHeadlessLegacyTerminalLeafId(tabId: string): string {
const hash = createHash('sha256').update(`headless-terminal-leaf:${tabId}`).digest('hex')
const variant = ((Number.parseInt(hash.slice(16, 17), 16) & 0x3) | 0x8).toString(16)
const leafId = [
hash.slice(0, 8),
hash.slice(8, 12),
`4${hash.slice(13, 16)}`,
`${variant}${hash.slice(17, 20)}`,
hash.slice(20, 32)
].join('-')
if (!isTerminalLeafId(leafId)) {
return randomUUID()
}
return leafId
}
export function cloneTerminalLayoutSnapshot(
layout: TerminalLayoutSnapshot
): TerminalLayoutSnapshot {
const cloned: TerminalLayoutSnapshot = {
root: layout.root,
activeLeafId: layout.activeLeafId,
expandedLeafId: layout.expandedLeafId
}
if (layout.ptyIdsByLeafId) {
cloned.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId }
}
if (layout.buffersByLeafId) {
cloned.buffersByLeafId = { ...layout.buffersByLeafId }
}
if (layout.scrollbackRefsByLeafId) {
cloned.scrollbackRefsByLeafId = { ...layout.scrollbackRefsByLeafId }
}
if (layout.titlesByLeafId) {
cloned.titlesByLeafId = { ...layout.titlesByLeafId }
}
return cloned
}
export function isPersistedTerminalLeafActive(
session: WorkspaceSessionState,
worktreeId: string,
tabId: string,
leafId: string,
layout: TerminalLayoutSnapshot | undefined
): boolean {
const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId
return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId)
}
export function pickHeadlessActiveTerminalTab(
tabs: readonly RuntimeMobileSessionTerminalTab[]
): RuntimeMobileSessionTerminalTab | null {
return tabs.find((tab) => tab.isActive) ?? tabs.find((tab) => tab.parentTabId) ?? null
}
export function collectHeadlessParentTabOrder(
tabs: readonly RuntimeMobileSessionTerminalTab[]
): string[] {
const order: string[] = []
const seen = new Set<string>()
for (const tab of tabs) {
if (!seen.has(tab.parentTabId)) {
seen.add(tab.parentTabId)
order.push(tab.parentTabId)
}
}
return order
}
// Why: the group tab order must follow actual creation/insertion order across
// both terminals and browsers, not list terminals first. A terminal's top-level
// id is its parentTabId (split leaves share one); a browser's is its own id.
export function collectHeadlessTopLevelTabOrder(
tabs: readonly RuntimeMobileSessionSnapshotTab[]
): string[] {
const order: string[] = []
const seen = new Set<string>()
for (const tab of tabs) {
const topLevelId = tab.type === 'terminal' ? tab.parentTabId : tab.id
if (!seen.has(topLevelId)) {
seen.add(topLevelId)
order.push(topLevelId)
}
}
return order
}
export function getHeadlessMobileSessionGroupId(worktreeId: string): string {
return `headless-terminals:${worktreeId}`
}
export function buildHeadlessMobileSessionTabGroups(
worktreeId: string,
tabs: readonly RuntimeMobileSessionSnapshotTab[],
activeTab: RuntimeMobileSessionSnapshotTab | null,
existingGroups?: readonly RuntimeMobileSessionTabGroup[],
// Why: a new tab created via a specific group's "+" must land in THAT group,
// not the active one — otherwise every "+" in a split funnels to one group.
newTabAssignment?: { tabId: string; groupId: string }
): RuntimeMobileSessionTabGroup[] {
// Why: order across terminals and browsers in their actual array order so a
// tab opened after a browser tab lands to its right, not regrouped before it.
const arrivalOrder = collectHeadlessTopLevelTabOrder(tabs)
// Why: tabOrder is the user-visible order and must survive a republish. A
// materialized idle surface can move to the end of the incoming array, so
// retain stored positions and append only genuinely new ids.
const liveTopLevelIds = new Set(arrivalOrder)
const tabOrder: string[] = []
const placed = new Set<string>()
for (const group of existingGroups ?? []) {
for (const tabId of group.tabOrder) {
if (liveTopLevelIds.has(tabId) && !placed.has(tabId)) {
tabOrder.push(tabId)
placed.add(tabId)
}
}
}
for (const tabId of arrivalOrder) {
if (!placed.has(tabId)) {
tabOrder.push(tabId)
placed.add(tabId)
}
}
const topLevelOf = (tab: RuntimeMobileSessionSnapshotTab): string =>
tab.type === 'terminal' ? tab.parentTabId : tab.id
const activeTopLevelId =
(activeTab ? topLevelOf(activeTab) : null) ??
existingGroups?.[0]?.activeTabId ??
(() => {
const active = tabs.find((tab) => tab.isActive)
return active ? topLevelOf(active) : null
})() ??
tabOrder[0] ??
null
// Why: when the user has split tabs into multiple groups, preserve that
// assignment across rebuilds instead of coalescing back to one group.
if (existingGroups && existingGroups.length > 1) {
return distributeHeadlessTabsAcrossGroups(
existingGroups,
tabOrder,
activeTopLevelId,
newTabAssignment
)
}
const groupId = existingGroups?.[0]?.id ?? getHeadlessMobileSessionGroupId(worktreeId)
return [
{
id: groupId,
activeTabId:
activeTopLevelId && tabOrder.includes(activeTopLevelId)
? activeTopLevelId
: (tabOrder[0] ?? null),
tabOrder
}
]
}
// Distribute live top-level tabs into the existing multi-group structure,
// keeping each tab in its group; tabs new since the last snapshot join the
// active group. Emptied groups are dropped so a closed split collapses.
export function distributeHeadlessTabsAcrossGroups(
existingGroups: readonly RuntimeMobileSessionTabGroup[],
tabOrder: readonly string[],
activeTopLevelId: string | null,
newTabAssignment?: { tabId: string; groupId: string }
): RuntimeMobileSessionTabGroup[] {
const groupIdByTabId = new Map<string, string>()
for (const group of existingGroups) {
for (const tabId of group.tabOrder) {
groupIdByTabId.set(tabId, group.id)
}
}
// Why: route a freshly-created tab to the group its "+" was clicked in,
// when that group still exists; otherwise fall through to the active group.
const hasTargetGroup =
newTabAssignment !== undefined &&
existingGroups.some((group) => group.id === newTabAssignment.groupId)
if (hasTargetGroup) {
groupIdByTabId.set(newTabAssignment!.tabId, newTabAssignment!.groupId)
}
const activeGroupId =
(activeTopLevelId ? groupIdByTabId.get(activeTopLevelId) : undefined) ?? existingGroups[0]!.id
const orderByGroup = new Map<string, string[]>(existingGroups.map((group) => [group.id, []]))
for (const tabId of tabOrder) {
const groupId = groupIdByTabId.get(tabId) ?? activeGroupId
orderByGroup.get(groupId)?.push(tabId)
}
return existingGroups
.map((group) => {
const nextOrder = orderByGroup.get(group.id) ?? []
return {
...group,
tabOrder: nextOrder,
activeTabId:
activeTopLevelId && nextOrder.includes(activeTopLevelId)
? activeTopLevelId
: group.activeTabId && nextOrder.includes(group.activeTabId)
? group.activeTabId
: (nextOrder[0] ?? null)
}
})
.filter((group) => group.tabOrder.length > 0)
}
export function buildMaterializedHeadlessParentLayout(
leafId: string,
ptyId: string,
existingLayout: TerminalLayoutSnapshot | undefined,
split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' }
): TerminalLayoutSnapshot {
if (!existingLayout) {
return {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: ptyId }
}
}
// Why: a split must insert the new leaf into the live layout tree with the
// requested direction, or the published snapshot keeps the old single-leaf
// root and the split renders with a fallback direction ("Split Right" lands
// as a top/bottom split). Reuse the persisted-split builder for parity.
if (split) {
return buildHeadlessTerminalSplitLayout(cloneTerminalLayoutSnapshot(existingLayout), {
leafId,
ptyId,
splitFromLeafId: split.splitFromLeafId,
direction: split.direction
})
}
return {
...cloneTerminalLayoutSnapshot(existingLayout),
ptyIdsByLeafId: {
...existingLayout.ptyIdsByLeafId,
[leafId]: ptyId
}
}
}
@@ -0,0 +1,149 @@
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab,
RuntimeSessionTabCloseReason,
RuntimeSyncedTab
} from '../../shared/runtime-types'
import type { MobileSessionTabCloseOutcome } from './mobile-session-tab-close-outcome'
import {
delegatedMobileSessionTabClose,
refusedMobileSessionTabClose
} from './mobile-session-tab-close-outcome'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import { worktreeIdsEqual } from '../../shared/worktree/id'
export type MobileSessionLifecycleCloseHost = {
tabs: ReadonlyMap<string, RuntimeSyncedTab>
leaves: ReadonlyMap<string, RuntimeLeafRecord>
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
findPtyForMobileTerminalTab: (
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab
) => RuntimePtyWorktreeRecord | null
republishSnapshot: (worktreeId: string) => void
}
/** Everything a close needs about the surface the request addresses, including one already retired. */
export type MobileSessionLifecycleCloseContext = {
closeParentTabId: string | null
closeLeafId: string | null
parentLeaves: RuntimeMobileSessionTerminalTab[]
rendererLeaves: RuntimeLeafRecord[]
leafHasConnectedPty: (leaf: RuntimeMobileSessionTerminalTab) => boolean
rendererLeafHasConnectedPty: (leaf: RuntimeLeafRecord) => boolean
}
export function resolveMobileSessionLifecycleCloseContext(args: {
host: MobileSessionLifecycleCloseHost
worktreeId: string
tabId: string
tab: RuntimeMobileSessionSnapshotTab | undefined
authorityTab: RuntimeMobileSessionSnapshotTab | undefined
snapshot: RuntimeMobileSessionTabsSnapshot | undefined
observedPtyIds: ReadonlySet<string> | null
}): MobileSessionLifecycleCloseContext {
const { host, worktreeId, tabId, tab, authorityTab, snapshot, observedPtyIds } = args
const closeParentTabId =
tab?.type === 'terminal'
? tab.parentTabId
: authorityTab?.type === 'terminal'
? authorityTab.parentTabId
: host.tabs.has(tabId)
? tabId
: ([...host.tabs.keys()]
.filter((parentTabId) => tabId.startsWith(`${parentTabId}::`))
.sort((a, b) => b.length - a.length)[0] ??
(
snapshot?.tabs.find(
(candidate) =>
candidate.type === 'terminal' && tabId.startsWith(`${candidate.parentTabId}::`)
) as RuntimeMobileSessionTerminalTab | undefined
)?.parentTabId ??
null)
const parentLeaves = closeParentTabId
? (snapshot?.tabs.filter(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' && candidate.parentTabId === closeParentTabId
) ?? [])
: []
const rendererLeaves = closeParentTabId
? [...host.leaves.values()].filter(
(leaf) => leaf.tabId === closeParentTabId && worktreeIdsEqual(leaf.worktreeId, worktreeId)
)
: []
return {
closeParentTabId,
closeLeafId:
closeParentTabId && tabId.startsWith(`${closeParentTabId}::`)
? tabId.slice(closeParentTabId.length + 2)
: null,
parentLeaves,
rendererLeaves,
// Why: exited PTYs keep a disconnected record for status reads, so record
// presence is not liveness — only `connected`, or a genuinely dead tab
// never retires and the echo loops forever. Daemon discovery can still
// prove a PTY live before its pane binding reconnects.
leafHasConnectedPty: (leaf) => {
const snapshotPtyIds = [leaf.ptyId, leaf.parentLayout?.ptyIdsByLeafId?.[leaf.leafId]].filter(
(ptyId): ptyId is string => Boolean(ptyId)
)
return (
host.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true ||
snapshotPtyIds.some((ptyId) => observedPtyIds?.has(ptyId) === true)
)
},
rendererLeafHasConnectedPty: (leaf) => {
const ptyId = leaf.ptyId
return Boolean(
ptyId &&
(host.ptysById.get(ptyId)?.connected === true || observedPtyIds?.has(ptyId) === true)
)
}
}
}
/**
* Adjudicates a close whose addressed surface is already absent.
* Lifecycle echoes are idempotent: a provider exit may have already retired the
* surface before the viewer reports its stale close. A user close still fails
* closed so an unknown target cannot be hidden.
*/
export function adjudicateAbsentMobileSessionTabClose(args: {
host: MobileSessionLifecycleCloseHost
context: MobileSessionLifecycleCloseContext
worktreeId: string
snapshot: RuntimeMobileSessionTabsSnapshot | undefined
reason: RuntimeSessionTabCloseReason | undefined
addressedByPtyCloseAuthority: boolean
}): MobileSessionTabCloseOutcome {
const { host, context, worktreeId, snapshot, reason } = args
if (reason === undefined || reason === 'user') {
throw new Error(args.addressedByPtyCloseAuthority ? 'terminal_handle_stale' : 'tab_not_found')
}
// A missing leaf can still be part of a live split parent. Closing that
// parent would take the surviving sibling down, so retain the refusal
// even though the addressed leaf has already been retired.
const hasLiveRendererParentLeaf = context.rendererLeaves.some(context.rendererLeafHasConnectedPty)
if (context.parentLeaves.some(context.leafHasConnectedPty) || hasLiveRendererParentLeaf) {
const addressedDeadRendererLeaf =
context.closeLeafId !== null &&
!context.rendererLeaves.some(
(leaf) => leaf.leafId === context.closeLeafId && context.rendererLeafHasConnectedPty(leaf)
)
if (addressedDeadRendererLeaf) {
return refusedMobileSessionTabClose('live-host-pty')
}
if (snapshot) {
host.republishSnapshot(worktreeId)
}
return refusedMobileSessionTabClose('live-host-pty')
}
// The renderer owns a graph-visible parent, including a dead leaf whose
// lifecycle echo arrived after main retired its mirror. Leave retirement
// to that renderer instead of acknowledging a host-side close.
if (context.closeParentTabId && host.tabs.has(context.closeParentTabId)) {
return refusedMobileSessionTabClose('retirement-owner')
}
return delegatedMobileSessionTabClose()
}
@@ -0,0 +1,61 @@
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
// Why: content equality for the hydrate's idempotence check — compares every
// client-visible field EXCEPT publicationEpoch/snapshotVersion (both are
// freshly minted on each rebuild and would defeat the comparison). Tab and
// group objects are rebuilt each hydrate, so compare by value, not identity.
export function headlessMobileSnapshotContentUnchanged(
existing: RuntimeMobileSessionTabsSnapshot,
next: RuntimeMobileSessionTabsSnapshot
): boolean {
if (
existing.worktree !== next.worktree ||
existing.activeGroupId !== next.activeGroupId ||
existing.activeTabId !== next.activeTabId ||
existing.activeTabType !== next.activeTabType
) {
return false
}
// Why: this runs per persisted worktree on EVERY graph sync whenever a
// serve PTY exists, so compare structurally instead of stable-stringifying
// both sides (which allocated six full serialized trees per worktree).
return (
mobileSnapshotValueEqual(existing.tabs, next.tabs) &&
mobileSnapshotValueEqual(existing.tabGroups ?? null, next.tabGroups ?? null) &&
mobileSnapshotValueEqual(existing.tabGroupLayout ?? null, next.tabGroupLayout ?? null)
)
}
// Deep structural equality over plain snapshot JSON (objects/arrays/scalars).
// Key order is irrelevant; a mismatch only costs a coalesced no-op emit.
export function mobileSnapshotValueEqual(a: unknown, b: unknown): boolean {
if (a === b) {
return true
}
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false
}
for (let index = 0; index < a.length; index++) {
if (!mobileSnapshotValueEqual(a[index], b[index])) {
return false
}
}
return true
}
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
const aRecord = a as Record<string, unknown>
const bRecord = b as Record<string, unknown>
const aKeys = Object.keys(aRecord)
if (aKeys.length !== Object.keys(bRecord).length) {
return false
}
for (const key of aKeys) {
if (!Object.hasOwn(bRecord, key) || !mobileSnapshotValueEqual(aRecord[key], bRecord[key])) {
return false
}
}
return true
}
return false
}
@@ -0,0 +1,131 @@
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
import {
collectHeadlessParentTabOrder,
getHeadlessMobileSessionGroupId
} from './mobile-session-layout-projection'
export function mergeMobileSessionSnapshotTabs(
baseTabs: readonly RuntimeMobileSessionSnapshotTab[],
extraTabs: readonly RuntimeMobileSessionSnapshotTab[]
): RuntimeMobileSessionSnapshotTab[] {
const seenIds = new Set<string>()
const merged: RuntimeMobileSessionSnapshotTab[] = []
const add = (tab: RuntimeMobileSessionSnapshotTab): void => {
const ids = getMobileSessionSnapshotTabIdentityKeys(tab)
if (ids.some((id) => seenIds.has(id))) {
return
}
for (const id of ids) {
seenIds.add(id)
}
merged.push(tab)
}
for (const tab of baseTabs) {
add(tab)
}
for (const tab of extraTabs) {
add(tab)
}
return merged
}
export function getMobileSessionSnapshotTabIdentityKeys(
tab: RuntimeMobileSessionSnapshotTab
): string[] {
if (tab.type === 'terminal') {
// Why: split terminal leaves share one parent tab; merge dedup must stay
// leaf-scoped or preserved siblings collapse into a single surface.
const keys = [tab.id, `${tab.parentTabId}::${tab.leafId}`]
if (typeof tab.ptyId === 'string' && tab.ptyId.length > 0) {
// Why: renderer and headless sources can derive different leafIds for the same
// terminal; real PTYs collapse those duplicates without merging pending splits.
keys.push(`${tab.parentTabId}::pty:${tab.ptyId}`)
}
return keys
}
if (tab.type === 'browser') {
return [tab.id, tab.browserWorkspaceId]
}
return [tab.id]
}
export function mergeMobileSessionTabGroups(
worktreeId: string,
groups: readonly RuntimeMobileSessionTabGroup[],
terminalTabs: readonly RuntimeMobileSessionTerminalTab[],
activeTab: RuntimeMobileSessionTerminalTab | null
): RuntimeMobileSessionTabGroup[] {
const parentTabOrder = collectHeadlessParentTabOrder(terminalTabs)
if (parentTabOrder.length === 0) {
return [...groups]
}
const targetGroupId = groups[0]?.id ?? getHeadlessMobileSessionGroupId(worktreeId)
const nextGroups =
groups.length > 0
? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
: [
{
id: targetGroupId,
activeTabId: null,
tabOrder: []
}
]
// Why: keep each tab in the group that already owns it (a multi-group split
// must survive the merge), drop tabs no longer present, and route only
// genuinely-new tabs into the active group — never funnel everything into
// group[0], which duplicated/coalesced tabs that lived in other groups.
const ownerGroupId = new Map<string, string>()
for (const group of nextGroups) {
for (const tabId of group.tabOrder) {
ownerGroupId.set(tabId, group.id)
}
}
const liveTabIds = new Set(parentTabOrder)
const activeParentId = activeTab?.parentTabId ?? null
const activeGroupId =
(activeParentId ? ownerGroupId.get(activeParentId) : undefined) ?? nextGroups[0]!.id
const retainedOrder = new Map<string, string[]>(nextGroups.map((group) => [group.id, []]))
// Why: tabOrder is the canonical user-visible order, so it must survive a republish.
// A materialized idle surface can move to the end of terminalTabs; retaining the
// stored order prevents activation from rotating the tab bar.
const placed = new Set<string>()
for (const group of nextGroups) {
for (const tabId of group.tabOrder) {
if (liveTabIds.has(tabId) && !placed.has(tabId)) {
retainedOrder.get(group.id)?.push(tabId)
placed.add(tabId)
}
}
}
for (const tabId of parentTabOrder) {
if (placed.has(tabId)) {
continue
}
const groupId = ownerGroupId.get(tabId) ?? activeGroupId
retainedOrder.get(groupId)?.push(tabId)
placed.add(tabId)
}
return nextGroups
.map((group) => {
const tabOrder = retainedOrder.get(group.id) ?? []
const keptActive =
group.activeTabId &&
tabOrder.includes(group.activeTabId) &&
liveTabIds.has(group.activeTabId)
? group.activeTabId
: null
return {
...group,
tabOrder,
activeTabId:
activeParentId && tabOrder.includes(activeParentId)
? activeParentId
: (keptActive ?? tabOrder[0] ?? null)
}
})
.filter((group) => group.tabOrder.length > 0)
}
@@ -0,0 +1,51 @@
import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types'
import type { TerminalTab } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
cloneTerminalLayoutSnapshot,
collectPersistedTerminalLeafIds,
deriveHeadlessLegacyTerminalLeafId,
isPersistedTerminalLeafActive
} from './mobile-session-layout-projection'
export function buildHeadlessMobileSessionTerminalTabs(
worktreeId: string,
persistedTabs: readonly TerminalTab[],
session: WorkspaceSessionState
): RuntimeMobileSessionTerminalTab[] {
return [...persistedTabs]
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
.flatMap((tab, index) => {
const layout = session.terminalLayoutsByTabId?.[tab.id]
const leafIds = collectPersistedTerminalLeafIds(layout)
if (leafIds.length === 0) {
leafIds.push(deriveHeadlessLegacyTerminalLeafId(tab.id))
}
return leafIds.flatMap((leafId) => {
const ptyId = layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null)
const title =
tab.customTitle?.trim() ||
tab.generatedTitle?.trim() ||
tab.title?.trim() ||
tab.defaultTitle?.trim() ||
`Terminal ${index + 1}`
return [
{
type: 'terminal' as const,
id: `${tab.id}::${leafId}`,
parentTabId: tab.id,
leafId,
title,
...(ptyId ? { ptyId } : {}),
...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}),
...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}),
...(layout ? { parentLayout: cloneTerminalLayoutSnapshot(layout) } : {}),
...(tab.color != null ? { color: tab.color } : {}),
...(tab.isPinned ? { isPinned: true } : {}),
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
isActive: isPersistedTerminalLeafActive(session, worktreeId, tab.id, leafId, layout)
}
]
})
})
}
@@ -892,9 +892,11 @@ describe('mobile subscribe integration', () => {
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
;(Reflect.get(runtime, 'terminalFitOverrides') as Map<string, unknown>).delete('pty-1')
;(Reflect.get(runtime, 'currentDriver') as Map<string, { kind: string }>).set('pty-1', {
kind: 'idle'
})
;(
Reflect.get(runtime, 'terminalDrivers') as {
set: (ptyId: string, driver: { kind: 'idle' }) => void
}
).set('pty-1', { kind: 'idle' })
const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map<string, unknown>
const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map<string, unknown>
@@ -0,0 +1,225 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithListManagedWorktrees } from './orca-runtime-list-managed-worktrees'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type {
WorktreeStartupDraftPaste,
WorktreeStartupFollowup
} from './runtime-worktree-agent-startup'
import {
buildWorktreeStartupForAgent,
buildWorktreeStartupForDraft,
markLocalWorktreeTrusted,
markRemoteWorktreeTrusted
} from './runtime-worktree-agent-startup'
import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority'
import type { Worktree } from '../../shared/worktree/types'
import type { WorktreeLineageResolution } from './runtime-worktree-lineage-resolution'
import type {
WorkspaceLineage,
WorktreeLineage,
WorktreeLineageWarning
} from '../../shared/worktree/lineage-types'
import { recordCreatedWorktreeLineage as recordCreatedWorktreeLineageState } from './runtime-worktree-lineage-recording'
import {
pasteWorktreeStartupDraftWhenReady,
sendWorktreeStartupFollowupWhenReady
} from './runtime-worktree-startup-readiness'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning'
export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees {
async activateManagedWorktree(
worktreeSelector: string,
opts: {
notifyClients?: boolean
clientKind?: 'mobile' | 'runtime'
navigation?: RuntimeNavigationTarget
} = {}
): Promise<{
repoId: string
worktreeId: string
activated: boolean
/** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no
* renderer holds the sleeping records (headless `orca serve`), so nothing
* woke — clients must not present the worktree's agents as resumed. */
sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable'
}> {
this.assertGraphReady()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const repo = this.store?.getRepo(worktree.repoId)
if (!repo) {
throw new Error('repo_not_found')
}
const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all')
const targetsHost = navigationTargetsHost(navigation)
const targetsClients = navigationTargetsClients(navigation)
if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) {
// Why: mobile/web session activation intentionally bypasses renderer
// selection, so the runtime must acknowledge the unread state itself.
this.store.setWorktreeMeta(worktree.id, { isUnread: false })
this.notifyWorktreesChanged(repo.id)
}
let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' =
'not-applicable'
if (targetsHost || targetsClients) {
// Why: inactive worktree terminal panes are renderer-owned and may not have
// live PTYs until the desktop activates the worktree and mounts them.
if (targetsHost) {
this.notifyHostActivateWorktree(repo.id, worktree.id)
}
if (targetsClients) {
this.notifyClientsActivateWorktree(repo.id, worktree.id)
}
}
if (!targetsHost) {
// Why: mobile/web selection needs fresh session surfaces without forcing
// every attached desktop renderer to navigate to the phone's workspace.
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
allowAttachedWindow: true
})
await this.refreshMobileSessionPtyRecords()
this.notifyMobileSessionTabsChanged(worktree.id)
// Why: a phone open must also wake the worktree's slept agents (experimental
// agent sleep). Only the host renderer holds the sleeping records + wake
// authority, so fire-and-forget ask it — mobile-scoped so web/desktop are
// unaffected. Headless serve has no renderer to wake anything, so report
// that explicitly instead of letting mobile assume the agents resumed.
if (opts.clientKind === 'mobile') {
if (this.getAvailableAuthoritativeWindow()) {
this.notifier?.resumeSleepingAgents?.(worktree.id)
sleepingAgentWake = 'requested'
} else if (
// Why: sleeping records are partitioned by execution host; reading
// only the local partition would miss slept agents on SSH-host
// worktrees and skip the headless warning for them.
Object.values(
this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo))
.sleepingAgentSessionsByPaneKey ?? {}
).some((record) => record.worktreeId === worktree.id)
) {
// Why: headless is only degraded when this worktree actually has a
// persisted resume record. Ordinary mobile activation must not show
// an unsupported warning merely because no desktop window is open.
sleepingAgentWake = 'unsupported-headless'
}
}
}
return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake }
}
protected async buildStartupForDraft(
repo: Repo,
draft: string,
requestedAgent?: TuiAgent
): Promise<{
agent: TuiAgent
startup: WorktreeStartupLaunch
draftPaste?: WorktreeStartupDraftPaste
} | null> {
if (!this.store) {
return null
}
return buildWorktreeStartupForDraft({
repo,
draft,
...(requestedAgent ? { requestedAgent } : {}),
settings: this.store.getSettings(),
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo)
})
}
protected buildStartupForAgent(
repo: Repo,
agent: TuiAgent,
prompt: string | undefined,
launchPreferences?: AgentLaunchPreferences
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
if (!this.store) {
throw new Error('runtime_unavailable')
}
return buildWorktreeStartupForAgent({
repo,
agent,
...(prompt !== undefined ? { prompt } : {}),
...(launchPreferences ? { launchPreferences } : {}),
settings: this.store.getSettings(),
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo),
toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences)
})
}
protected async markLocalWorkspaceTrustedForAgent(
agent: TuiAgent,
workspacePath: string
): Promise<void> {
await markLocalWorktreeTrusted(agent, workspacePath)
}
protected async markWorkspaceTrustedForAgent(
agent: TuiAgent,
connectionId: string | null | undefined,
workspacePath: string
): Promise<void> {
if (connectionId) {
await this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, workspacePath)
return
}
await this.markLocalWorkspaceTrustedForAgent(agent, workspacePath)
}
protected async markRemoteWorkspaceTrustedForAgent(
agent: TuiAgent,
connectionId: string,
workspacePath: string
): Promise<void> {
await markRemoteWorktreeTrusted(agent, connectionId, workspacePath)
}
protected recordCreatedWorktreeLineage(
worktree: Pick<Worktree, 'id' | 'instanceId'>,
lineageResolution: WorktreeLineageResolution
): {
lineage: WorktreeLineage | null
workspaceLineage: WorkspaceLineage | null
warnings: WorktreeLineageWarning[]
} {
return recordCreatedWorktreeLineageState(this.store, worktree, lineageResolution)
}
protected pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void {
pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft)
}
protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void {
sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup)
}
protected async provisionManagedWorktreeTerminals(args: {
worktreeSelector: string
worktreeId: string
worktreePath: string
setup?: CreateWorktreeResult['setup']
defaultTabs?: CreateWorktreeResult['defaultTabs']
primaryTerminalHandle?: string | null
hasStartupTerminal: boolean
setupCommandPlatform: 'windows' | 'posix'
observeSetupCompletion?: boolean
// Why: when the agent startup is sequenced to wait for setup
// (waitForAgentStartup), the startup PTY runs a wrapper that already embeds
// the setup command. Pass that wrapped command through so the Setup tab runs
// the same script the agent is waiting on instead of a bare runner.
wrappedSetupCommand?: string
// Why: a workspace provisioned in the background must not pull the sidebar
// to itself; the user never asked to look at these tabs.
surfaceOwner?: false
}): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> {
return provisionWorktreeTerminals(this.getWorktreeTerminalProvisioningHost(), args)
}
}
@@ -0,0 +1,177 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithSubscribeToTerminalResize } from './orca-runtime-subscribe-to-terminal-resize'
import type {
RuntimeMobileSessionTabsResult,
RuntimeTerminalOrphanAdoptionRequest,
RuntimeTerminalOrphanAdoptionResult
} from '../../shared/runtime-types'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
import { resolveTerminalSessionWorktreeId } from './runtime-worktree-path-identity'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import type { PtyLivenessVerdict } from '../../shared/pty-liveness-verdict'
export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntimeWithSubscribeToTerminalResize {
protected async adoptTerminalOrphansFromInventoryUnderMutation(
request: RuntimeTerminalOrphanAdoptionRequest,
workspace: TerminalWorkspaceLaunchScope,
inventory: PtyControllerInventory
): Promise<RuntimeTerminalOrphanAdoptionResult> {
const store = this.store
const session = this.getWorkspaceSessionForWorktree(workspace.id)
if (
!store?.setWorkspaceSession ||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow) ||
!session
) {
throw new Error('workspace_session_unavailable')
}
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id)
if (!sessionWorktreeId) {
throw new Error('terminal_orphan_competing_owner')
}
const worktreeConnectionId = workspace.connectionId
let worktreeWslDistro: string | null = null
if (!worktreeConnectionId && workspace.repo) {
try {
worktreeWslDistro =
getLocalProjectWorktreeGitOptions(this.requireStore(), workspace.repo).wslDistro ?? null
} catch {
throw new Error('terminal_orphan_owner_mismatch')
}
}
return adoptRuntimeTerminalOrphansFromInventory({
request,
workspace,
inventory,
session,
sessionWorktreeId,
repoId: getRepoIdFromWorktreeId(workspace.id),
worktreeWslDistro,
currentRevision: this.getTerminalTopologyRevision(workspace.id),
ports: {
getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null,
getLeaves: (ptyId) => this.getLeavesForPty(ptyId),
getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)),
getMobileSnapshots: () => this.mobileSessionTabsByWorktree.values(),
getSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId),
setSession: (worktreeId, next) => this.setWorkspaceSessionForWorktree(worktreeId, next),
flushSession: () => this.flushWorkspaceSessionOrThrowAsync(),
hydrateSession: (worktreeId) =>
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
force: true,
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
}),
notifySessionChanged: (worktreeId) => this.notifyMobileSessionTabsChanged(worktreeId),
getSnapshot: (worktreeId) => this.getTerminalOrphanAdoptionSnapshot(worktreeId)
}
})
}
protected getTerminalOrphanAdoptionSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
})
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
return this.getMobileSessionTabsForWorktree(worktreeId)
}
// Why: when --terminal is omitted, the CLI auto-resolves to the active
// terminal in the current worktree — matching browser's implicit active tab.
async resolveActiveTerminal(worktreeSelector?: string): Promise<string> {
if (this.graphStatus !== 'ready') {
const targetWorktreeId = worktreeSelector
? (await this.resolveWorktreeSelector(worktreeSelector)).id
: null
const snapshots = targetWorktreeId
? [this.getMobileSessionTabsForWorktree(targetWorktreeId)]
: await this.listAllMobileSessionTabs()
for (const snapshot of snapshots) {
const activeTerminal = snapshot.tabs.find(
(tab) =>
tab.type === 'terminal' &&
tab.isActive &&
tab.status === 'ready' &&
typeof tab.terminal === 'string'
)
if (activeTerminal?.type === 'terminal' && activeTerminal.terminal) {
return activeTerminal.terminal
}
}
const listed = await this.listTerminals(worktreeSelector, undefined, {
includeVisualLayouts: false
})
const first = listed.terminals[0]?.handle
if (first) {
return first
}
throw new Error('no_active_terminal')
}
this.assertGraphReady()
const targetWorktreeId = worktreeSelector
? (await this.resolveWorktreeSelector(worktreeSelector)).id
: null
// Prefer the tab's activeLeafId — this is the pane the user last focused
for (const tab of this.tabs.values()) {
if (targetWorktreeId && tab.worktreeId !== targetWorktreeId) {
continue
}
if (!tab.activeLeafId) {
continue
}
const leafKey = this.getLeafKey(tab.tabId, tab.activeLeafId)
const leaf = this.leaves.get(leafKey)
if (leaf) {
return this.issueHandle(leaf)
}
}
// Fallback: any leaf in the target worktree
for (const leaf of this.leaves.values()) {
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
continue
}
return this.issueHandle(leaf)
}
throw new Error('no_active_terminal')
}
// Why: orchestration records the pane key as the remint-stable assignee
// identity at dispatch time; null (best-effort) rather than throwing so
// dispatch still works for handles without a resolvable pane.
getTerminalPaneKey(handle: string): string | null {
return this.getPaneKeyForTerminalHandle(handle)
}
getLiveTerminalPaneKey(handle: string): string | null {
const runtimePty = this.getLivePtyForHandle(handle)
if (runtimePty) {
return runtimePty.pty.connected ? (runtimePty.pty.paneKey ?? null) : null
}
try {
const leaf = this.resolveLiveLeafForHandle(handle)
if (!leaf?.ptyId) {
return null
}
const pty = this.ptysById.get(leaf.ptyId)
return pty?.connected === false ? null : this.getPaneKeyForTerminalHandle(handle)
} catch {
return null
}
}
getTerminalLivenessVerdict(handle: string): PtyLivenessVerdict | null {
try {
return this.getPtyLivenessVerdict(this.getTerminalAgentStatusPtyId(handle))
} catch {
return null
}
}
}
@@ -224,4 +224,25 @@ describe('agent skill sharing runtime', () => {
await expect(publishing).rejects.toThrow('upload-aborted')
expect(await operationDirectories()).toEqual([])
})
it('honors cancellation that arrives while preparation is completing', async () => {
const alpha = await createSkill('alpha-id', 'alpha')
const { runtime, publishVersion } = runtimeWithCloud({ isEnabled: () => true })
let abortedReads = 0
const signal = {
get aborted() {
abortedReads += 1
return abortedReads > 1
},
reason: new Error('skill-share-cancelled'),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
} as unknown as AbortSignal
await expect(
runtime.publishDiscoveredSkillsFromAgent(request(['alpha-id']), [alpha], signal)
).rejects.toThrow('skill-share-cancelled')
expect(publishVersion).not.toHaveBeenCalled()
expect(await operationDirectories()).toEqual([])
})
})
@@ -0,0 +1,56 @@
// @ts-nocheck -- the launch-plan adapter is kept independent from the runtime mixin chain.
import type { ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat'
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
import {
addClaudeTeammateModeAuto,
addClaudeTeammateModeInProcess,
buildClaudeAgentTeamsLaunchPlan,
inferCapturedClaudeAgentTeamsMode
} from './orca-runtime-create-terminal-dependencies'
export async function buildRuntimeAgentTeamsLaunchPlan(args: {
launchConfig: TerminalCreateOptions['launchConfig']
command?: string
claudeAgentTeamsSourceCommand?: string
claudeAgentTeamsMode?: ClaudeAgentTeamsMode
baseEnv: Record<string, string | undefined>
adoptedBeforeLaunch: boolean
createTeamEnv: (shimDir: string, shimBin: string) => Record<string, string>
}): Promise<{
plan: Awaited<ReturnType<typeof buildClaudeAgentTeamsLaunchPlan>> | undefined
sequencedStartupCommand?: string
effectiveLaunchConfig: TerminalCreateOptions['launchConfig']
}> {
const sourceCommand =
args.claudeAgentTeamsSourceCommand?.trim() || args.command?.trim() || undefined
const mode = inferCapturedClaudeAgentTeamsMode(
args.launchConfig,
sourceCommand,
args.claudeAgentTeamsMode
)
const plan = args.adoptedBeforeLaunch
? undefined
: await buildClaudeAgentTeamsLaunchPlan({
command: sourceCommand,
mode,
baseEnv: args.baseEnv,
createTeamEnv: args.createTeamEnv
})
const sequencedStartupCommand =
plan && sourceCommand && args.command && sourceCommand !== args.command
? plan.command
: undefined
const effectiveLaunchConfig =
args.launchConfig && plan
? {
...args.launchConfig,
agentCommand: args.launchConfig.agentCommand
? mode === 'in-process' || process.platform === 'win32'
? addClaudeTeammateModeInProcess(args.launchConfig.agentCommand)
: addClaudeTeammateModeAuto(args.launchConfig.agentCommand)
: plan.command,
agentEnv: { ...args.launchConfig.agentEnv, ...plan.env }
}
: args.launchConfig
return { plan, sequencedStartupCommand, effectiveLaunchConfig }
}
@@ -0,0 +1,166 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithPickMostRecentActor } from './orca-runtime-pick-most-recent-actor'
import type { ApplyLayoutResult, PtyLayoutState, PtyLayoutTarget } from './orca-runtime-core'
export class OrcaRuntimeWithApplyLayout extends OrcaRuntimeWithPickMostRecentActor {
protected async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise<ApplyLayoutResult> {
// Why: re-check pty-exit at the head of the slot — the queue may have
// accepted this target before onPtyExit ran.
if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) {
return { ok: false, reason: 'pty-exited' }
}
const prev = this.layouts.get(ptyId) ?? null
const seq = (prev?.seq ?? 0) + 1
const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() }
const currentSize = this.getTerminalSize(ptyId)
const dimsChanged = currentSize?.cols !== target.cols || currentSize?.rows !== target.rows
const modeChanged = (prev?.kind ?? 'desktop') !== target.kind
// Snapshot for rollback.
const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null
// Tentative writes — the resize is the point of no return.
this.layouts.set(ptyId, next)
if (target.kind === 'phone') {
// Why: pull baseline cols+rows atomically from the same subscriber so
// they can't desync.
const baseline = (() => {
const inner = this.mobileSubscribers.get(ptyId)
if (!inner) {
return null
}
return this.pickEarliestRestoreTarget(inner)
})()
this.terminalFitOverrides.set(ptyId, {
mode: 'mobile-fit',
cols: target.cols,
rows: target.rows,
previousCols: baseline?.previousCols ?? null,
previousRows: baseline?.previousRows ?? null,
updatedAt: next.appliedAt,
clientId: target.ownerClientId
})
} else {
this.terminalFitOverrides.delete(ptyId)
}
if (dimsChanged) {
let ok = false
try {
const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows)
ok = r ?? true
} catch (err) {
console.error('[layout] ptyController.resize threw', { ptyId, err })
ok = false
}
if (!ok) {
// Roll back to pre-call snapshot. seq is NOT bumped on the wire
// because we never emit below.
if (prev) {
this.layouts.set(ptyId, prev)
} else {
this.layouts.delete(ptyId)
}
if (prevFitOverride) {
this.terminalFitOverrides.set(ptyId, prevFitOverride)
} else {
this.terminalFitOverrides.delete(ptyId)
}
return { ok: false, reason: 'resize-failed' }
}
this.resizeHeadlessTerminal(ptyId, target.cols, target.rows)
}
// Why: remote desktop ownership is a fit hold for the host and passive
// peer viewers. Emit every remote layout so owner changes at equal geometry
// still park/release the correct clients without relying on resize deltas.
// Defense-in-depth (#7588): also emit when the override's presence
// changed even without a kind flip. applyLayout is the sole writer and
// keeps override presence in lockstep with layout kind, so overrideChanged
// ≡ modeChanged in every reachable state today; the extra clause fires
// only if that invariant is ever violated, repairing the renderer instead
// of stranding the held modal.
const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone')
if (target.kind === 'remote-desktop' || modeChanged || overrideChanged) {
// Why: phone→desktop arms the renderer-cascade suppress window
// before the collateral safeFit IPCs arrive. See "Renderer cascade
// suppression".
if (target.kind === 'desktop') {
this.lastRendererSizes.delete(ptyId)
this.suppressResizesForMs(500)
}
this.notifier?.terminalFitOverrideChanged(
ptyId,
target.kind === 'phone'
? 'mobile-fit'
: target.kind === 'remote-desktop'
? 'remote-desktop-fit'
: 'desktop-fit',
target.cols,
target.rows
)
this.notifyFitOverrideListeners(
ptyId,
target.kind === 'phone'
? 'mobile-fit'
: target.kind === 'remote-desktop'
? 'remote-desktop-fit'
: 'desktop-fit',
target.cols,
target.rows
)
}
// Mobile-facing event always fires (phone clients need to re-fit on
// every dim change, not just mode flips).
this.notifyTerminalResize(ptyId, {
cols: target.cols,
rows: target.rows,
displayMode: target.kind === 'phone' ? 'phone' : 'desktop',
reason: 'apply-layout',
seq
})
return { ok: true, state: next }
}
// ─── Server-Authoritative Mobile Display Mode ─────────────────────
setMobileDisplayMode(ptyId: string, mode: 'auto' | 'desktop'): void {
if (mode === 'auto') {
this.mobileDisplayModes.delete(ptyId)
} else {
this.mobileDisplayModes.set(ptyId, mode)
}
}
getMobileDisplayMode(ptyId: string): 'auto' | 'desktop' {
return this.mobileDisplayModes.get(ptyId) ?? 'auto'
}
isMobileSubscriberActive(ptyId: string): boolean {
const inner = this.mobileSubscribers.get(ptyId)
return inner !== undefined && inner.size > 0
}
// Why: late-bind viewport on an existing subscriber record. Subscribers
// that registered before the mobile side measured (e.g. terminal first
// mounted while the WebView was still loading) have null viewport, and
// applyMobileDisplayMode's auto branch needs a viewport to phone-fit.
// The setDisplayMode RPC carries the latest viewport so we can patch it
// here just before applyMobileDisplayMode runs.
updateMobileSubscriberViewport(
ptyId: string,
clientId: string,
viewport: { cols: number; rows: number }
): void {
const inner = this.mobileSubscribers.get(ptyId)
const record = inner?.get(clientId)
if (!record) {
return
}
record.viewport = viewport
}
}
@@ -0,0 +1,185 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithHandleMobileUnsubscribe } from './orca-runtime-handle-mobile-unsubscribe'
export class OrcaRuntimeWithApplyMobileDisplayMode extends OrcaRuntimeWithHandleMobileUnsubscribe {
// Why: called when mode changes via terminal.setDisplayMode. Applies the
// mode change immediately if there's an active subscriber, and emits a
// 'resized' event so the mobile client can reinitialize xterm inline.
//
// Multi-mobile: the most recent mobile actor's viewport drives the active
// phone-fit dims. The earliest-by-subscribe-time subscriber's
// previousCols/Rows drive the desktop-restore target.
//
// Returns the post-condition "no fit-override remains held" (#7588): `true`
// when it cleared a held override OR nothing was held to begin with, `false`
// only when a restore was attempted and the resize failed (override rolled
// back, still held). reclaimTerminalForDesktop gates its driver/mode
// transitions on this; other callers ignore it.
async applyMobileDisplayMode(ptyId: string): Promise<boolean> {
const mode = this.getMobileDisplayMode(ptyId)
const inner = this.mobileSubscribers.get(ptyId)
const subscriber = inner ? this.pickMostRecentActor(inner) : null
const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null
if (mode === 'desktop') {
// Reset wasResizedToPhone on every fitted subscriber so a future
// toggle back to auto re-issues the resize. applyLayout owns the
// actual PTY resize + override delete + renderer notify. Track which
// subscribers we cleared so a failed resize can re-arm them.
const clearedFitSubscribers = inner
? [...inner.values()].filter((sub) => sub.wasResizedToPhone)
: []
for (const sub of clearedFitSubscribers) {
sub.wasResizedToPhone = false
}
const anyWasResized = clearedFitSubscribers.length > 0
// Why (#7588): also restore when a fit-override is still held but no
// subscriber carries wasResizedToPhone — e.g. a null-viewport resubscribe
// after an indefinite hold resets the flag yet leaves the override,
// stranding the desktop "phone size" modal. Reuse resolveDesktopRestoreTarget
// (the same resolver the anyWasResized branch uses) so the two adjacent
// restore paths can never resolve to different dims for the same state.
if (anyWasResized || this.terminalFitOverrides.has(ptyId)) {
const restore = this.resolveDesktopRestoreTarget(ptyId)
const result = await this.enqueueLayout(ptyId, {
kind: 'desktop',
cols: restore.cols,
rows: restore.rows
})
// Why (#7588): a failed resize rolls the override back (still held), so
// re-arm the flags we cleared. Otherwise a later unsubscribe under a
// finite mobileAutoRestoreFitMs would see wasResizedToPhone=false, skip
// scheduling its auto-restore timer, and strand the held phone-fit.
if (!result.ok) {
for (const sub of clearedFitSubscribers) {
sub.wasResizedToPhone = true
}
}
} else {
// Nothing was fitted or held — emit a mode-change resize event so
// the mobile client still learns the toggle landed.
const size = this.getTerminalSize(ptyId)
this.notifyTerminalResize(ptyId, {
cols: size?.cols ?? 0,
rows: size?.rows ?? 0,
displayMode: 'desktop',
reason: 'mode-change',
seq: this.layouts.get(ptyId)?.seq
})
}
} else {
// mode === 'auto' — the only non-desktop mode after the 'phone'
// (sticky-fit) collapse. Phone-fit if the active subscriber has a
// viewport and we haven't already applied it.
if (subscriberRecord && !subscriberRecord.wasResizedToPhone) {
const viewport = subscriberRecord.viewport
if (viewport) {
await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport)
// After a phone-fit an override IS held, so this reports false. The
// auto branch is never reached from reclaim (it sets 'desktop'
// first); computed here only to keep the post-condition uniform.
return !this.terminalFitOverrides.has(ptyId)
}
}
// Why: always emit the mode change even when no resize occurred — the
// mobile client needs to learn the toggle landed even if dims didn't
// actually change. Carry the current seq (or undefined if no layout
// entry yet) so the mobile-side stale-event filter behaves correctly.
const size = this.getTerminalSize(ptyId)
this.notifyTerminalResize(ptyId, {
cols: size?.cols ?? 0,
rows: size?.rows ?? 0,
displayMode: 'auto',
reason: 'mode-change',
seq: this.layouts.get(ptyId)?.seq
})
}
return !this.terminalFitOverrides.has(ptyId)
}
// Why: called after a desktop renderer path has successfully resized the
// PTY (local IPC or remote desktop viewport). The runtime mirror must take
// the same accepted geometry so hidden-output restore parses at PTY width.
onExternalPtyResize(ptyId: string, cols: number, rows: number): void {
// The pty:resize IPC handler is supposed to gate via `isResizeSuppressed`
// before calling here, but defend against callers that don't.
if (this.isResizeSuppressed()) {
return
}
// Why: while a mobile-fit override is in place, the desktop renderer's
// safeFit echoes pty:resize(override.cols, override.rows). Treating that
// echo as legitimate geometry would overwrite each subscriber's
// previousCols/Rows baseline with phone dims, so the next take-back
// enqueues a no-op {kind:'desktop', cols:49, rows:40} and leaves xterm
// stuck. Only filter reports that EXACTLY match the override — a fresh
// measurement from a now-visible pane (e.g. user activated a previously
// hidden tab on desktop, container went 0×0 → 1782×1195) reports
// different dims and is the right baseline to remember.
const activeOverride = this.terminalFitOverrides.get(ptyId)
if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) {
return
}
// Why: a successful host resize supersedes any target retained after a
// failed viewer reclaim; a later viewer cycle must capture this new truth.
this.remoteDesktopFloor.clearStaleHostReclaimTarget(ptyId)
this.resizeHeadlessTerminal(ptyId, cols, rows)
this.refreshRendererGeometry(ptyId, cols, rows)
}
// Why: pty:reportGeometry IPC sibling. The renderer calls this when a
// desktop pane container goes from 0×0 to a real size while a mobile-fit
// override is active (e.g. user activates a previously-hidden tab on
// desktop after the phone has already taken the floor). We need the
// restore-target baseline to track real desktop dims even during the
// fit period — otherwise resolveDesktopRestoreTarget falls back to the
// PTY's spawn default (typically 80×24) and Take Back leaves the
// terminal partially restored. This is a measurement-only channel: it
// refreshes lastRendererSizes and non-null subscriber baselines, never
// resizes the PTY, and bypasses both isResizeSuppressed and the
// override-echo gate by design — the renderer only fires it when it
// has just measured fresh real geometry. See docs/mobile-fit-hold.md.
recordRendererGeometry(ptyId: string, cols: number, rows: number): void {
if (cols <= 0 || rows <= 0) {
return
}
// Why: a viewer may leave while phone-fit still owns the PTY. Keep its
// deferred host reclaim cache aligned with later trusted pane measurements.
this.remoteDesktopFloor.updateHostReclaimTarget(ptyId, cols, rows)
this.refreshRendererGeometry(ptyId, cols, rows)
}
// Why: test seam — exposes lastRendererSizes for assertions about
// pty:reportGeometry / onExternalPtyResize side effects without making
// the underlying Map writable from the outside.
getLastRendererSize(ptyId: string): { cols: number; rows: number } | null {
return this.lastRendererSizes.get(ptyId) ?? null
}
protected refreshRendererGeometry(ptyId: string, cols: number, rows: number): void {
this.lastRendererSizes.set(ptyId, { cols, rows })
const inner = this.mobileSubscribers.get(ptyId)
if (!inner) {
return
}
// Refresh the renderer-current size as the next-restore target on every
// subscriber that already has a non-null baseline. Subscribers with null
// baselines (joined while a peer had already phone-fitted) stay null.
for (const sub of inner.values()) {
if (sub.previousCols != null && sub.previousRows != null) {
sub.previousCols = cols
sub.previousRows = rows
}
}
}
// Why: the pty:resize IPC handler calls this to check if the global
// suppress window is active. During this window, all desktop renderer
// pty:resize events are ignored to prevent collateral safeFit corruption.
isResizeSuppressed(): boolean {
return Date.now() < this.resizeSuppressedUntil
}
protected suppressResizesForMs(ms: number): void {
this.resizeSuppressedUntil = Date.now() + ms
}
}
@@ -0,0 +1,209 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh } from './orca-runtime-perform-mobile-session-pty-records-refresh'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import { navigationTargetsClients } from '../../shared/runtime-navigation'
import {
activateClientSessionTabSelection,
deriveClientSessionTabSelection,
projectClientSessionTabSelection
} from './client-session-tab-selection'
import { makePaneKey } from '../../shared/stable-pane-id'
import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity'
import {
buildHeadlessMobileSessionTabGroups,
cloneTerminalLayoutSnapshot
} from './mobile-session-layout-projection'
import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout'
export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh {
protected applyMobileSessionTabNavigation(
snapshot: RuntimeMobileSessionTabsResult,
activeTabId: string,
navigation: RuntimeNavigationTarget,
clientNavigationId?: string
): RuntimeMobileSessionTabsResult {
let callerSnapshot: RuntimeMobileSessionTabsResult | null = null
if (navigationTargetsClients(navigation)) {
// Why: follow is live intent; disconnected devices must not inherit stale navigation on reconnect.
const ids = new Set(
[...this.mobileSessionTabListeners]
.map((subscription) => subscription.clientNavigationId)
.filter((id): id is string => Boolean(id))
)
if (clientNavigationId) {
ids.add(clientNavigationId)
}
for (const id of ids) {
const projected = this.clientSessionTabSelections.activate(
this.withClientHostedPagesHold(snapshot, id),
id,
activeTabId
)
this.emitMobileSessionTabsSnapshotToClient(projected, id, true)
if (id === clientNavigationId) {
callerSnapshot = projected
}
}
} else if (clientNavigationId) {
// Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner.
callerSnapshot = this.clientSessionTabSelections.activate(
this.withClientHostedPagesHold(snapshot, clientNavigationId),
clientNavigationId,
activeTabId
)
this.emitMobileSessionTabsSnapshotToClient(callerSnapshot, clientNavigationId)
}
if (clientNavigationId) {
return callerSnapshot ?? this.projectMobileSessionTabsForClient(snapshot, clientNavigationId)
}
if (navigation === 'caller') {
const selection = activateClientSessionTabSelection(
snapshot,
deriveClientSessionTabSelection(snapshot),
activeTabId
)
return projectClientSessionTabSelection(snapshot, selection).snapshot
}
return snapshot
}
/**
* Whether persistence proves this pane's PTY was deliberately taken down and parked
* (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect.
* Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it
* indefinitely — and respawning a parked pane re-launches its agent behind the user.
* Only an automatic activation consults this; a user opening the tab is the wake gesture.
*/
protected isDeliberatelyParkedPane(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab
): boolean {
const record =
this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[
makePaneKey(tab.parentTabId, tab.leafId)
]
// Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect
// must still mint its replacement PTY (#11542). Only a worktree-owned capture records
// a deliberate takedown the user did not ask to undo.
return (
record?.origin === 'worktree-sleep' && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId)
)
}
protected shouldMaterializeHeadlessMobileSessionTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab
): boolean {
return (
this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) ||
this.hasServeOrSshOwnedBinding(tab)
)
}
protected shouldPersistHeadlessMobileSessionActivation(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab
): boolean {
if (snapshot.publicationEpoch.includes(':headless-merge:')) {
return false
}
if (this.authoritativeWindowId !== null && this.graphStatus === 'ready') {
return false
}
return this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab)
}
protected activateHeadlessMobileSessionTerminalTab(
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,
activeTab: RuntimeMobileSessionTerminalTab
): void {
const tabs = snapshot.tabs.map((candidate) => ({
...candidate,
isActive: candidate.id === activeTab.id
}))
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
...snapshot,
publicationEpoch: `headless:${Date.now().toString(36)}`,
snapshotVersion: snapshot.snapshotVersion + 1,
activeTabId: activeTab.id,
activeTabType: 'terminal',
tabGroups: buildHeadlessMobileSessionTabGroups(
worktreeId,
tabs,
activeTab,
snapshot.tabGroups
),
tabs
}
this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab)
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
// Why: a headless split only updated the LIVE session snapshot, never the
// persisted workspace session layout. So a later snapshot rebuild (e.g. on the
// next terminal create) re-derived from the stale single-leaf persisted layout
// and collapsed the split. Persist the new split leaf into the workspace
// session's terminalLayoutsByTabId so the split survives rebuilds.
protected persistHeadlessTerminalSplit(args: {
worktreeId: string
tabId: string
leafId: string
ptyId: string
splitFromLeafId: string
direction: 'horizontal' | 'vertical'
}): boolean {
const session = this.getWorkspaceSessionForWorktree(args.worktreeId)
if (!session || !this.store?.setWorkspaceSession) {
return false
}
const existing = session.terminalLayoutsByTabId?.[args.tabId]
const nextLayout = buildHeadlessTerminalSplitLayout(
existing ? cloneTerminalLayoutSnapshot(existing) : undefined,
args
)
this.setWorkspaceSessionForWorktree(args.worktreeId, {
...session,
terminalLayoutsByTabId: {
...session.terminalLayoutsByTabId,
[args.tabId]: nextLayout
}
})
return true
}
protected persistHeadlessTerminalActiveLeaf(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab
): void {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session || !this.store?.setWorkspaceSession) {
return
}
const existingLayout = session.terminalLayoutsByTabId?.[tab.parentTabId]
const nextLayouts = existingLayout
? {
...session.terminalLayoutsByTabId,
[tab.parentTabId]: {
...cloneTerminalLayoutSnapshot(existingLayout),
activeLeafId: tab.leafId
}
}
: session.terminalLayoutsByTabId
this.setWorkspaceSessionForWorktree(worktreeId, {
...session,
activeTabId: tab.parentTabId,
activeTabIdByWorktree: {
...session.activeTabIdByWorktree,
[worktreeId]: tab.parentTabId
},
terminalLayoutsByTabId: nextLayouts
})
}
}
@@ -0,0 +1,191 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithGetUnpersistedTrackedTitleForPty } from './orca-runtime-get-unpersisted-tracked-title-for-pty'
import type { TerminalTitleFactMeta } from '../../shared/terminal-output-side-effects'
import { detectAgentStatusFromTitle } from '../../shared/agent-detection'
import { terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-status-projection'
export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnpersistedTrackedTitleForPty {
/** Apply one observed OSC title (raw form) to the PTY and leaf records.
* Returns true when the PTY record's title or status changed. */
protected applyTrackedPtyTitle(
ptyId: string,
rawTitle: string,
normalizedTitle: string,
meta?: TerminalTitleFactMeta
): boolean {
// Why: status is detected from the RAW title (mirrors the renderer tracker),
// so working/idle transitions are unaffected by normalization; the records
// store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to
// one stable stored label (#7880) instead of churning `ps`/mobile tabs.
//
// Why the identity-only case: the bare cursor-agent literal identifies the pane without
// asserting activity, so it records NO title/status evidence — only the tracker keeps it,
// for display (#10258). Nulling the status here rather than trusting the detector keeps
// that contract local, since every activity-gated effect below is keyed on status.
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
const recordedTitle = identityOnlyTitle ? null : normalizedTitle
const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle)
this.recordAgentPromptLifecycleState(ptyId, agentStatus)
let ptyRecordChanged = false
const pty = this.ptysById.get(ptyId)
if (pty) {
const prevStatus = pty.lastAgentStatus
const prevTitle = pty.lastOscTitle
const observedAt = this.nextTitleObservationSequence()
const observedAtEpochMs = identityOnlyTitle ? null : Date.now()
pty.lastOscTitle = recordedTitle
pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt
pty.lastOscTitleEpochMs = observedAtEpochMs
pty.lastAgentStatus = agentStatus
pty.lastAgentStatusObservedLive = true
if (prevStatus !== agentStatus) {
pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs
}
if (
identityOnlyTitle ||
terminalTitleBlocksExplicitAgentStatus(recordedTitle) ||
(prevStatus !== null && agentStatus !== null && prevStatus !== agentStatus)
) {
pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now()
}
if (identityOnlyTitle) {
pty.managementTitle = null
pty.managementTitleAt = null
} else {
this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt)
}
ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolvePtyTuiIdleWaiters(pty, ptyId)
}
const shouldDelayMobileSnapshot =
ptyRecordChanged &&
this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle)
let foregroundRefresh: Promise<boolean> | undefined
// Why: gate on an actual status transition — braille spinner frames
// mutate the title every tick, so probing per-title-change would stream
// a foreground query per frame during active work.
if (prevStatus !== agentStatus) {
foregroundRefresh = this.ptyForegroundAgent.refresh(ptyId, observedAt)
} else if (shouldDelayMobileSnapshot) {
// Why: same-status compatible title changes can arrive before the
// foreground owner probe settles; publishing them would flicker.
foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt)
}
if (foregroundRefresh && shouldDelayMobileSnapshot) {
// Why: report "unchanged" so the per-chunk batch skips the mobile
// snapshot fan-out; the delayed publish fires when the probe settles.
ptyRecordChanged = false
this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh)
}
}
for (const leaf of this.getLeavesForPty(ptyId)) {
// Why: keep the latest OSC title on the leaf so worktree.ps can
// recompute status from the live title each call. Without this,
// daemon-hosted terminals (no renderer pushing pane titles) had no
// way to clear a stale 'working' status after the agent exited and
// the shell took over the title — the stuck-spinner bug in #1437.
const prevStatus = leaf.lastAgentStatus
const prevObservedLive = leaf.lastAgentStatusObservedLive
leaf.lastOscTitle = recordedTitle
leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence()
// Why: when a new OSC title doesn't classify as an agent state (e.g.
// bare shell title after the agent exits), clear lastAgentStatus so
// it is no longer sticky. Tui-idle waiters that needed the previous
// 'idle' transition were already resolved at the moment of the
// transition below; only fresh waiters registered after the agent
// exits would observe the cleared value, and they correctly fall
// back to title-based detection / polling.
leaf.lastAgentStatus = agentStatus
leaf.lastAgentStatusObservedLive = true
// Why: resolve tui-idle on any transition TO idle (not just working→idle).
// Claude Code may skip "working" entirely on fast tasks, going null→idle,
// and the coordinator's tui-idle waiter would hang forever waiting for a
// working→idle transition that never comes. Permission→idle is excluded:
// it means the agent was blocked on user approval and the user said no,
// which isn't a task-completion signal.
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolveTuiIdleWaiters(leaf)
}
// Why the second condition: push delivery is gated on LIVE idle, so its
// authorizing edge is liveness as well as status. A restore seed or a
// status kept across a same-id respawn leaves a stale 'idle' behind, and
// an agent whose first live title is already idle (claude --resume at its
// prompt) then shows no transition — the row would strand, which is
// exactly #12536. Waiter semantics stay transition-only above.
if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) {
this.deliverPendingMessagesForLeaf(leaf)
}
}
return ptyRecordChanged
}
/** Cancel the per-PTY title tracker (stale-title timer included) on PTY
* teardown so it cannot fire into pruned records. */
protected disposePtyTitleTracker(ptyId: string): void {
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose()
this.ptyTitleTrackersByPtyId.delete(ptyId)
this.ptyForegroundAgent.clearDelayedSnapshot(ptyId)
this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId)
this.clientEvents.clearPtyTitleGate(ptyId)
}
protected resetTrackedTerminalStateForProviderGeneration(ptyId: string): void {
// Why: a replacement daemon session can reuse the PTY id, but title/parser
// state from the prior process must not bleed into its snapshots or chunks.
this.disposePtyTitleTracker(ptyId)
this.oscTitleScanTailByPtyId.delete(ptyId)
this.osc7ScanTailByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.agentPromptLifecycleByPtyId.delete(ptyId)
this.agentPromptPermissionSequenceByPtyId.delete(ptyId)
this.clearWaitBlockedCheckState(ptyId)
const pty = this.ptysById.get(ptyId)
if (pty) {
pty.lastOscTitle = null
pty.lastOscTitleAt = null
pty.lastOscTitleEpochMs = null
pty.lastAgentStatus = null
// Why: the prior process's live frames say nothing about the replacement,
// so the seed a same-id restore applies must not inherit its authority.
pty.lastAgentStatusObservedLive = false
pty.lastAgentStatusStartedAtEpochMs = null
pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now()
pty.managementTitle = null
pty.managementTitleAt = null
pty.waitBlockedAt = null
pty.tailWaitState = undefined
}
for (const leaf of this.getLeavesForPty(ptyId)) {
leaf.lastOscTitle = null
leaf.lastOscTitleAt = null
leaf.lastAgentStatus = null
leaf.lastAgentStatusObservedLive = false
leaf.waitBlockedAt = null
leaf.tailWaitState = undefined
}
this.primeWaitBlockedBaselineFromSeededTail(ptyId)
this.clearAgentRowSnapshotsForPty(ptyId)
}
protected setTerminalSideEffectConsumerAvailable(available: boolean): void {
this.terminalSideEffectLocalConsumerAvailable = available && this.onTerminalSideEffects !== null
this.refreshTerminalSideEffectConsumerAvailability()
}
protected refreshTerminalSideEffectConsumerAvailability(): void {
const nextAvailable =
this.terminalSideEffectLocalConsumerAvailable ||
this.countTerminalSideEffectConsumingClientEventListeners() > 0
if (nextAvailable === this.terminalSideEffectConsumerAvailable) {
return
}
this.terminalSideEffectConsumerAvailable = nextAvailable
for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) {
entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable)
entry.commandCodeDetector = nextAvailable
? this.createTerminalSideEffectCommandCodeDetector(ptyId)
: null
}
}
}
@@ -0,0 +1,208 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithRecordAgentPromptLifecycleState } from './orca-runtime-record-agent-prompt-lifecycle-state'
import type {
RemoteTerminalSourceRangeReplacementPublication,
RemoteTerminalSourceRangeReplacementReservation,
RemoteTerminalSourceRangeStreamIdentity
} from './remote-terminal-source-range-consumer'
import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range'
import type { DriverState } from './orca-runtime-core'
import { addListenerToMap } from './orca-runtime-core'
import { notifyRuntimeListeners } from './runtime-async-boundaries'
import type { RuntimeTerminalBufferSnapshot } from './runtime-terminal-state-records'
import { AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS } from './orca-runtime-postlude'
import { assertTerminalInputWithinLimitWithYield } from './terminal-send-payload'
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends OrcaRuntimeWithRecordAgentPromptLifecycleState {
attachRemoteTerminalSourceRangeConsumer(
identity: RemoteTerminalSourceRangeStreamIdentity
): boolean {
return this.terminalStreamConsumers.attachSourceRangeConsumer(identity)
}
settleRemoteTerminalSourceRanges(
identity: RemoteTerminalSourceRangeStreamIdentity,
ranges: readonly TerminalOutputSourceRange[]
): void {
this.terminalStreamConsumers.settleSourceRanges(identity, ranges)
}
reserveRemoteTerminalSourceRangeReplacement(
identity: RemoteTerminalSourceRangeStreamIdentity,
requiredSeq: number,
reason: string
): RemoteTerminalSourceRangeReplacementReservation | null {
return this.terminalStreamConsumers.reserveSourceRangeReplacement(identity, requiredSeq, reason)
}
commitRemoteTerminalSourceRangeReplacement(
reservation: RemoteTerminalSourceRangeReplacementReservation,
publication: RemoteTerminalSourceRangeReplacementPublication
): boolean {
return this.terminalStreamConsumers.commitSourceRangeReplacement(reservation, publication)
}
rollbackRemoteTerminalSourceRangeReplacement(
reservation: RemoteTerminalSourceRangeReplacementReservation,
reason: string
): boolean {
return this.terminalStreamConsumers.rollbackSourceRangeReplacement(reservation, reason)
}
cancelRemoteTerminalSourceRanges(
identity: RemoteTerminalSourceRangeStreamIdentity,
ranges: readonly TerminalOutputSourceRange[],
reason: string
): void {
this.terminalStreamConsumers.cancelSourceRanges(identity, ranges, reason)
}
protected notifyRemoteTerminalViewPresenceChanged(ptyId: string): void {
try {
this.onRemoteTerminalViewPresenceChanged?.(ptyId)
} catch (err) {
console.error('[runtime] remote view presence listener threw', { ptyId, err })
}
}
/** Registered by terminal-RPC subscribe/multiplex streams: while a remote
* view subscriber is attached its xterm answers queries with view
* authority and the model responder must stay silent. Returns an
* idempotent release. */
registerRemoteTerminalViewSubscriber(ptyId: string): () => void {
return this.terminalViewSubscribers.registerRemote(ptyId)
}
/** A local daemon session main knows is live but has never ingested a byte
* from — i.e. no pane ever attached it, so the daemon is not emitting.
* Headless state exists only after the first ingested byte; a snapshot
* reconcile in flight implies a spawn-path attach already happened. */
protected isKnownUnattachedLocalDaemonPty(ptyId: string): boolean {
return this.terminalViewSubscribers.isKnownUnattachedLocal(ptyId)
}
protected reconcileSubscriberDrivenProviderAttach(ptyId: string): void {
this.terminalViewSubscribers.reconcileProviderAttach(ptyId)
}
/** Mark a raw-output viewer without transferring terminal query authority. */
registerRawTerminalViewSubscriber(ptyId: string): () => void {
return this.terminalViewSubscribers.registerRaw(ptyId)
}
/** Raw stream presence prevents provider thinning without changing reply ownership. */
hasRawTerminalViewSubscriber(ptyId: string): boolean {
return this.terminalViewSubscribers.hasRaw(ptyId)
}
hasRemoteTerminalViewSubscriber(ptyId: string): boolean {
return this.terminalViewSubscribers.hasRemote(ptyId)
}
isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean {
// Why: a passive phone watching desktop-sized output must not race the
// desktop xterm. Mobile becomes reply authority only with the mobile floor.
if (this.getDriver(ptyId).kind !== 'mobile') {
return false
}
const subscribers = this.mobileSubscribers.get(ptyId)
if (!subscribers) {
return false
}
// Why: soft-leave resubscribe preserves the original subscription time but
// reinserts the record. Elect fitted responders from that stable age, not
// mutable Map order or passive desktop-mode watchers.
let earliest: { clientId: string; subscribedAt: number } | null = null
for (const subscriber of subscribers.values()) {
if (!subscriber.wasResizedToPhone) {
continue
}
if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) {
earliest = subscriber
}
}
return earliest?.clientId === clientId
}
subscribeToFitOverrideChanges(
ptyId: string,
listener: (event: {
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
cols: number
rows: number
}) => void
): () => void {
return addListenerToMap(this.fitOverrideListeners, ptyId, listener)
}
subscribeToDriverChanges(ptyId: string, listener: (driver: DriverState) => void): () => void {
return this.terminalDrivers.subscribe(ptyId, listener)
}
protected notifyFitOverrideListeners(
ptyId: string,
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit',
cols: number,
rows: number
): void {
const listeners = this.fitOverrideListeners.get(ptyId)
if (!listeners) {
return
}
notifyRuntimeListeners(listeners, (listener) => listener({ mode, cols, rows }), 'fit-override')
}
serializeTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
): Promise<RuntimeTerminalBufferSnapshot | null> {
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
}
async serializeAuthoritativeTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
): Promise<RuntimeTerminalBufferSnapshot | null> {
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts, {
timeoutMs: AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
retireOnTimeout: true
})
if (providerSnapshot) {
return providerSnapshot
}
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
}
/** Raw keystroke pass-through for the pop-out dashboard's terminal preview.
* Honors the mobile-presence lock like the main window's pty:write path. */
async writeTerminalPreviewInput(ptyId: string, data: string): Promise<boolean> {
if (data.length === 0 || this.getDriver(ptyId).kind === 'mobile') {
return false
}
try {
await assertTerminalInputWithinLimitWithYield(data)
const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId)
await this.writeTerminalInputChunks(
ptyId,
data,
{
// Why: a phone can claim the floor while a paste yields between chunks.
beforeWrite: () => {
if (this.getDriver(ptyId).kind === 'mobile') {
throw new Error('terminal_mobile_driver_active')
}
}
},
admitted
)
return true
} catch {
return false
}
}
hasHeadlessTerminalState(ptyId: string): boolean {
return this.headlessTerminals.has(ptyId)
}
}
@@ -0,0 +1,98 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithNotifySshStateChanged } from './orca-runtime-notify-ssh-state-changed'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
export class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifySshStateChanged {
attachWindow(windowId: number): void {
if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) {
if (
this.pendingHeadlessPromotionWindowId !== null &&
windowId !== this.pendingHeadlessPromotionWindowId
) {
return
}
// Why: promotion is a renderer reload of the same graph owner, not a new
// runtime; stale handles must transition before the real window publishes.
this.persistWindowlessPtyBindingsForDesktopAttach()
this.pendingHeadlessPromotionWindowId = windowId
this.authoritativeWindowId = windowId
this.beginGraphReload(windowId)
return
}
if (this.authoritativeWindowId === null) {
// Why: a promoted serve can close and later reopen its window while new
// background PTYs keep arriving; every windowless gap needs this handoff.
this.persistWindowlessPtyBindingsForDesktopAttach()
this.authoritativeWindowId = windowId
}
}
protected persistWindowlessPtyBindingsForDesktopAttach(): void {
if (!this.store?.getWorkspaceSession || !this.store.setWorkspaceSession) {
return
}
const partitions = new Map<
ExecutionHostId,
{ session: WorkspaceSessionState; ptys: RuntimePtyWorktreeRecord[] }
>()
for (const pty of this.ptysById.values()) {
if (!pty.connected || !pty.tabId) {
continue
}
const hostId = this.getWorkspaceSessionHostIdForWorktree(pty.worktreeId)
const session = this.store.getWorkspaceSession(hostId)
const tab = session.tabsByWorktree[pty.worktreeId]?.find(
(candidate) => candidate.id === pty.tabId
)
if (!tab) {
continue
}
const layoutPtyIds = Object.values(
session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {}
)
if (tab.ptyId !== pty.ptyId && !layoutPtyIds.includes(pty.ptyId)) {
continue
}
const partition = partitions.get(hostId) ?? { session, ptys: [] }
partition.ptys.push(pty)
partitions.set(hostId, partition)
}
for (const [hostId, { session, ptys }] of partitions) {
// Why: windowless SSH PTYs must be handed to the desktop through their SSH partition, never the local session.
const activeWorktreeIdsOnShutdown = [
...new Set([
...(session.activeWorktreeIdsOnShutdown ?? []),
...ptys.map((pty) => pty.worktreeId)
])
]
const activeConnectionIdsAtShutdown = [
...new Set([
...(session.activeConnectionIdsAtShutdown ?? []),
...ptys
.map((pty) => pty.connectionId)
.filter((connectionId): connectionId is string => connectionId !== null)
])
]
const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId }
for (const pty of ptys) {
if (pty.connectionId && pty.tabId) {
remoteSessionIdsByTabId[pty.tabId] = pty.ptyId
}
}
this.store.setWorkspaceSession(
{
...session,
activeWorktreeIdsOnShutdown,
...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}),
...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {})
},
hostId
)
}
}
}
@@ -0,0 +1,183 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithBuildPtyTerminalSummary } from './orca-runtime-build-pty-terminal-summary'
import type { PtyIncarnationHandleRecord } from './orca-runtime-core'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import { randomUUID } from 'node:crypto'
export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuildPtyTerminalSummary {
protected bindPtyIncarnationHandle(
retained: PtyIncarnationHandleRecord,
leaf: RuntimeLeafRecord
): void {
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
if (retained.leafKey !== leafKey) {
if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) {
this.handleByLeafKey.delete(retained.leafKey)
}
retained.leafKey = leafKey
}
this.handles.set(retained.handle, {
handle: retained.handle,
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
worktreeId: leaf.worktreeId,
tabId: leaf.tabId,
leafId: leaf.leafId,
ptyId: leaf.ptyId,
ptyGeneration: leaf.ptyGeneration
})
this.handleByLeafKey.set(leafKey, retained.handle)
}
protected invalidatePtyIncarnationHandle(ptyId: string): void {
const retained = this.handleByPtyIncarnation.get(ptyId)
if (!retained) {
return
}
this.handleByPtyIncarnation.delete(ptyId)
if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) {
this.handleByLeafKey.delete(retained.leafKey)
}
this.handles.delete(retained.handle)
this.syntheticTerminalHandles.delete(retained.handle)
this.rejectWaitersForHandle(retained.handle, 'terminal_handle_stale')
}
protected clearPtyIncarnationHandles(): void {
for (const retained of this.handleByPtyIncarnation.values()) {
this.syntheticTerminalHandles.delete(retained.handle)
}
this.handleByPtyIncarnation.clear()
}
protected reconcilePtyIncarnationHandles(): void {
for (const [ptyId, retained] of this.handleByPtyIncarnation) {
const pty = this.ptysById.get(ptyId)
const leaves = this.getLeavesForPty(ptyId)
if (
!pty?.incarnationId ||
pty.incarnationId !== retained.incarnationId ||
leaves.length !== 1 ||
this.handleByPtyId.has(ptyId)
) {
this.invalidatePtyIncarnationHandle(ptyId)
continue
}
this.bindPtyIncarnationHandle(retained, leaves[0])
}
}
protected adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null {
if (!leaf.ptyId) {
return null
}
const preAllocated = this.handleByPtyId.get(leaf.ptyId)
if (!preAllocated) {
return null
}
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
this.handles.set(preAllocated, {
handle: preAllocated,
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
worktreeId: leaf.worktreeId,
tabId: leaf.tabId,
leafId: leaf.leafId,
ptyId: leaf.ptyId,
ptyGeneration: leaf.ptyGeneration
})
this.handleByLeafKey.set(leafKey, preAllocated)
return preAllocated
}
protected issuePtyHandle(pty: RuntimePtyWorktreeRecord): string {
const existingHandle =
this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId)
if (existingHandle) {
const existingRecord = this.handles.get(existingHandle)
if (
existingRecord &&
existingRecord.runtimeId === this.runtimeId &&
existingRecord.ptyId === pty.ptyId
) {
this.handleByPtyId.set(pty.ptyId, existingHandle)
return existingHandle
}
}
const handle = existingHandle ?? `term_${randomUUID()}`
if (!existingHandle) {
this.syntheticTerminalHandles.add(handle)
}
const syntheticId = `pty:${pty.ptyId}`
this.handles.set(handle, {
handle,
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
worktreeId: pty.worktreeId,
tabId: syntheticId,
leafId: syntheticId,
ptyId: pty.ptyId,
ptyGeneration: 0
})
this.handleByPtyId.set(pty.ptyId, handle)
return handle
}
protected findHandleForPtyRecord(ptyId: string): string | null {
for (const [handle, record] of this.handles) {
if (
record.runtimeId === this.runtimeId &&
record.ptyId === ptyId &&
record.tabId.startsWith('pty:')
) {
return handle
}
}
return null
}
protected refreshWritableFlags(): void {
for (const leaf of this.leaves.values()) {
leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null
}
}
protected invalidateLeafHandle(leafKey: string): void {
const handle = this.handleByLeafKey.get(leafKey)
if (!handle) {
return
}
const record = this.handles.get(handle)
if (record?.ptyId && this.handleByPtyIncarnation.get(record.ptyId)?.handle === handle) {
this.handleByPtyIncarnation.delete(record.ptyId)
}
this.handleByLeafKey.delete(leafKey)
this.handles.delete(handle)
this.syntheticTerminalHandles.delete(handle)
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
}
protected adoptFirstPtyForLeafHandle(
leafKey: string,
ptyId: string | null,
ptyGeneration: number
): boolean {
const handle = this.handleByLeafKey.get(leafKey)
const record = handle ? this.handles.get(handle) : null
if (!handle || !record || record.ptyId !== null || ptyId === null) {
return false
}
this.handles.set(handle, { ...record, ptyId, ptyGeneration })
return true
}
protected rememberDetachedPreAllocatedLeaves(): void {
for (const leaf of this.leaves.values()) {
if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) {
// Why: ORCA_TERMINAL_HANDLE is an agent identity, so CLI control survives renderer graph loss while the PTY is alive.
this.detachedPreAllocatedLeaves.set(leaf.ptyId, leaf)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
import type { PtyControllerTerminalIdentity } from './runtime-pty-controller-contract'
import type { PtyProcessInfo } from '../providers/types'
export function buildControllerTerminalIdentities(sessions: PtyProcessInfo[]): {
controllerIdentityByPtyId: Map<string, PtyControllerTerminalIdentity>
ptyIdByControllerHandle: Map<string, string>
} {
const controllerIdentityByPtyId = new Map<string, PtyControllerTerminalIdentity>()
const ptyIdByControllerHandle = new Map<string, string>()
const ambiguousControllerPtyIds = new Set<string>()
for (const session of sessions) {
const handle = session.terminalHandle?.trim()
const incarnationId = session.incarnationId?.trim()
if (!handle?.startsWith('term_') || !incarnationId) {
continue
}
const priorPtyId = ptyIdByControllerHandle.get(handle)
if (priorPtyId && priorPtyId !== session.id) {
ambiguousControllerPtyIds.add(priorPtyId)
ambiguousControllerPtyIds.add(session.id)
controllerIdentityByPtyId.delete(priorPtyId)
continue
}
if (controllerIdentityByPtyId.has(session.id)) {
ambiguousControllerPtyIds.add(session.id)
controllerIdentityByPtyId.delete(session.id)
continue
}
ptyIdByControllerHandle.set(handle, session.id)
controllerIdentityByPtyId.set(session.id, {
handle,
incarnationId,
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {})
})
}
for (const ptyId of ambiguousControllerPtyIds) {
controllerIdentityByPtyId.delete(ptyId)
}
return { controllerIdentityByPtyId, ptyIdByControllerHandle }
}
@@ -0,0 +1,216 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from './orca-runtime-persist-terminal-surface-retirements'
import type {
RuntimeMobileSessionBrowserTab,
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot
} from '../../shared/runtime-types'
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import type { Tab } from '../../shared/tab-types'
import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close'
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback'
export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements {
// Why: headless serve backs browser panes with offscreen WebContents that live
// only in the BrowserManager, never in a renderer graph. Without surfacing them
// as session tabs, a session.tabs snapshot (e.g. on terminal open) prunes the
// paired browser tab and closing it fails with tab_not_found. Synthesize browser
// session tabs from the live bridge so they are first-class alongside terminals.
protected buildHeadlessMobileSessionBrowserTabs(
worktreeId: string
): RuntimeMobileSessionBrowserTab[] {
const serverTabs =
this.offscreenBrowserBackend && this.agentBrowserBridge?.tabList
? this.agentBrowserBridge.tabList(worktreeId).tabs
: []
const publishedServerTabs = serverTabs.map((tab) => {
const persistedProps = this.getPersistedUnifiedSessionTabProps(worktreeId, tab.browserPageId)
return {
type: 'browser' as const,
// Why: an offscreen page has no separate workspace identity, so the page id
// is its own workspace id (matches the server's browserWorkspaceId fallback).
id: tab.browserPageId,
title: tab.title || tab.url || 'Browser',
browserWorkspaceId: tab.browserPageId,
browserPageId: tab.browserPageId,
url: tab.url || 'about:blank',
loading: false,
canGoBack: false,
canGoForward: false,
loadError: tab.loadError ?? undefined,
certificateFailure: tab.certificateFailure ?? undefined,
...(persistedProps ? { color: persistedProps.color } : {}),
...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}),
isActive: tab.active === true
}
})
const publishedClientTabs = getRuntimeBrowserPageRegistry(this)
.listPages(worktreeId)
.map((page) => ({
type: 'browser' as const,
id: page.browserPageId,
title: page.title || page.url || 'Browser',
browserWorkspaceId: page.browserPageId,
browserPageId: page.browserPageId,
browserProfileId: page.browserProfileId,
executionHostKey: page.executionHostKey,
placement: page.placement,
url: page.url,
loading: page.loading,
canGoBack: page.canGoBack,
canGoForward: page.canGoForward,
isActive: page.active
}))
return [...publishedServerTabs, ...publishedClientTabs]
}
protected getPersistedUnifiedSessionTabProps(
worktreeId: string,
tabId: string
): Pick<Tab, 'color' | 'isPinned'> | null {
const tab =
this.getWorkspaceSessionForWorktree(worktreeId)?.unifiedTabs?.[worktreeId]?.find(
(candidate) => candidate.id === tabId || candidate.entityId === tabId
) ?? null
return tab ? { color: tab.color, isPinned: tab.isPinned } : null
}
protected commitHeadlessTerminalTabRetirement(
worktreeId: string,
parentTabId: string,
options: { allowMissing?: boolean } = {}
): string[] {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session || !this.store?.setWorkspaceSession || !this.store.flushOrThrow) {
throw new Error('workspace_session_unavailable')
}
const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId)
if (result.pinned) {
throw new Error('terminal_tab_pinned')
}
if (!result.closed) {
if (!options.allowMissing) {
throw new Error('tab_not_found')
}
}
const persisted = result.closed
? advanceTerminalTopologyRevision(result.session, worktreeId)
: session
this.setWorkspaceSessionForWorktree(worktreeId, persisted)
const staged = this.getWorkspaceSessionForWorktree(worktreeId)
try {
this.store.flushOrThrow()
} catch (error) {
const current = this.getWorkspaceSessionForWorktree(worktreeId)
if (staged && current) {
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current)
if (rolledBack !== current) {
this.setWorkspaceSessionForWorktree(worktreeId, rolledBack)
}
}
throw error
}
return result.ptyIdsToKill
}
protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session || !this.store?.setWorkspaceSession) {
return
}
const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index]))
const tabs = session.tabsByWorktree[worktreeId] ?? []
const reordered = [...tabs]
.sort((a, b) => {
const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER
return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt
})
.map((tab, index) => ({
...tab,
sortOrder: index
}))
this.setWorkspaceSessionForWorktree(worktreeId, {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[worktreeId]: reordered
}
})
}
protected emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void {
if (this.mobileSessionTabListeners.size === 0) {
return
}
const result = this.toMobileSessionTabsResult(snapshot)
const changeSequence = ++this.mobileSessionTabsChangeSequence
for (const subscription of this.mobileSessionTabListeners) {
subscription.listener(
this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId),
changeSequence
)
}
}
/**
* Answers one client's session-tabs question: whether this runtime has taken back *that* client's
* client-hosted pages yet, then that client's own tab selection.
*
* The hold is decided here and nowhere else, and it is set or cleared rather than only set, so a
* frame built for one client can never carry another client's answer.
*/
protected projectMobileSessionTabsForClient(
result: RuntimeMobileSessionTabsResult,
clientNavigationId?: string
): RuntimeMobileSessionTabsResult {
return this.clientSessionTabSelections.project(
this.withClientHostedPagesHold(result, clientNavigationId),
clientNavigationId
)
}
protected withClientHostedPagesHold(
result: RuntimeMobileSessionTabsResult,
clientNavigationId: string | undefined
): RuntimeMobileSessionTabsResult {
return this.clientHostedPageReconciliation.holdFor(result, clientNavigationId, Date.now())
}
protected async refreshMobileSessionPtyRecords(
targetWorktreeId: string | null = null
): Promise<Set<string> | null> {
const inventory = await this.refreshMobileSessionPtyInventory(targetWorktreeId)
return inventory ? new Set(inventory.livePtyIds) : null
}
protected async refreshMobileSessionPtyInventory(
targetWorktreeId: string | null = null
): Promise<PtyControllerInventory | null> {
// Targeted mobile polls must not queue behind an aggregate census that may
// be waiting on an unrelated SSH provider.
if (targetWorktreeId !== null && targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) {
return this.performMobileSessionPtyRecordsRefresh(targetWorktreeId)
}
if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) {
// Fleet-wide refreshes share one aggregate controller inventory.
const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh
if (pending) {
return pending
}
// Why: reconnect exit bursts share one authoritative daemon inventory
// instead of multiplying a full cross-generation list RPC per stale tab.
const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => {
if (this.pendingMobileSessionPtyAggregateInventoryRefresh === refresh) {
this.pendingMobileSessionPtyAggregateInventoryRefresh = null
}
})
this.pendingMobileSessionPtyAggregateInventoryRefresh = refresh
return refresh
}
return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId)
}
}
@@ -0,0 +1,176 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithGetPtyRecordForPaneKey } from './orca-runtime-get-pty-record-for-pane-key'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../shared/runtime-types'
import { getLatestPtyTitle } from './runtime-worktree-status-projection'
import { parsePaneKey } from '../../shared/stable-pane-id'
import type { TerminalHandleRecord } from './runtime-terminal-contracts'
import { readTerminalTail } from './terminal-tail-read'
import { randomUUID } from 'node:crypto'
export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPtyRecordForPaneKey {
protected buildPtyTerminalSummary(
pty: RuntimePtyWorktreeRecord,
worktreesById: Map<string, ResolvedWorktree>
): RuntimeTerminalSummary {
const worktree = worktreesById.get(pty.worktreeId)
const title = getLatestPtyTitle(pty)
const pane = parsePaneKey(pty.paneKey ?? '')
const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId
return {
handle: this.issuePtyHandle(pty),
ptyId: pty.ptyId,
incarnationId: pty.incarnationId,
orphaned,
worktreeId: pty.worktreeId,
worktreePath: worktree?.path ?? '',
branch: worktree?.branch ?? '',
tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!,
leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId,
title,
connected: pty.connected,
writable: pty.connected,
lastOutputAt: pty.lastOutputAt,
preview: pty.preview,
...(pty.lastExitCause ? { exitCause: pty.lastExitCause } : {}),
...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId),
...this.resolvePaneAgentIdentityField(
pty.launchAgent,
pty.foregroundAgent,
title,
pty.paneKey ?? null
)
}
}
protected getLiveLeafForHandle(handle: string): {
record: TerminalHandleRecord
leaf: RuntimeLeafRecord
} {
this.assertGraphReady()
const record = this.handles.get(handle)
if (!record || record.runtimeId !== this.runtimeId) {
throw new Error('terminal_handle_stale')
}
if (record.rendererGraphEpoch !== this.rendererGraphEpoch) {
throw new Error('terminal_handle_stale')
}
const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId))
if (!leaf || leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) {
throw new Error('terminal_handle_stale')
}
return { record, leaf }
}
protected getLivePtyForHandle(handle: string): {
record: TerminalHandleRecord
pty: RuntimePtyWorktreeRecord
} | null {
let record = this.handles.get(handle)
if (!record) {
const ptyId = [...this.handleByPtyId.entries()].find(
([, mappedHandle]) => mappedHandle === handle
)?.[0]
const pty = ptyId ? this.ptysById.get(ptyId) : null
if (pty) {
// Why: graph reload clears renderer handle records, but runtime-owned PTY handles remain the caller's control identity.
this.issuePtyHandle(pty)
record = this.handles.get(handle)
}
}
if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) {
return null
}
if (!record.ptyId) {
return null
}
const pty = this.ptysById.get(record.ptyId)
if (!pty || pty.ptyId !== record.ptyId) {
return null
}
// Why: renderer adoption can race with CLI reads; keep ptyId → handle populated so summaries don't mint a second handle for the same terminal.
this.handleByPtyId.set(record.ptyId, handle)
return { record, pty }
}
protected assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void {
const runtimePty = this.getLivePtyForHandle(handle)
if (runtimePty) {
if (runtimePty.pty.ptyId !== expectedPtyId) {
throw new Error('terminal_handle_stale')
}
return
}
const { leaf } = this.getLiveLeafForHandle(handle)
if (leaf.ptyId !== expectedPtyId) {
throw new Error('terminal_handle_stale')
}
}
protected readPtyTerminal(
handle: string,
pty: RuntimePtyWorktreeRecord,
opts: { cursor?: number; limit?: number } = {}
): RuntimeTerminalRead {
return readTerminalTail({
handle,
status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown',
previewLines: pty.tailBuffer,
completedLines: pty.tailTranscriptBuffer,
partialLine: pty.tailPartialLine,
completedLineCount: pty.tailLinesTotal,
bufferTruncated: pty.tailTruncated,
cursor: opts.cursor,
limit: opts.limit
})
}
protected issueHandle(leaf: RuntimeLeafRecord): string {
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
const existingHandle = this.handleByLeafKey.get(leafKey)
if (existingHandle) {
const existingRecord = this.handles.get(existingHandle)
if (
existingRecord &&
existingRecord.rendererGraphEpoch === this.rendererGraphEpoch &&
existingRecord.ptyId === leaf.ptyId &&
existingRecord.ptyGeneration === leaf.ptyGeneration
) {
return existingHandle
}
}
const preAllocatedHandle = this.adoptPreAllocatedHandle(leaf)
if (preAllocatedHandle) {
return preAllocatedHandle
}
const incarnationId = leaf.ptyId ? (this.ptysById.get(leaf.ptyId)?.incarnationId ?? null) : null
const retained = leaf.ptyId ? this.handleByPtyIncarnation.get(leaf.ptyId) : undefined
if (retained && leaf.ptyId && retained.incarnationId !== incarnationId) {
this.invalidatePtyIncarnationHandle(leaf.ptyId)
} else if (retained) {
this.bindPtyIncarnationHandle(retained, leaf)
return retained.handle
}
const handle = `term_${randomUUID()}`
this.syntheticTerminalHandles.add(handle)
this.handles.set(handle, {
handle,
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
worktreeId: leaf.worktreeId,
tabId: leaf.tabId,
leafId: leaf.leafId,
ptyId: leaf.ptyId,
ptyGeneration: leaf.ptyGeneration
})
this.handleByLeafKey.set(leafKey, handle)
if (leaf.ptyId && incarnationId) {
this.handleByPtyIncarnation.set(leaf.ptyId, { handle, incarnationId, leafKey })
}
return handle
}
}
@@ -0,0 +1,191 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithSerializeTerminalBufferFromAvailableState } from './orca-runtime-serialize-terminal-buffer-from-available-state'
import type { PtyProviderBufferSnapshot } from '../providers/types'
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
import type { RuntimeProviderSnapshotReadOptions } from './runtime-terminal-contracts'
import {
buildVisibleSnapshotReadFallback,
shouldFallbackToVisibleTerminalSnapshot,
terminalReadLimit
} from './terminal-tail-read'
import type { RuntimeTerminalProjection } from './orca-runtime-core'
import { DEFAULT_TERMINAL_READ_LIMIT } from './terminal-tail-limits'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import { projectTerminalTailLines } from './orca-runtime-terminal-projection'
export class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaRuntimeWithSerializeTerminalBufferFromAvailableState {
protected async captureProviderTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number },
generation: number
): Promise<PtyProviderBufferSnapshot | null> {
const liveModeTracker = new TerminalKittyKeyboardModeTracker()
let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId)
if (!liveModeTrackers) {
liveModeTrackers = new Set()
this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers)
}
liveModeTrackers.add(liveModeTracker)
try {
// Why: daemon PTYs survive an app relaunch before any renderer mounts.
// Mobile still needs their retained history without navigating desktop.
const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts)
if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) {
return null
}
const snapshotModeTracker = new TerminalKittyKeyboardModeTracker()
if (typeof snapshot.alternateScreen === 'boolean') {
snapshotModeTracker.scan(snapshot.alternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
} else {
// Why: older providers omit mode metadata, but their ANSI snapshot
// still carries the DECSET/DECRST needed to classify the active screen.
snapshotModeTracker.scanReplay(snapshot.data)
}
const observedSnapshotMode = snapshotModeTracker.hasObservedAlternateScreenSwitch
let effectiveAlternateScreen: boolean | undefined
if (observedSnapshotMode || liveModeTracker.hasObservedAlternateScreenSwitch) {
const modeTracker = new TerminalKittyKeyboardModeTracker()
if (observedSnapshotMode) {
modeTracker.scan(snapshotModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
}
// Why: stream bytes received after the request began can be newer
// than snapshot metadata, so an observed live transition wins.
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
}
this.providerModeTrackersByPtyId.set(ptyId, modeTracker)
effectiveAlternateScreen = modeTracker.isAlternateScreen
}
const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0
const reconciledSnapshot = this.preferTrackedLastTitle(ptyId, {
...snapshot,
seq: providerOffset + snapshot.seq,
...(effectiveAlternateScreen !== undefined
? { alternateScreen: effectiveAlternateScreen }
: {})
})
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
this.providerSnapshotsWithLiveModeTransition.add(reconciledSnapshot)
}
return reconciledSnapshot
} catch {
return null
} finally {
liveModeTrackers.delete(liveModeTracker)
if (liveModeTrackers.size === 0) {
this.providerModeSnapshotScansByPtyId.delete(ptyId)
}
}
}
protected async withVisibleSnapshotFallback(
ptyId: string,
read: RuntimeTerminalRead,
opts: { cursor?: number; limit?: number } = {},
providerSnapshot: RuntimeProviderSnapshotReadOptions = {}
): Promise<RuntimeTerminalRead> {
if (typeof opts.cursor === 'number') {
return read
}
const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts)
const recoveredWorkerFallback =
read.tail.length === 0 && this.legacyWorkerRecovery.hasRecoveredPty(ptyId)
// Why: a live daemon session no pane ever attached has ingested zero bytes,
// so only the provider holds its screen. Unprovable state stays empty.
const neverAttachedProviderFallback =
read.tail.length === 0 &&
!recoveredWorkerFallback &&
this.isKnownUnattachedLocalDaemonPty(ptyId)
if (recoveredWorkerFallback || neverAttachedProviderFallback) {
const providerProjection = await this.readProviderTerminalTailLines(
ptyId,
opts.limit,
providerSnapshot
)
if (providerProjection.lines.length > 0) {
return buildVisibleSnapshotReadFallback(
read,
providerProjection.lines,
opts.limit,
providerProjection.draft
)
}
}
const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId)
const providerModeUnknown =
this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId)
if (
!blankFallback &&
!recoveredWorkerFallback &&
!providerModeUnknown &&
!knownAlternateScreen &&
!this.headlessTerminals.has(ptyId)
) {
return read
}
const visibleState = await this.readVisibleTerminalState(ptyId)
if (
!blankFallback &&
!recoveredWorkerFallback &&
!knownAlternateScreen &&
!visibleState?.isAlternateScreen
) {
return read
}
let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] }
if (projection.lines.length === 0) {
projection = await this.readRendererVisibleSnapshotLines(ptyId)
}
if (projection.lines.length === 0) {
return read
}
return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft)
}
protected async readProviderTerminalTailLines(
ptyId: string,
limit: number | undefined,
snapshotOptions: RuntimeProviderSnapshotReadOptions = {}
): Promise<RuntimeTerminalProjection> {
const generation = this.getPtyLifecycleGeneration(ptyId)
const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT)
const snapshot = await this.serializeProviderTerminalBuffer(
ptyId,
{ scrollbackRows: snapshotOptions.visibleScreenOnly ? 0 : lineLimit },
snapshotOptions
)
if (!snapshot) {
return { lines: [] }
}
// Why: a cached acquisition can carry scrollback this caller did not ask for,
// so visible-only reads parse the grid itself rather than trusting the request.
if (snapshotOptions.visibleScreenOnly) {
const projection = await this.parseVisibleSnapshot(snapshot)
// Live bytes ordered after the provider frame make that frame stale.
return this.getPtyLifecycleGeneration(ptyId) === generation &&
this.getPtyOutputSequence(ptyId) <= snapshot.seq
? projection
: { lines: [] }
}
const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
if (data.length === 0) {
return { lines: [] }
}
const emulator = new HeadlessEmulator({
cols: snapshot.cols,
rows: snapshot.rows,
scrollback: lineLimit
})
try {
await emulator.write(data)
const projection = projectTerminalTailLines(emulator, lineLimit)
return this.getPtyLifecycleGeneration(ptyId) === generation &&
this.getPtyOutputSequence(ptyId) <= snapshot.seq
? projection
: { lines: [] }
} finally {
emulator.dispose()
}
}
}
@@ -0,0 +1,230 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './orca-runtime-close-structured-agent-session-tab'
import type {
RuntimeMobileSessionTabMove,
RuntimeMobileSessionTabMoveResult,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
import { buildHeadlessMobileSessionTabGroups } from './mobile-session-layout-projection'
import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types'
export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithCloseStructuredAgentSessionTab {
protected closeHeadlessMobileTerminalTab(
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab,
options: {
allowMissingPersistedTab?: boolean
killPtys?: boolean
authorizedPty?: RuntimePtyWorktreeRecord
} = {}
): void {
const closedParentTabId = tab.parentTabId
const retirementProofs = snapshot.tabs.flatMap((candidate) => {
if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) {
return []
}
const proof = this.getMobileSessionTerminalRetirementProof(
worktreeId,
candidate,
options.authorizedPty
)
return proof ? [proof] : []
})
const projectedPtyIds = this.commitHeadlessTerminalTabRetirement(
worktreeId,
closedParentTabId,
{ allowMissing: options.allowMissingPersistedTab }
)
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId)
if (options.authorizedPty) {
options.authorizedPty.runtimeSessionOwned = false
this.setPairedRendererSessionOwnership(options.authorizedPty.ptyId, false)
}
// Why: local provider ids can be reused after restart, so a dormant
// persisted id is not kill authority. SSH relay ids remain durable exact
// identities even before pane metadata reconnects.
const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId)))
for (const candidate of snapshot.tabs) {
if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) {
continue
}
const authorizedPty =
options.authorizedPty &&
this.getMobileTerminalLeafPtyIds(candidate).includes(options.authorizedPty.ptyId)
? options.authorizedPty
: null
const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) ?? authorizedPty
const ptyId = livePty?.ptyId ?? candidate.ptyId
const hasOtherOwner = snapshot.tabs.some(
(other) =>
other.type === 'terminal' &&
other.parentTabId !== closedParentTabId &&
other.ptyId === ptyId
)
if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) {
// Why: a live serve leaf can exist before its debounced binding reaches
// persistence. Include it from the authoritative snapshot so split
// close cannot leave a provider process behind.
ptyIdsToKill.add(ptyId)
}
}
if (options.killPtys !== false) {
for (const ptyId of ptyIdsToKill) {
this.ptyController?.kill(ptyId)
}
}
const nextTabs = snapshot.tabs.filter((candidate) => {
if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) {
return true
}
return false
})
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
...snapshot,
publicationEpoch: `headless:${Date.now().toString(36)}`,
snapshotVersion: snapshot.snapshotVersion + 1,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
tabGroups: buildHeadlessMobileSessionTabGroups(
worktreeId,
nextTabs,
active,
snapshot.tabGroups
),
...(retirementProofs.length > 0
? {
retiredTerminalSurfaces: appendRetiredTerminalSurfaceProofs(
snapshot.retiredTerminalSurfaces,
retirementProofs
)
}
: {}),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
async moveMobileSessionTab(
worktreeSelector: string,
move: RuntimeMobileSessionTabMove
): Promise<RuntimeMobileSessionTabMoveResult> {
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (!snapshot) {
throw new Error('tab_not_found')
}
if (!this.notifier?.moveSessionTab) {
return this.moveHeadlessMobileSessionTab(worktreeId, snapshot, move)
}
const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId)
if (!hostTabId) {
throw new Error('tab_not_found')
}
const publicSnapshot = this.toMobileSessionTabsResult(snapshot)
const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId)
if (!targetGroup) {
throw new Error('target_group_not_found')
}
// Why: web clients address terminal surfaces as tab::leaf, while desktop
// tab grouping is owned by the outer terminal tab id.
if (move.kind === 'reorder') {
const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder)
if (!tabOrder.includes(hostTabId)) {
throw new Error('invalid_tab_order')
}
this.notifier.moveSessionTab(worktreeId, {
...move,
tabId: hostTabId,
tabOrder
})
return { moved: true }
}
this.notifier.moveSessionTab(worktreeId, {
...move,
tabId: hostTabId
})
return { moved: true }
}
// Why: pane geometry inside a tab (split ratios, expanded pane, pane titles)
// is host-authoritative for remote-server tabs but had no push path, so a
// client divider-drag / expand / pane-rename reverted on the next snapshot.
// Persist the structural fields onto the tab's layout, keeping host-owned
// pty bindings and active leaf.
async updateMobileSessionPaneLayout(
worktreeSelector: string,
args: {
tabId: string
root: TerminalPaneLayoutNode | null
expandedLeafId: string | null
titlesByLeafId?: Record<string, string>
}
): Promise<{ updated: true }> {
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
// Why: when a renderer is authoritative (desktop host reached via shared
// control), it owns pane geometry and republishes it — a headless write here
// would be overwritten and could fight the renderer. Persist only headlessly.
if (this.getAvailableAuthoritativeWindow()) {
return { updated: true }
}
// Why: resolve to the host tab id (older/raw-id clients) so the persisted
// layout entry matches, matching setMobileSessionTabProps.
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const hostTabId = snapshot
? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId)
: args.tabId
const resolvedArgs = { ...args, tabId: hostTabId }
const acceptedLayout = this.persistHeadlessTerminalPaneLayout(worktreeId, resolvedArgs)
if (acceptedLayout) {
this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, {
tabId: hostTabId,
root: acceptedLayout.root,
expandedLeafId: acceptedLayout.expandedLeafId,
...(acceptedLayout.titlesByLeafId ? { titlesByLeafId: acceptedLayout.titlesByLeafId } : {})
})
}
return { updated: true }
}
// Why: tab color/pin are host-authoritative for remote-server tabs but had no
// push path, so pinning or coloring a tab reverted on the next snapshot and
// was never persisted. Persist to the workspace session + live snapshot.
async setMobileSessionTabProps(
worktreeSelector: string,
args: {
tabId: string
color?: string | null
isPinned?: boolean
viewMode?: 'terminal' | 'chat'
}
): Promise<{ updated: true }> {
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
// Why: a renderer-authoritative host owns + republishes tab props, so a
// headless write would be overwritten. Persist only when headless.
if (this.getAvailableAuthoritativeWindow()) {
return { updated: true }
}
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const hostTabId = snapshot
? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId)
: args.tabId
this.persistHeadlessSessionTabProps(worktreeId, hostTabId, args)
this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, hostTabId, args)
return { updated: true }
}
}
@@ -0,0 +1,313 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose } from './orca-runtime-refuse-unattributed-mobile-session-tab-close'
import type {
RuntimeMobileSessionTerminalTab,
RuntimeSessionTabCloseReason
} from '../../shared/runtime-types'
import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records'
import {
adjudicateAbsentMobileSessionTabClose,
resolveMobileSessionLifecycleCloseContext,
type MobileSessionLifecycleCloseHost
} from './mobile-session-lifecycle-close-adjudication'
import type { MobileSessionTabCloseOutcome } from './mobile-session-tab-close-outcome'
import {
committedMobileSessionTabClose,
delegatedMobileSessionTabClose,
refusedMobileSessionTabClose
} from './mobile-session-tab-close-outcome'
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import type { RuntimeCommandSurfaceHost } from './orca-runtime-core'
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close'
export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose {
async closeMobileSessionTab(
worktreeSelector: string,
tabId: string,
options: {
reason?: RuntimeSessionTabCloseReason
expectedPublicationEpoch?: string
expectedTerminalHandle?: string
clientNavigationId?: string
localPtyTeardownOwnedExternally?: boolean
expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority
} = {}
): Promise<MobileSessionTabCloseOutcome> {
const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
const observedPtyIds = await this.refreshMobileSessionPtyRecords()
if (graphEpoch !== null) {
this.assertStableReadyGraph(graphEpoch)
}
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) {
// Why: keep-on-unknown must also restore the mirror the caller already pruned.
this.republishMobileSessionTabsSnapshot(worktreeId)
return refusedMobileSessionTabClose('unknown-liveness', {
snapshotRepublished: Boolean(snapshot)
})
}
if (
options.expectedPublicationEpoch !== undefined &&
snapshot?.publicationEpoch !== options.expectedPublicationEpoch
) {
this.republishMobileSessionTabsSnapshot(worktreeId)
return refusedMobileSessionTabClose('stale-publication', {
snapshotRepublished: Boolean(snapshot)
})
}
const ptyCloseAuthority = options.expectedPtyCloseAuthority
? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority)
: null
const tab = options.expectedPtyCloseAuthority
? ptyCloseAuthority?.surface.tab
: (snapshot?.tabs.find((candidate) => candidate.id === tabId) ??
snapshot?.tabs.find(
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId
) ??
snapshot?.tabs.find(
(candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId
))
const lifecycleClose = resolveMobileSessionLifecycleCloseContext({
host: this.getMobileSessionLifecycleCloseHost(),
worktreeId,
tabId,
tab,
authorityTab: ptyCloseAuthority?.surface.tab,
snapshot,
observedPtyIds
})
if (!snapshot || !tab) {
return adjudicateAbsentMobileSessionTabClose({
host: this.getMobileSessionLifecycleCloseHost(),
context: lifecycleClose,
worktreeId,
snapshot,
reason: options.reason,
addressedByPtyCloseAuthority: options.expectedPtyCloseAuthority !== undefined
})
}
if (options.expectedTerminalHandle !== undefined) {
const terminalIncarnationMatches =
tab.type === 'terminal' &&
snapshot.tabs.some(
(candidate) =>
candidate.type === 'terminal' &&
candidate.parentTabId === tab.parentTabId &&
this.getMobileSessionTerminalHandle(worktreeId, candidate) ===
options.expectedTerminalHandle
)
if (!terminalIncarnationMatches) {
this.republishMobileSessionTabsSnapshot(worktreeId)
return refusedMobileSessionTabClose('stale-terminal', {
snapshotRepublished: true
})
}
}
let closedSelectionTabIds = [tab.id]
const finishCommittedClose = (): MobileSessionTabCloseOutcome =>
committedMobileSessionTabClose(
this.clientSessionTabSelections,
worktreeId,
closedSelectionTabIds
)
if (tab.type === 'terminal') {
const parentLeafCount = snapshot.tabs.filter(
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
).length
const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1
if (closingWholeParent) {
closedSelectionTabIds = snapshot.tabs.flatMap((candidate) =>
candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
? [candidate.id, candidate.parentTabId]
: []
)
}
// Why: a non-'user' reason is a client-lifecycle echo ("terminal gone"),
// not authorization to kill. Every destructive branch below can take the
// whole parent down, so any live PTY under the parent means the echo is a
// transport artifact: refuse the close and republish the snapshot so the
// echoing client re-syncs and re-attaches. A reasonless close keeps
// legacy behavior — old clients send user closes without the field.
if (options.reason !== undefined && options.reason !== 'user') {
const leafHasConnectedPty = lifecycleClose.leafHasConnectedPty
if (lifecycleClose.parentLeaves.some(leafHasConnectedPty)) {
// Why: when the echo addresses a dead leaf under a live sibling we
// still refuse (every reachable close path below destroys the whole
// parent, live sibling included) but skip the republish — re-adding
// the dead leaf on the echoing client would feed an endless
// refuse→republish→re-echo cycle.
const addressedDeadLeaf = tab.id === tabId && !leafHasConnectedPty(tab)
if (!addressedDeadLeaf) {
this.republishMobileSessionTabsSnapshot(worktreeId)
}
// Why: both markers are skew-safe; clients must restore a mirror only
// when the host actually republished it, not for a dead leaf.
return refusedMobileSessionTabClose('live-host-pty', {
snapshotRepublished: !addressedDeadLeaf
})
}
if (!closingWholeParent || this.tabs.has(tab.parentTabId)) {
// Why: only the renderer may retire its own tab or split leaf; a
// remote lifecycle echo must never cross that boundary into a kill.
return refusedMobileSessionTabClose('retirement-owner')
}
}
// Why: a runtime-owned headless tab is absent from renderer state, so the
// closeTerminalTab relay below would ack success without killing its PTY,
// and syncMobileSessionTabs would republish the "closed" tab. Only bypass
// the relay when no renderer owns the parent: an adopted tab needs the
// renderer's live pin guard and durable close transaction.
if (closingWholeParent && !this.tabs.has(tab.parentTabId)) {
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, {
allowMissingPersistedTab: Boolean(ptyCloseAuthority),
killPtys:
options.localPtyTeardownOwnedExternally !== true &&
(options.reason === undefined || options.reason === 'user'),
...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {})
})
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
return finishCommittedClose()
}
if (closingWholeParent && this.notifier?.closeTerminalTab) {
// Why: whole-tab close is a lifecycle transaction. The renderer reply
// arrives only after canonical retirement and a forced session flush.
const win = this.getAvailableAuthoritativeWindow()
if (win?.webContents.isDestroyed?.()) {
throw new Error('runtime_unavailable')
}
const releasePublicationThrottle =
options.clientNavigationId && win
? this.rendererPublicationThrottle.acquire(win.webContents)
: () => {}
try {
await (options.localPtyTeardownOwnedExternally
? this.notifier.closeTerminalTab(tab.parentTabId, {
localPtyTeardownOwnedExternally: true
})
: this.notifier.closeTerminalTab(tab.parentTabId))
} finally {
releasePublicationThrottle()
}
const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const remainingTab = remainingSnapshot?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
)
if (
remainingSnapshot &&
remainingTab &&
this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab)
) {
const remainingPtyCloseAuthority = options.expectedPtyCloseAuthority
? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority)
: null
// Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface.
this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, {
// Why: the renderer may already have durably removed the tab before acknowledging.
allowMissingPersistedTab: true,
...(remainingPtyCloseAuthority ? { authorizedPty: remainingPtyCloseAuthority.pty } : {})
})
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
}
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId)
return finishCommittedClose()
}
// Why: notifier implementations without the acknowledged relay may expose
// only raw pane close. Runtime-owned parents still need de-persist + kill.
if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) {
this.closeHeadlessMobileTerminalTab(
worktreeId,
snapshot,
tab,
ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}
)
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
return finishCommittedClose()
}
if (!this.notifier?.closeTerminal) {
this.closeHeadlessMobileTerminalTab(
worktreeId,
snapshot,
tab,
ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}
)
return finishCommittedClose()
}
if (tab.id === tabId) {
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab)
if (pty) {
if (this.ptyController?.kill(pty.ptyId) !== true) {
throw new Error('terminal_close_failed')
}
return finishCommittedClose()
}
this.notifier.closeTerminal(tab.parentTabId)
return delegatedMobileSessionTabClose()
}
// Why: paired web tab bars represent a split terminal with one local
// parent tab id. Closing that parent should close the desktop tab, not
// just whichever leaf happened to be first in the session snapshot.
this.notifier.closeTerminal(tab.parentTabId)
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId)
return delegatedMobileSessionTabClose()
} else if (tab.type === 'browser') {
// Why: a browser tab can be hosted by a client, by the offscreen backend,
// or by the renderer; each surface owns a different retirement path.
const clientPage = tab.browserPageId
? getRuntimeBrowserPageRegistry(this).getPage(tab.browserPageId)
: undefined
if (clientPage) {
await (this as RuntimeCommandSurfaceHost<this>).browserTabClose({
worktree: `id:${worktreeId}`,
page: clientPage.browserPageId
})
} else if (this.isOffscreenMobileSessionBrowserTab(snapshot, tab)) {
await this.offscreenBrowserBackend!.closeTab(tab.browserPageId!).catch(() => {})
this.retireRuntimeOwnedBrowserSessionTab(worktreeId, tab.browserPageId!)
} else {
if (!this.notifier?.closeSessionTab) {
throw new Error('runtime_unavailable')
}
await this.notifier.closeSessionTab(tab.id, worktreeId)
}
} else if (tab.type === 'agent-session') {
if (this.notifier?.closeSessionTab) {
try {
await this.notifier.closeSessionTab(
structuredAgentSessionTabId(tab.sessionId),
worktreeId
)
} catch (error) {
// The renderer already having removed the tab is an idempotent close, not a veto.
if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) {
throw error
}
}
}
await this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab)
} else {
if (!this.notifier?.closeSessionTab) {
throw new Error('runtime_unavailable')
}
await this.notifier.closeSessionTab(tab.id, worktreeId)
}
return finishCommittedClose()
}
protected getMobileSessionLifecycleCloseHost(): MobileSessionLifecycleCloseHost {
return {
tabs: this.tabs,
leaves: this.leaves,
ptysById: this.ptysById,
findPtyForMobileTerminalTab: (worktreeId, tab) =>
this.findPtyForMobileTerminalTab(worktreeId, tab),
republishSnapshot: (worktreeId) => this.republishMobileSessionTabsSnapshot(worktreeId)
}
}
}
@@ -0,0 +1,224 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithCloseMobileSessionTab } from './orca-runtime-close-mobile-session-tab'
import type {
RuntimeMobileSessionAgentTab,
RuntimeMobileSessionBrowserTab,
RuntimeMobileSessionRetiredTerminalSurface,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-merge'
import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication'
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab {
protected async closeStructuredAgentSessionTab(
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionAgentTab
): Promise<void> {
const host = getStructuredAgentSessionHost()
if (typeof host?.setSessionTabVisibility === 'function') {
await host.setSessionTabVisibility(tab.sessionId, false)
}
const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id)
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
tabGroups: (snapshot.tabGroups ?? []).map((group) => ({
...group,
tabOrder: group.tabOrder.filter((id) => id !== tab.id),
activeTabId: group.activeTabId === tab.id ? null : group.activeTabId,
recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id)
})),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
// Why: a refused echoed close means the echoing client already pruned its
// local mirror. Bump the version and emit the unchanged snapshot so clients
// that dedupe by snapshotVersion re-add and re-attach the still-live tab.
protected republishMobileSessionTabsSnapshot(worktreeId: string): void {
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (snapshot) {
this.mobileSessionTabsByWorktree.set(worktreeId, {
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1
})
}
this.notifyMobileSessionTabsChanged(worktreeId)
}
protected getMobileSessionTerminalHandle(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab
): string | null {
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab)
if (!pty) {
return null
}
return this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId)
}
protected getMobileSessionTerminalRetirementProof(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab,
authorizedPty?: RuntimePtyWorktreeRecord
): RuntimeMobileSessionRetiredTerminalSurface | null {
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) ?? authorizedPty ?? null
if (!pty || !this.getMobileTerminalLeafPtyIds(tab).includes(pty.ptyId)) {
return null
}
const terminal = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId)
if (!terminal) {
return null
}
const incarnationId =
pty.incarnationId ??
this.getWorkspaceSessionForWorktree(worktreeId)?.terminalPtyIncarnationsByPaneKey?.[
this.getMobileTerminalPaneKey(tab)
]
return {
parentTabId: tab.parentTabId,
leafId: tab.leafId,
ptyId: pty.ptyId,
terminal,
...(incarnationId ? { incarnationId } : {})
}
}
protected notifyRendererOfHeadlessTerminalClose(parentTabId: string): void {
// Why: this relay is advisory after main owns teardown; renderer failure must
// not prevent the authoritative session flush or turn the close into failure.
try {
this.notifier?.closeTerminal(parentTabId)
} catch (error) {
console.warn('[runtime] failed to notify renderer after headless terminal close', {
parentTabId,
error
})
}
}
protected isOffscreenMobileSessionBrowserTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionBrowserTab
): boolean {
if (!this.offscreenBrowserBackend || !tab.browserPageId) {
return false
}
if (this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch)) {
return true
}
const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree)
return (
snapshot.publicationEpoch.includes(':headless-merge:') &&
accepted !== undefined &&
!getMobileSessionSnapshotTabIdentityKeys(tab).some((id) =>
accepted.rendererTabIdentityKeys.has(id)
) &&
this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)
)
}
// Public so runtime-side page release (lease fencing) can prune a tab whose page is gone.
retireRuntimeOwnedBrowserSessionTab(worktreeId: string, browserPageId: string): boolean {
// Why: before the snapshot guard — worktree removal drops the snapshot first, and the host
// rows for its client pages would otherwise be stranded on screen with nothing to retract them.
this.clientHostedBrowserRows.publish(worktreeId)
this.persistClientHostedBrowserPagesForWorktree(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (!snapshot) {
return false
}
const retiredTab = snapshot.tabs.find(
(candidate): candidate is RuntimeMobileSessionBrowserTab =>
candidate.type === 'browser' && candidate.browserPageId === browserPageId
)
if (!retiredTab) {
return false
}
const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== retiredTab.id)
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
...snapshot,
publicationEpoch: `headless:${Date.now().toString(36)}`,
snapshotVersion: snapshot.snapshotVersion + 1,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
tabGroups: (snapshot.tabGroups ?? []).map((group) => ({
...group,
tabOrder: group.tabOrder.filter((id) => id !== retiredTab.id),
activeTabId: group.activeTabId === retiredTab.id ? null : group.activeTabId
})),
tabs: nextTabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
return true
}
protected markHeadlessBrowserSessionTabActive(
worktreeId: string | undefined,
browserPageId: string,
options: BrowserSessionTabSelectionOptions
): void {
if (!worktreeId) {
return
}
const { targetGroupId, focusesHost } = options
// Why: client-placed pages publish through the page registry and need no offscreen backing.
if (
!this.offscreenBrowserBackend &&
!getRuntimeBrowserPageRegistry(this).getPage(browserPageId)
) {
return
}
// Hydrate first so the freshly created browser tab is present in the snapshot.
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const tab = snapshot?.tabs.find(
(candidate): candidate is RuntimeMobileSessionBrowserTab =>
candidate.type === 'browser' && candidate.browserPageId === browserPageId
)
if (!snapshot || !tab) {
return
}
const {
snapshot: nextSnapshot,
groups: nextGroups,
placedInTargetGroup
} = applyBrowserSessionTabSelection({
snapshot,
tabId: tab.id,
...(targetGroupId !== undefined ? { targetGroupId } : {}),
focusesHost,
publicationEpoch: `headless:${Date.now().toString(36)}`
})
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
// Why: browser group membership is otherwise live-only; persist it so a
// later rebuild keeps the browser in its group instead of coalescing left.
if (placedInTargetGroup && nextSnapshot.tabGroupLayout) {
this.persistHeadlessTabGroups(worktreeId, nextGroups, nextSnapshot.tabGroupLayout)
}
this.emitMobileSessionTabsSnapshot(nextSnapshot)
if (options.caller) {
// Why: the originating device still lands on the tab it just created; only the shared
// snapshot stayed put. Local creates keep the pre-navigation shape by having no caller.
this.applyMobileSessionTabNavigation(
this.getMobileSessionTabsForWorktree(worktreeId),
tab.id,
options.caller.navigation,
options.caller.clientNavigationId
)
}
}
}
@@ -0,0 +1,198 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithSyncWindowGraph } from './orca-runtime-sync-window-graph'
import type { RuntimeMobileSessionTabsResult, RuntimeSyncedTab } from '../../shared/runtime-types'
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
import { parseExecutionHostId } from '../../shared/execution-host'
export class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees extends OrcaRuntimeWithSyncWindowGraph {
// Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and
// this.leaves, so any tab/leaf delta a graph sync installs can flip the
// client payload (pending-handle → ready, tab title) with zero change to the
// stored snapshot. Compare exactly the projection-relevant fields and report
// the affected worktrees; false positives only cost a coalesced no-op emit.
protected collectMobileVisibleGraphChangedWorktrees(
previousTabs: Map<string, RuntimeSyncedTab>,
previousLeaves: Map<string, RuntimeLeafRecord>
): Set<string> {
const changed = new Set<string>()
for (const [tabId, tab] of this.tabs) {
const prev = previousTabs.get(tabId)
if (!prev || prev.title !== tab.title) {
changed.add(tab.worktreeId)
}
}
for (const [tabId, tab] of previousTabs) {
if (!this.tabs.has(tabId)) {
changed.add(tab.worktreeId)
}
}
for (const [leafKey, leaf] of this.leaves) {
const prev = previousLeaves.get(leafKey)
if (
!prev ||
prev.ptyId !== leaf.ptyId ||
prev.connected !== leaf.connected ||
prev.paneTitle !== leaf.paneTitle
) {
changed.add(leaf.worktreeId)
}
}
for (const [leafKey, leaf] of previousLeaves) {
if (!this.leaves.has(leafKey)) {
changed.add(leaf.worktreeId)
}
}
return changed
}
async listMobileSessionTabs(
worktreeSelector: string,
clientNavigationId?: string
): Promise<RuntimeMobileSessionTabsResult> {
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
if (explicitWorktreeId) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId, {
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
})
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId)
await this.refreshMobileSessionPtyRecords(explicitWorktreeId)
this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId)
return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId)
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
})
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id)
await this.refreshMobileSessionPtyRecords()
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id)
return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId)
}
async listAllMobileSessionTabs(
clientNavigationId?: string
): Promise<RuntimeMobileSessionTabsResult[]> {
return (await this.listAllMobileSessionTabsWithChangeSequence(clientNavigationId)).snapshots
}
async listAllMobileSessionTabsWithChangeSequence(clientNavigationId?: string): Promise<{
snapshots: RuntimeMobileSessionTabsResult[]
changeSequence: number
}> {
const inventory = await this.collectAllMobileSessionTabs(clientNavigationId)
return { snapshots: inventory.snapshots, changeSequence: inventory.changeSequence }
}
protected async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{
snapshots: RuntimeMobileSessionTabsResult[]
ptyInventory: PtyControllerInventory | null
changeSequence: number
}> {
for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
})
}
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession()
const ptyInventory = await this.refreshMobileSessionPtyInventory()
this.restoreLivePairedRendererSessionOwnedMobileTerminals(null)
const snapshots = [...this.mobileSessionTabsByWorktree.values()].map((snapshot) =>
this.projectMobileSessionTabsForClient(
this.toMobileSessionTabsResult(snapshot),
clientNavigationId
)
)
return { snapshots, ptyInventory, changeSequence: this.mobileSessionTabsChangeSequence }
}
async listAllMobileSessionTabsInventory(
clientNavigationId?: string,
signal?: AbortSignal
): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> {
const { snapshots, authoritative } =
await this.listAllMobileSessionTabsInventoryWithChangeSequence(clientNavigationId, signal)
return { snapshots, ...(authoritative ? { authoritative } : {}) }
}
async listAllMobileSessionTabsInventoryWithChangeSequence(
clientNavigationId?: string,
signal?: AbortSignal
): Promise<{
snapshots: RuntimeMobileSessionTabsResult[]
authoritative?: true
changeSequence: number
}> {
this.assertSessionTabsInventoryRequestActive(signal)
const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch()
const primed = await this.collectAllMobileSessionTabs(clientNavigationId)
this.assertSessionTabsInventoryRequestActive(signal)
if (
primedPublicationEpoch !== null &&
this.getAuthoritativeSessionTabsInventoryEpoch() === primedPublicationEpoch
) {
return await this.settleSessionTabsInventory(primed, clientNavigationId, signal)
}
while (true) {
const publicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch()
if (publicationEpoch === null) {
await this.waitForSessionTabsInventoryPublication(signal)
continue
}
const inventory = await this.collectAllMobileSessionTabs(clientNavigationId)
this.assertSessionTabsInventoryRequestActive(signal)
if (this.getAuthoritativeSessionTabsInventoryEpoch() === publicationEpoch) {
return await this.settleSessionTabsInventory(inventory, clientNavigationId, signal)
}
}
}
protected async settleSessionTabsInventory(
inventory: {
snapshots: RuntimeMobileSessionTabsResult[]
ptyInventory: PtyControllerInventory | null
changeSequence: number
},
clientNavigationId?: string,
signal?: AbortSignal
): Promise<{
snapshots: RuntimeMobileSessionTabsResult[]
authoritative?: true
changeSequence: number
}> {
if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) {
return {
snapshots: inventory.snapshots,
authoritative: true,
changeSequence: inventory.changeSequence
}
}
const retried = await this.collectAllMobileSessionTabs(clientNavigationId)
this.assertSessionTabsInventoryRequestActive(signal)
return { snapshots: retried.snapshots, changeSequence: retried.changeSequence }
}
supportsAuthoritativeSessionTabsInventory(): boolean {
return process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1'
}
protected assertSessionTabsInventoryRequestActive(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error('client_disconnected')
}
}
protected isCompleteSessionTabsPtyCensus(inventory: PtyControllerInventory | null): boolean {
if (!inventory) {
return false
}
const knownHostIds = this.listKnownExecutionHostIds(inventory.queriedHostIds)
return ![...knownHostIds].some((hostId) => {
const parsed = parseExecutionHostId(hostId)
return parsed?.kind !== 'runtime' && !inventory.queriedHostIds.has(hostId)
})
}
}
@@ -0,0 +1,179 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-terminal-pane'
import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core'
import type { RuntimeTerminalSend } from '../../shared/runtime-types'
import {
assertTerminalInputWithinLimitWithYield,
buildTerminalSendPayload
} from './terminal-send-payload'
import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection'
export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithResolveTerminalPane {
protected controllerKnowsPtyIsLive(ptyId: string): boolean {
try {
return this.ptyController?.hasPty?.(ptyId) === true
} catch {
// Why: liveness lookup failures are doubt; doubt never gates a write.
return false
}
}
/** True only on controller-proven absence; live, unknown, and probe errors all answer false. */
protected isLeafPtyProvenAbsent(ptyId: string): Promise<boolean> {
// Why hasPty and not ptysById: graph sync mirrors a connected record for
// every leaf ptyId — including a prior process's — so runtime records can't
// distinguish live from stale. The controller's exact-id hasPty is the
// provider's own synchronous inventory: a known id is alive, skip probing
// and supersede any cached verdict (the id came back).
if (this.controllerKnowsPtyIsLive(ptyId)) {
this.provenAbsentLeafPtyVerdicts.delete(ptyId)
return Promise.resolve(false)
}
const verdictAt = this.provenAbsentLeafPtyVerdicts.get(ptyId)
if (verdictAt !== undefined) {
if (Date.now() - verdictAt < PROVEN_ABSENT_LEAF_PTY_TTL_MS) {
return Promise.resolve(true)
}
this.provenAbsentLeafPtyVerdicts.delete(ptyId)
}
const probeLiveness = this.ptyController?.probePtyLiveness?.bind(this.ptyController)
if (!probeLiveness) {
return Promise.resolve(false)
}
const inFlight = this.leafPtyAbsenceProbes.get(ptyId)
if (inFlight) {
return inFlight
}
const probe = (async () => {
try {
if ((await probeLiveness(ptyId)) !== false) {
return false
}
this.provenAbsentLeafPtyVerdicts.set(ptyId, Date.now())
return true
} catch {
// Why: a failed probe is unknown, and unknown never rejects a write.
return false
} finally {
this.leafPtyAbsenceProbes.delete(ptyId)
}
})()
this.leafPtyAbsenceProbes.set(ptyId, probe)
return probe
}
async sendTerminal(
handle: string,
action: {
text?: string
enter?: boolean
interrupt?: boolean
},
options: {
signal?: AbortSignal
beforeWrite?: (ptyId: string) => void | Promise<void>
reserveWrite?: (ptyId: string) => void
afterWrite?: (ptyId: string) => void | Promise<void>
suffixFailureError?: string
} = {}
): Promise<RuntimeTerminalSend> {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
if (!pty.pty.connected) {
throw new Error('terminal_not_writable')
}
const payload = buildTerminalSendPayload(action)
if (payload === null) {
throw new Error('invalid_terminal_send')
}
await assertTerminalInputWithinLimitWithYield(action.text)
await this.writeTerminalAction(pty.pty.ptyId, action, payload, options)
return {
handle,
accepted: true,
bytesWritten: Buffer.byteLength(payload, 'utf8')
}
}
const { leaf } = this.getLiveLeafForHandle(handle)
if (!leaf.writable || !leaf.ptyId) {
throw new Error('terminal_not_writable')
}
const payload = buildTerminalSendPayload(action)
if (payload === null) {
throw new Error('invalid_terminal_send')
}
await assertTerminalInputWithinLimitWithYield(action.text)
// Why: leaf.writable mirrors the renderer graph, which can still answer for
// a prior process's ptyId — and provider writes to unknown ids are accepted
// no-ops. Only controller-proven absence rejects; unknown proceeds (a
// restored daemon session takes writes before its pane remounts).
if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) {
throw new Error('terminal_not_writable')
}
await this.writeTerminalAction(leaf.ptyId, action, payload, options)
return {
handle,
accepted: true,
bytesWritten: Buffer.byteLength(payload, 'utf8')
}
}
async sendTerminalAgentPrompt(
handle: string,
prompt: string,
options: {
beforeWrite?: (ptyId: string) => void | Promise<void>
suffixFailureError?: string
signal?: AbortSignal
} = {}
): Promise<RuntimeTerminalSend> {
const payload = buildAgentPromptPasteBytes(prompt)
const pty = this.getLivePtyForHandle(handle)
if (pty) {
if (!pty.pty.connected) {
throw new Error('terminal_not_writable')
}
await assertTerminalInputWithinLimitWithYield(payload)
const generation = this.getPtyLifecycleGeneration(pty.pty.ptyId)
const submits = await this.serializeAgentPromptSubmission(
pty.pty.ptyId,
generation,
async () => {
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
this.assertAgentPromptGeneration(pty.pty.ptyId, generation)
return await this.writeTerminalAgentPrompt(
handle,
pty.pty.ptyId,
generation,
payload,
options
)
}
)
const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits
return { handle, accepted: true, bytesWritten }
}
const { leaf } = this.getLiveLeafForHandle(handle)
if (!leaf.writable || !leaf.ptyId) {
throw new Error('terminal_not_writable')
}
await assertTerminalInputWithinLimitWithYield(payload)
// Why: same absence gate as sendTerminal — a stale graph mirror must not
// accept a prompt into a void; unknown liveness still proceeds.
if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) {
throw new Error('terminal_not_writable')
}
const generation = this.getPtyLifecycleGeneration(leaf.ptyId)
const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, generation, async () => {
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId!)
this.assertAgentPromptGeneration(leaf.ptyId!, generation)
return await this.writeTerminalAgentPrompt(handle, leaf.ptyId!, generation, payload, options)
})
const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits
return { handle, accepted: true, bytesWritten }
}
}
+355
View File
@@ -0,0 +1,355 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
import { isCursorAgentTitle } from '../../shared/agent-detection'
import { isAbsolute, relative, resolve } from 'node:path'
import type {
RuntimeTerminalDriverState,
RuntimeTerminalPresentation
} from '../../shared/runtime-types'
import type { RuntimeEdgeCommandSurface } from './runtime-edge-command-controller'
import type { RuntimeLinearCommandSurface } from './runtime-linear-command-surface'
import type { RuntimeFileCommandSurface } from './runtime-file-command-surface'
import type { RuntimeGitCommandSurface } from './runtime-git-command-surface'
import type { RuntimeRepositoryCommandSurface } from './runtime-repository-command-surface'
import type { RuntimeReviewCommandSurface } from './runtime-review-command-surface'
import type { RuntimeServiceCommandSurface } from './runtime-service-command-surface'
import type { RuntimeSkillCommandSurface } from './runtime-skill-command-surface'
export type PtyIncarnationHandleRecord = {
handle: string
incarnationId: string | null
leafKey: string
}
export type RuntimeWorktreeScanCache = {
generation: number
runtimeKey: string
result: RuntimeWorktreeScanResult
expiresAt: number
adminFingerprint: string | null
scannedAt: number
}
export type RuntimeWorktreeScanInFlight = {
generation: number
runtimeKey: string
promise: Promise<RuntimeWorktreeScanRefresh>
}
export type RuntimeWorktreeScanRefresh = {
result: RuntimeWorktreeScanResult
adminFingerprint: string | null
adminFingerprintProbe: Promise<string | null> | null
scannedAt: number
}
export type ResolvedTerminalWorkspaceLaunchTarget = {
scope: TerminalWorkspaceLaunchScope
managedWorktree: ResolvedWorktree | null
}
export function isCursorAgentOrchestrationTarget(
leaf: RuntimeLeafRecord,
tabTitle: string | null | undefined
): boolean {
return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle)
}
export const AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT = 512
export const AGENT_SESSION_OPERATION_GLOBAL_LIMIT = 4_096
// Why: long enough for a phone to reconnect and retry a create whose response
// was lost, short enough that an intentional later re-resume forks fresh.
export const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000
// Why: same idempotency window for worktree.create — a phone whose create was
// interrupted by a connection migration retries with the same clientMutationId
// and reuses the just-created worktree instead of spawning a duplicate.
export const WORKTREE_CREATE_RESULT_TTL_MS = 60_000
export const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
// Why: the split already failed; the caller waits on this teardown only to learn whether the
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
export const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
export const EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS = 2_000
export const CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000
export const CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS = 1500
// Why: Claude emits show-cursor while rendering its composer; output must settle afterward.
export const CLAUDE_AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h'
export const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
export const SSH_PANE_RECOVERY_GRACE_MS = 30_000
// Why: long enough that a keystroke burst to a proven-dead leaf probes once,
// short enough that a recreated session id regains writability quickly even if
// its runtime record (which also invalidates the verdict) is late.
export const PROVEN_ABSENT_LEAF_PTY_TTL_MS = 15_000
export const TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS = 2_000
export type RuntimeTerminalProjection = { lines: string[]; draft?: string }
export function assertAgentPromptRequestActive(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error('request_aborted')
}
}
export async function waitForAgentPromptPromise<T>(
promise: Promise<T>,
signal?: AbortSignal
): Promise<T> {
if (!signal) {
return await promise
}
assertAgentPromptRequestActive(signal)
return await new Promise<T>((resolve, reject) => {
let settled = false
const finish = (result: { value: T } | { error: unknown }): void => {
if (settled) {
return
}
settled = true
signal.removeEventListener('abort', onAbort)
if ('error' in result) {
reject(result.error)
} else {
resolve(result.value)
}
}
const onAbort = (): void => finish({ error: new Error('request_aborted') })
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
return
}
promise.then(
(value) => finish({ value }),
(error: unknown) => finish({ error })
)
})
}
// Generic terminal.send uses setImmediate to let abort/permission/data callbacks run between
// chunks without paying a full Windows timer tick for every 16 KiB write. Agent prompts use an
// atomic bracketed-paste write, so they do not rely on this scheduler.
// Why the global and not node:timers/promises: only the global is intercepted by fake timers,
// so a chunked paste stays observable on the test clock.
export function yieldBetweenTerminalInputChunks(): Promise<void> {
return new Promise<void>((resolve) => {
setImmediate(resolve)
})
}
export async function waitForAgentPromptDelay(
delayMs: number,
signal?: AbortSignal
): Promise<void> {
if (!signal) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
return
}
assertAgentPromptRequestActive(signal)
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer)
reject(new Error('request_aborted'))
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, delayMs)
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
}
})
}
export function findLastCompleteOscTitleRange(data: string): { start: number; end: number } | null {
let last: { start: number; end: number } | null = null
let searchFrom = 0
while (searchFrom < data.length) {
const start = data.indexOf('\x1b]', searchFrom)
if (start === -1) {
break
}
const command = data[start + 2]
if ((command !== '0' && command !== '1' && command !== '2') || data[start + 3] !== ';') {
searchFrom = start + 2
continue
}
let cursor = start + 4
for (; cursor < data.length; cursor += 1) {
if (data[cursor] === '\x07') {
last = { start, end: cursor + 1 }
searchFrom = cursor + 1
break
}
if (data[cursor] !== '\x1b') {
continue
}
if (data[cursor + 1] === '\\') {
last = { start, end: cursor + 2 }
searchFrom = cursor + 2
} else {
searchFrom = cursor
}
break
}
if (cursor === data.length) {
break
}
}
return last
}
export function isClientDisconnectedError(error: unknown): boolean {
return error instanceof Error && error.message === 'client_disconnected'
}
export function createTerminalRevealWarning(handle: string, error?: unknown): string {
const reason =
error instanceof Error && error.message.trim().length > 0
? ` Reason: ${error.message.trim()}.`
: ''
return [
`Terminal ${handle} is running, but Orca could not make it discoverable.${reason}`,
`Run \`orca terminal focus --terminal ${handle}\` to reveal and focus it.`
].join(' ')
}
// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit
// the key rather than send `true`.
export function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } {
return shouldSurface ? {} : { surfaceOwner: false }
}
export function resolveTerminalPresentation(opts: {
presentation?: RuntimeTerminalPresentation
focus?: boolean
activate?: boolean
}): RuntimeTerminalPresentation | undefined {
if (opts.presentation) {
return opts.presentation
}
if (opts.focus === true || opts.activate === true) {
return 'focused'
}
return undefined
}
// Subscribe a listener to a per-key Set, pruning the key's entry once its last
// listener unsubscribes. Returns the unsubscribe callback.
export function addListenerToMap<T>(
map: Map<string, Set<T>>,
key: string,
listener: T
): () => void {
let listeners = map.get(key)
if (!listeners) {
listeners = new Set<T>()
map.set(key, listeners)
}
const set = listeners
set.add(listener)
return () => {
set.delete(listener)
if (set.size === 0) {
map.delete(key)
}
}
}
export function isPathWithinDirectory(directory: string, candidate: string): boolean {
const relativePath = relative(resolve(directory), resolve(candidate))
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
}
export const AGENT_HOOK_RUNTIME_ENV_KEYS = [
'ORCA_AGENT_HOOK_PORT',
'ORCA_AGENT_HOOK_TOKEN',
'ORCA_AGENT_HOOK_ENV',
'ORCA_AGENT_HOOK_VERSION',
'ORCA_AGENT_HOOK_TRANSPORT',
'ORCA_AGENT_HOOK_ENDPOINT'
] as const
// Why: notificationSeq is the desktop-assigned monotonic sequence used for
// mobile reconnect catch-up (#8129). It is added on dispatch (and replay) so a
// client can watermark the last event it delivered and request exactly the
// events after it — idempotent, no duplicate local pushes.
export type RuntimeWorktreeLifecycleEvent =
| { kind: 'created'; worktreeId: string; path: string; branch: string }
| { kind: 'removed'; worktreeId: string; path: string }
// Why: presence-based driver state for the mobile-presence lock. Exactly one
// driver per PTY at any moment. See docs/mobile-presence-lock.md.
// - `idle`: no mobile subscribers; desktop input flows freely
// - `desktop`: at least one mobile client subscribed but desktop reclaimed
// (or all mobile clients are passive `desktop`-mode watchers); desktop
// input flows freely
// - `mobile{clientId}`: a mobile client is the active driver; desktop
// input/resize are dropped server-side and the lock banner is mounted.
// `clientId` is the most recent mobile actor for this PTY.
export type DriverState = RuntimeTerminalDriverState
// Why: per-PTY layout target — what the PTY *should* be at right now.
// `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive
// watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at
// `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed.
// See docs/mobile-terminal-layout-state-machine.md.
export type PtyLayoutTarget =
| { kind: 'desktop'; cols: number; rows: number }
| { kind: 'phone'; cols: number; rows: number; ownerClientId: string }
| { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string }
// Why: authoritative layout state with monotonic seq. Bumped on every
// applyLayout success; emitted on mobile subscribe-stream events so clients
// drop stale events that arrive after a newer transition.
export type PtyLayoutState = PtyLayoutTarget & {
seq: number
appliedAt: number
}
// Why: applyLayout result discriminator. Callers (especially RPC handlers)
// need to distinguish "shipped a new state at seq N" from "no-op — caller
// should not claim a seq it didn't produce." `pty-exited` is terminal;
// `resize-failed` is transient and the caller may retry.
export type ApplyLayoutResult =
| { ok: true; state: PtyLayoutState }
| { ok: false; reason: 'pty-exited' | 'resize-failed' }
export type LayoutQueueEntry = {
running: Promise<ApplyLayoutResult> | null
pending: {
target: PtyLayoutTarget
waiters: ((r: ApplyLayoutResult) => void)[]
}[]
}
export type RuntimeInstalledCommandSurfaces = RuntimeEdgeCommandSurface &
RuntimeLinearCommandSurface &
RuntimeFileCommandSurface &
RuntimeGitCommandSurface &
RuntimeRepositoryCommandSurface &
RuntimeReviewCommandSurface &
RuntimeServiceCommandSurface &
RuntimeSkillCommandSurface
export type RuntimeCommandSurfaceHost<T> = T & RuntimeInstalledCommandSurfaces
export type RuntimeRendererReloadFence = Readonly<{
revision: number
recovery: 'renderer' | 'headless' | 'reloading'
}>
@@ -0,0 +1,141 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithWriteTerminalAgentPrompt } from './orca-runtime-write-terminal-agent-prompt'
import {
CLAUDE_AGENT_PROMPT_RENDER_MARKER,
CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS,
CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS
} from './orca-runtime-core'
import type { RuntimeTerminalWait, RuntimeTerminalWaitCondition } from '../../shared/runtime-types'
export class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRuntimeWithWriteTerminalAgentPrompt {
protected createAgentPromptRenderGate(
ptyId: string,
pasteIngestMs: number
): {
arm: () => void
wait: () => Promise<void>
dispose: () => void
} | null {
const pty = this.ptysById.get(ptyId)
if (!['claude', 'codex'].includes(pty?.launchAgent ?? pty?.foregroundAgent ?? '')) {
return null
}
let armed = false
let observedMarker = false
let settled = false
let ingested = pasteIngestMs <= 0
// Why absolute: the ingest clock starts once, here, but the cap is armed twice (at arm()
// and again on the marker). Re-adding the whole window would charge ingest twice.
const ingestDeadlineAt = Date.now() + pasteIngestMs
let markerCarry = ''
let quietTimer: NodeJS.Timeout | null = null
let hardTimer: NodeJS.Timeout | null = null
let ingestTimer: NodeJS.Timeout | null = null
let resolveRender!: () => void
const rendered = new Promise<void>((resolve) => {
resolveRender = resolve
})
const clearGateTimers = (): void => {
if (quietTimer) {
clearTimeout(quietTimer)
quietTimer = null
}
if (hardTimer) {
clearTimeout(hardTimer)
hardTimer = null
}
if (ingestTimer) {
clearTimeout(ingestTimer)
ingestTimer = null
}
}
const finish = (): void => {
if (settled) {
return
}
settled = true
clearGateTimers()
resolveRender()
}
const armQuietTimer = (): void => {
// Why: the quiet window measures the agent going still after a *complete* paste.
// Silence during ingest is not settlement, so it cannot start the clock.
if (!ingested) {
return
}
if (quietTimer) {
clearTimeout(quietTimer)
}
quietTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS)
}
const armHardTimer = (): void => {
if (hardTimer) {
clearTimeout(hardTimer)
}
hardTimer = setTimeout(
finish,
CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS + Math.max(0, ingestDeadlineAt - Date.now())
)
}
const armIngestTimer = (): void => {
if (ingested || ingestTimer) {
return
}
ingestTimer = setTimeout(
() => {
ingestTimer = null
ingested = true
if (observedMarker) {
armQuietTimer()
}
},
Math.max(0, ingestDeadlineAt - Date.now())
)
}
const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => {
if (!armed || settled) {
return
}
if (!observedMarker) {
const combined = markerCarry + data
markerCarry = combined.slice(-(CLAUDE_AGENT_PROMPT_RENDER_MARKER.length - 1))
if (!combined.includes(CLAUDE_AGENT_PROMPT_RENDER_MARKER)) {
return
}
observedMarker = true
armHardTimer()
}
armQuietTimer()
})
return {
arm: () => {
armed = true
markerCarry = ''
armIngestTimer()
armHardTimer()
},
wait: async () => {
if (settled) {
return
}
await rendered
},
dispose: () => {
unsubscribe()
clearGateTimers()
}
}
}
waitForTerminal(
handle: string,
options?: {
condition?: RuntimeTerminalWaitCondition
timeoutMs?: number
signal?: AbortSignal
}
): Promise<RuntimeTerminalWait> {
return this.terminalWait.wait(handle, options)
}
}
@@ -0,0 +1,248 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithGetAgentSessionExecutionNamespace } from './orca-runtime-get-agent-session-execution-namespace'
import type {
RuntimeAgentSessionRpcCaller,
RuntimeCreateAgentSessionRequest,
RuntimeCreateAgentSessionResult
} from '../../shared/agent-session-host-authority'
import {
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS,
parseAgentSessionOperationTimestamp
} from '../../shared/agent-session-host-authority'
import { createHash } from 'node:crypto'
import {
AGENT_SESSION_OPERATION_GLOBAL_LIMIT,
AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT
} from './orca-runtime-core'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../shared/tui-agent-launch-defaults'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
import {
deterministicAgentSessionUuid,
isAgentSessionOperationOutcomeUnknown
} from './runtime-agent-launch-resolution'
export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSessionExecutionNamespace {
async createAgentSession(
request: RuntimeCreateAgentSessionRequest,
caller: RuntimeAgentSessionRpcCaller = {}
): Promise<RuntimeCreateAgentSessionResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const now = Date.now()
const operationTimestamp = parseAgentSessionOperationTimestamp(request.clientOperationId)
if (
operationTimestamp === null ||
operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS
) {
throw new Error('agent_session_operation_invalid')
}
const callerKey = caller.clientId?.trim() || `trusted-local:${caller.clientKind ?? 'runtime'}`
const operationKey = `${callerKey}\0${request.clientOperationId}`
const requestFingerprint = createHash('sha256')
.update(
JSON.stringify([
request.worktree,
request.agent,
request.prompt ?? null,
request.promptDelivery ?? null,
request.agentArgs ?? null,
request.agentArgs === undefined ? 'host-default' : 'client-override',
request.launchPreferences?.model ?? null,
request.launchPreferences?.effort ?? null,
request.launchPreferences?.mode ?? null,
request.startupCwd ?? null,
request.presentation ?? null,
request.placement?.tabId ?? null,
request.placement?.leafId ?? null,
request.viewMode ?? null
])
)
.digest('base64url')
const existing = this.agentSessionCreateOperations.get(operationKey)
if (existing) {
if (existing.fingerprint !== requestFingerprint) {
throw new Error('agent_session_operation_conflict')
}
const replayed = await existing.promise
return { ...replayed, disposition: 'replayed' }
}
if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) {
// Why: once a tombstone could have expired, an unseen replay must never
// be reinterpreted as permission to start another fresh agent.
throw new Error('agent_session_operation_expired')
}
let callerOperationCount = 0
const callerPrefix = `${callerKey}\0`
for (const key of this.agentSessionCreateOperations.keys()) {
if (key.startsWith(callerPrefix)) {
callerOperationCount += 1
}
}
if (
callerOperationCount >= AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT ||
this.agentSessionCreateOperations.size >= AGENT_SESSION_OPERATION_GLOBAL_LIMIT
) {
// Why: tombstones cannot be evicted early without making an old replay
// capable of spawning again; reject new IDs until retained entries age out.
throw new Error('agent_session_operation_capacity')
}
let retainReplayFence = false
const operation = (async (): Promise<RuntimeCreateAgentSessionResult> => {
// Why: reserve the client operation before any async preflight so concurrent retries cannot
// both observe an empty ledger and reach the execution owner independently.
const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree)
if (
!(await this.executionOwnerSupportsAgentSessionOperation(
workspace,
'create',
caller.signal
))
) {
// Why: the exact legacy launch remains client-owned until this pre-spawn check succeeds.
throw new Error('agent_session_legacy_required')
}
const startupCwd = this.resolveWorkspaceTerminalStartupCwd(workspace, request.startupCwd)
// Why: aliases and object property order are client syntax, not authority;
// fingerprint the host-resolved fields in one fixed order.
const resolvedFingerprint = createHash('sha256')
.update(
JSON.stringify([
workspace.id,
request.agent,
request.prompt ?? null,
request.promptDelivery ?? null,
request.agentArgs ?? null,
request.agentArgs === undefined ? 'host-default' : 'client-override',
request.launchPreferences?.model ?? null,
request.launchPreferences?.effort ?? null,
request.launchPreferences?.mode ?? null,
startupCwd ?? null,
request.presentation ?? null,
request.placement?.tabId ?? null,
request.placement?.leafId ?? null,
request.viewMode ?? null
])
)
.digest('base64url')
const settings = this.store!.getSettings()
if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) {
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
const isRemote = workspace.repo
? repoIsRemote(workspace.repo)
: Boolean(workspace.connectionId)
const shell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
terminalWindowsShell: settings.terminalWindowsShell
})
const startupArgs = {
agent: request.agent,
cmdOverrides: settings.agentCmdOverrides ?? {},
agentArgs:
request.agentArgs !== undefined
? request.agentArgs
: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv),
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
platform,
shell,
isRemote
}
const startup =
request.promptDelivery === 'draft'
? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' })
: buildAgentStartupPlan({
...startupArgs,
prompt: request.prompt ?? '',
allowEmptyPromptLaunch: true
})
if (!startup) {
throw new Error('agent_session_identity_required')
}
await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path)
if (caller.signal?.aborted) {
throw new Error('client_disconnected')
}
let terminal: RuntimeTerminalCreate
const executionOperationId = createHash('sha256')
.update(this.runtimeId)
.update('\0')
.update(operationKey)
.update('\0')
.update(resolvedFingerprint)
.digest('base64url')
const operationTabId =
request.placement?.tabId ?? deterministicAgentSessionUuid(`${executionOperationId}:tab`)
const operationLeafId =
request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`)
const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}`
try {
terminal = await this.createTerminal(`id:${workspace.id}`, {
command: startup.launchCommand,
env: startup.env,
launchConfig: startup.launchConfig,
launchAgent: request.agent,
startupCommandDelivery: startup.startupCommandDelivery,
cwd: startupCwd,
presentation: request.presentation ?? 'background',
tabId: operationTabId,
leafId: operationLeafId,
preAllocatedHandle: operationHandle,
viewMode: request.viewMode,
agentSessionCreateOperationId: executionOperationId,
signal: caller.signal,
onPtySpawnCommitted: () => {
retainReplayFence = true
}
})
} catch (error) {
if (isAgentSessionOperationOutcomeUnknown(error)) {
retainReplayFence = true
}
throw error
}
return { terminal, disposition: 'created' }
})()
this.agentSessionCreateOperations.set(operationKey, {
fingerprint: requestFingerprint,
promise: operation
})
const expireOperation = (): void => {
const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS
const timer = setTimeout(
() => {
if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) {
this.agentSessionCreateOperations.delete(operationKey)
}
},
Math.max(1, expiresAt - Date.now())
)
timer.unref?.()
}
try {
const result = await operation
expireOperation()
return result
} catch (error) {
if (retainReplayFence) {
// Why: the first PTY may still be alive; replay the same failure until
// expiry instead of interpreting a lost outcome as a fresh spawn grant.
expireOperation()
} else if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) {
this.agentSessionCreateOperations.delete(operationKey)
}
throw error
}
}
}
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreeCreatePreparation from '../worktree-create-preparation'
import type { Project } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
const mocks = vi.hoisted(() => ({
prefetchWorktreeCreateBase: vi.fn(),
prepareWorktreeCreateForRepo: vi.fn()
}))
vi.mock('../worktree-create-base-prefetch', () => ({
prefetchWorktreeCreateBase: mocks.prefetchWorktreeCreateBase
}))
vi.mock('../worktree-create-preparation', async (importOriginal) => ({
...(await importOriginal<typeof WorktreeCreatePreparation>()),
prepareWorktreeCreateForRepo: mocks.prepareWorktreeCreateForRepo
}))
import { OrcaRuntimeService } from './orca-runtime'
const repo: Repo = {
id: 'repo-1',
displayName: 'Repo',
path: String.raw`C:\workspace\repo`,
badgeColor: '#000000',
addedAt: 0
}
const project: Project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000000',
sourceRepoIds: ['repo-1'],
createdAt: 0,
updatedAt: 0,
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
}
function makeStore(overrides: Partial<Project> = {}): unknown {
return {
getRepos: () => [repo],
getRepo: (id: string) => (id === repo.id ? repo : undefined),
getProjects: () => [{ ...project, ...overrides }],
getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } })
}
}
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
const hostPlatform = process.platform
beforeEach(() => {
mocks.prefetchWorktreeCreateBase.mockReset().mockResolvedValue(undefined)
mocks.prepareWorktreeCreateForRepo.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
setPlatform(hostPlatform)
_resetWslCachesForTests()
})
// The RPC/relay prefetch is the only warm-up a remote client reaches, so it has
// to resolve the same project runtime the IPC handler does. Constructed through
// the barrel because the split chain resolves selectors in a later subclass.
describe('prefetchManagedWorktreeCreateBase (orca-runtime-get-worktree-terminal-provisioning-host)', () => {
it('warms up in the distro a WSL-routed project runs in', async () => {
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
setPlatform('win32')
const runtime = new OrcaRuntimeService(makeStore() as never)
await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith(
expect.objectContaining({ gitOptions: { wslDistro: 'Ubuntu' } })
)
})
it('warms up on host git when no project runtime routes the repo', async () => {
setPlatform('darwin')
const runtime = new OrcaRuntimeService(makeStore() as never)
await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith(
expect.objectContaining({ gitOptions: {} })
)
})
// A repair-required runtime must degrade to host git, not fail the warm-up.
it('does not surface a repair-required project runtime as a prefetch failure', async () => {
_setWslCachesForTests({ available: true, distros: ['Debian'] })
setPlatform('win32')
const runtime = new OrcaRuntimeService(makeStore() as never)
await expect(
runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
).resolves.toBeUndefined()
expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith(
expect.objectContaining({ gitOptions: {} })
)
})
it('prepares the checkout the prefetch resolved', async () => {
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
setPlatform('win32')
mocks.prefetchWorktreeCreateBase.mockResolvedValue('origin/main')
const runtime = new OrcaRuntimeService(makeStore() as never)
await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
expect(mocks.prepareWorktreeCreateForRepo).toHaveBeenCalledWith(
expect.anything(),
repo,
'origin/main'
)
})
})
@@ -0,0 +1,213 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithCreateManagedWorktree } from './orca-runtime-create-managed-worktree'
import type { Repo } from '../../shared/repo-types'
import type { RuntimeRemoteWorktreeCreateArgs } from './runtime-remote-worktree-create-request'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { createRuntimeRemoteManagedWorktree } from './runtime-remote-managed-worktree-create'
import type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller'
import type { WorktreeBaseStatusEvent } from '../../shared/worktree/base-ref-drift-types'
import { probeRuntimeWorktreeDrift } from './runtime-worktree-drift-probe'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/worktree/types'
import {
persistRuntimeManagedWorktreeSortOrder,
updateRuntimeManagedWorktreeMetadata
} from './runtime-managed-worktree-metadata'
import { resolveRuntimeGitHubWorktreeBase } from './runtime-github-worktree-base'
import { resolveRuntimeGitLabWorktreeBase } from './runtime-gitlab-worktree-base'
export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithCreateManagedWorktree {
protected createManagedRemoteWorktree(
repo: Repo,
args: RuntimeRemoteWorktreeCreateArgs
): Promise<CreateWorktreeResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
return createRuntimeRemoteManagedWorktree(repo, args, {
store: this.store,
canSpawn: () => Boolean(this.ptyController?.spawn),
markTrusted: (agent, connectionId, path) =>
this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, path),
createTerminal: (selector, options) => this.createTerminal(selector, options),
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
provision: (options) => this.provisionManagedWorktreeTerminals(options),
activate: (repoId, worktreeId, setup, startup, defaultTabs) =>
this.notifyActivateWorktree(
repoId,
worktreeId,
setup,
startup,
defaultTabs,
args.navigation
),
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId),
notifyWorktreesChanged: (repoId) => this.notifyWorktreesChanged(repoId)
})
}
async getCanonicalFetchKey(
repoPath: string,
remote: string,
gitOptions: { wslDistro?: string } = {}
): Promise<string> {
return await this.remoteFetches.getCanonicalFetchKey(repoPath, remote, gitOptions)
}
async getOrStartRemoteFetch(
repoPath: string,
remote: string,
gitOptions: { wslDistro?: string } = {}
): Promise<RemoteFetchResult> {
return await this.remoteFetches.getOrStartRemoteFetch(repoPath, remote, gitOptions)
}
async getOrStartRemoteTrackingBaseRefresh(
repoPath: string,
base: RemoteTrackingBase,
gitOptions: { wslDistro?: string } = {}
): Promise<RemoteFetchResult> {
return await this.remoteFetches.getOrStartRemoteTrackingBaseRefresh(repoPath, base, gitOptions)
}
async fetchRemoteWithCache(
repoPath: string,
remote: string,
gitOptions: { wslDistro?: string } = {}
): Promise<void> {
await this.remoteFetches.fetchRemoteWithCache(repoPath, remote, gitOptions)
}
async resolveRemoteTrackingBase(
repoPath: string,
baseBranch: string,
gitOptions: { wslDistro?: string } = {}
): Promise<RemoteTrackingBase | null> {
return await this.remoteFetches.resolveRemoteTrackingBase(repoPath, baseBranch, gitOptions)
}
async hasRemoteTrackingRef(
repoPath: string,
base: RemoteTrackingBase,
gitOptions: { wslDistro?: string } = {}
): Promise<boolean> {
return await this.remoteFetches.hasRemoteTrackingRef(repoPath, base, gitOptions)
}
recordOptimisticReconcileToken(worktreeId: string): string {
return this.worktreeBaseReconciliation.recordToken(worktreeId)
}
clearOptimisticReconcileToken(worktreeId: string): void {
this.worktreeBaseReconciliation.clearToken(worktreeId)
}
emitWorktreeBaseStatus(event: WorktreeBaseStatusEvent): void {
this.worktreeBaseReconciliation.emitStatus(event)
}
async reconcileWorktreeBaseStatus(args: {
repoId: string
repoPath: string
worktreeId: string
base: RemoteTrackingBase
branchName: string
createdBaseSha: string
token: string
fetchPromise: Promise<RemoteFetchResult>
}): Promise<void> {
await this.worktreeBaseReconciliation.reconcile(args)
}
/**
* Probe how far the worktree's HEAD is behind its tracking remote. Returns
* null when the probe cannot establish a signal (no default base ref, or
* git failure). Dispatch treats null as "unknown — proceed" (§3.1); only
* knowing-and-stale refuses.
*/
async probeWorktreeDrift(worktreeSelector: string): Promise<{
base: string
behind: number
recentSubjects: string[]
} | null> {
return probeRuntimeWorktreeDrift({
selector: worktreeSelector,
store: this.store ? this.requireStore() : null,
resolveWorktree: (selector) => this.resolveWorktreeSelector(selector),
resolveRemoteTrackingBase: (repoPath, base, options) =>
this.resolveRemoteTrackingBase(repoPath, base, options),
fetchRemote: (repoPath, remote, options) =>
this.fetchRemoteWithCache(repoPath, remote, options)
})
}
async updateManagedWorktreeMeta(
worktreeSelector: string,
updates: Omit<Partial<WorktreeMeta>, 'pushTarget'> & {
pushTarget?: GitPushTarget | null
lineage?: {
parentWorktree?: string
noParent?: boolean
}
}
) {
if (!this.store) {
throw new Error('runtime_unavailable')
}
return updateRuntimeManagedWorktreeMetadata({
selector: worktreeSelector,
updates,
store: this.store,
ports: {
resolveWorktree: (selector) => this.resolveWorktreeSelector(selector),
validateParent: (worktree, parent) => this.worktreeLineage.validateParent(worktree, parent),
invalidateResolved: () => this.invalidateResolvedWorktreeCache(),
invalidateScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId),
notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId),
showWorktree: (selector) => this.showManagedWorktree(selector)
}
})
}
persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } {
if (!this.store) {
throw new Error('runtime_unavailable')
}
return persistRuntimeManagedWorktreeSortOrder({
orderedIds,
store: this.store,
invalidateResolved: () => this.invalidateResolvedWorktreeCache(),
notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId)
})
}
async resolveManagedPrBase(args: {
repoSelector: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
}): Promise<GitHubPrStartPoint | { error: string }> {
return resolveRuntimeGitHubWorktreeBase(args, {
store: this.store ? this.requireStore() : null,
resolveRepo: (selector) => this.resolveRepoSelector(selector)
})
}
async resolveManagedMrBase(args: {
repoSelector: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
}): Promise<
{ baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string }
> {
return resolveRuntimeGitLabWorktreeBase(args, {
store: this.store ? this.requireStore() : null,
resolveRepo: (selector) => this.resolveRepoSelector(selector)
})
}
}
@@ -0,0 +1,278 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithGetWorktreeTerminalProvisioningHost } from './orca-runtime-get-worktree-terminal-provisioning-host'
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { isFolderRepo } from '../../shared/repo-kind'
import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create'
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup'
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup'
export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost {
async createManagedWorktree(
args: RuntimeManagedWorktreeCreateArgs
): Promise<CreateWorktreeResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const repo = await this.resolveRepoSelector(args.repoSelector)
const createSettings = this.store.getSettings()
const requestedAgent = args.startupAgent ?? args.createdWithAgent
const requestedAgentEnabled =
requestedAgent !== undefined
? isTuiAgentEnabled(requestedAgent, createSettings.disabledTuiAgents)
: false
if ((args.startup || args.startupAgent) && requestedAgent && !requestedAgentEnabled) {
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
if (
args.startup &&
args.startupDraftPaste &&
!isTuiAgentEnabled(args.startupDraftPaste.agent, createSettings.disabledTuiAgents)
) {
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
const agentStartup =
!args.startup && args.startupAgent
? this.buildStartupForAgent(
repo,
args.startupAgent,
args.startupPrompt,
args.startupLaunchPreferences
)
: null
const draftStartup =
!args.startup && !agentStartup && args.startupDraft
? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent)
: null
const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup
const effectiveStartupFollowup = agentStartup?.followup
const effectiveCreatedWithAgent = args.startup
? args.createdWithAgent
: (agentStartup?.agent ??
draftStartup?.agent ??
(requestedAgentEnabled ? requestedAgent : undefined))
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
if (isFolderRepo(repo)) {
return createRuntimeFolderWorktree({
request: args,
repo,
startup: effectiveStartup,
startupFollowup: effectiveStartupFollowup,
createdWithAgent: effectiveCreatedWithAgent,
draftPaste: effectiveDraftPaste,
deps: {
store: this.store,
ptySpawnAvailable: Boolean(this.ptyController?.spawn),
createTerminal: (selector, options) => this.createTerminal(selector, options),
markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path),
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
notifyWorktreesChanged: (repoId) => this.notifyWorktreesChanged(repoId),
emitCreated: (event) => this.emitWorktreeLifecycle(event),
activate: (repoId, worktreeId, setup, startup) =>
this.notifyActivateWorktree(
repoId,
worktreeId,
setup,
startup,
undefined,
args.navigation
)
}
})
}
const lineageInput =
args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined
const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput)
if (repo.connectionId) {
const result = await this.createManagedRemoteWorktree(repo, {
...args,
activate: args.activate,
...(effectiveStartup ? { startup: effectiveStartup } : {}),
...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}),
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
})
const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution)
this.emitWorktreeLifecycle({
kind: 'created',
worktreeId: result.worktree.id,
path: result.worktree.path,
branch: result.worktree.branch
})
return {
...result,
worktree: {
...result.worktree,
parentWorktreeId: recordedLineage.lineage?.parentWorktreeId ?? null,
childWorktreeIds: result.worktree.childWorktreeIds ?? [],
lineage: recordedLineage.lineage,
workspaceLineage: recordedLineage.workspaceLineage
},
...(lineageInput
? {
lineage: recordedLineage.lineage,
workspaceLineage: recordedLineage.workspaceLineage,
warnings: recordedLineage.warnings
}
: {})
}
}
const { worktree, worktreePath, includeCopyWarning, created, addResult, metadataResult } =
await createRuntimeLocalManagedWorktree({
request: args,
repo,
store: this.requireStore(),
createdWithAgent: effectiveCreatedWithAgent,
hostedReviewExecutionContext: this.getHostedReviewExecutionOptions(repo),
resolveRemoteTrackingBase: (path, base, ...options) =>
this.resolveRemoteTrackingBase(path, base, ...options),
hasRemoteTrackingRef: (path, base, ...options) =>
this.hasRemoteTrackingRef(path, base, ...options),
refreshRemoteTrackingBase: (path, base, ...options) =>
this.getOrStartRemoteTrackingBaseRefresh(path, base, ...options),
fetchRemote: (path, remote, ...options) =>
this.fetchRemoteWithCache(path, remote, ...options),
onWorktreeMetadataPersisted: (persistedWorktree) =>
this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution)
})
const settings = createSettings
const { lineage, workspaceLineage, warnings: lineageWarnings } = metadataResult
let {
setup,
defaultTabs,
warning,
effectiveDecision,
hookFound,
shouldRunSetup,
didStartInProcessSetupHook
} = await prepareRuntimeLocalWorktreeSetup({
request: args,
repo,
worktreePath,
settings,
runtimeTarget: this.getLocalGitExecutionOptionArgs(repo)[0],
shouldUseSetupRunner:
this.authoritativeWindowId !== null ||
Boolean(effectiveStartup) ||
Boolean(this.ptyController?.spawn),
warning: includeCopyWarning
})
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(repo.id)
// Why: the filesystem-auth layer maintains a separate cache of registered
// worktree roots used by git IPC handlers (branchCompare, diff, status, etc.)
// to authorize paths. Without invalidating it here, CLI-created worktrees
// are not recognized and all git operations fail with "Access denied:
// unknown repository or worktree path".
invalidateAuthorizedRootsCache()
this.notifyWorktreesChanged(repo.id)
const {
warning: terminalWarning,
returnedSetup,
didSpawnSetup,
didSpawnStartup,
setupTerminalHandle,
startupTerminalHandle,
startupTerminalTabId,
startupTerminalPaneKey,
startupTerminalPtyId
} = await startRuntimeLocalWorktreeTerminals({
request: args,
repo,
worktree,
setup,
defaultTabs,
startup: effectiveStartup,
startupFollowup: effectiveStartupFollowup,
createdWithAgent: effectiveCreatedWithAgent,
draftPaste: effectiveDraftPaste,
warning,
ports: {
canSpawn: Boolean(this.ptyController?.spawn),
markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path),
createTerminal: (selector, options) => this.createTerminal(selector, options),
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
provision: (options) => this.provisionManagedWorktreeTerminals(options),
activate: (repoId, worktreeId, activationSetup, startup, activationDefaultTabs) =>
this.notifyActivateWorktree(
repoId,
worktreeId,
activationSetup,
startup,
activationDefaultTabs,
args.navigation
)
}
})
warning = terminalWarning
this.emitWorktreeLifecycle({
kind: 'created',
worktreeId: worktree.id,
path: worktree.path,
branch: worktree.branch
})
return {
worktree: {
...worktree,
parentWorktreeId: lineage?.parentWorktreeId ?? null,
childWorktreeIds: [],
lineage,
workspaceLineage,
git: created
},
...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}),
...(returnedSetup ? { setup: returnedSetup } : {}),
...(args.awaitTerminalProvisioning
? {
setupReceipt: {
requested: effectiveDecision,
hookFound,
startupPolicy: setup?.waitForAgentStartup
? ('wait-for-setup' as const)
: ('start-immediately' as const),
state: !hookFound
? ('not_configured' as const)
: effectiveDecision === 'skip' || !shouldRunSetup
? ('skipped' as const)
: // Why: the in-process hook is already executing, so reporting
// spawn_failed would strand callers that retry on it.
didSpawnSetup || didStartInProcessSetupHook
? ('running' as const)
: ('spawn_failed' as const),
...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {})
}
}
: {}),
...(defaultTabs ? { defaultTabs } : {}),
...(warning ? { warning } : {}),
...(addResult.localBaseRefRefresh
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
: {}),
...(addResult.localBaseRefUpdateSuggestion
? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion }
: {}),
...(didSpawnStartup && startupTerminalHandle
? {
startupTerminal: {
spawned: true,
handle: startupTerminalHandle,
...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}),
...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}),
...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}),
surface: 'background' as const
}
}
: {})
}
}
}
@@ -0,0 +1,81 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types'
import { navigationTargetsHost } from '../../shared/runtime-navigation'
import { MOBILE_TERMINAL_CREATE_RESULT_TTL_MS } from './orca-runtime-core'
export class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRuntimeWithCreateTerminal {
async createMobileSessionTerminal(
worktreeSelector: string,
opts: {
afterTabId?: string
targetGroupId?: string
command?: string
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
agent?: TuiAgent
agentPrompt?: string
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
activate?: boolean
select?: boolean
clientNavigationId?: string
navigation?: RuntimeNavigationTarget
clientMutationId?: string
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const navigation = opts.navigation ?? 'all'
const select = opts.select ?? opts.activate !== false
const runOpts = {
...opts,
activate: select && navigationTargetsHost(navigation)
}
const mutationId = opts.clientMutationId
let result: RuntimeMobileSessionCreateTerminalResult
if (!mutationId) {
result = await this.runCreateMobileSessionTerminal(worktreeSelector, runOpts)
} else {
// Why: idempotency is caller-owned; two paired devices may reuse the same mutation id without sharing a result.
const mutationKey = `${opts.clientNavigationId ?? 'local'}\0${worktreeSelector}\0${mutationId}`
// Why: a retried create (double-tap, reconnect replay) with the same
// idempotency key must return the in-flight operation instead of spawning a
// duplicate terminal. Successes are kept briefly so a retry whose response
// was lost in transit reuses the created terminal; failures are dropped
// immediately so a retry can start a fresh create.
const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey)
const run = inflight ?? this.runCreateMobileSessionTerminal(worktreeSelector, runOpts)
if (!inflight) {
this.mobileTerminalCreateByMutationId.set(mutationKey, run)
const drop = (): void => {
if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) {
this.mobileTerminalCreateByMutationId.delete(mutationKey)
}
}
void run.then(() => {
setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.()
}, drop)
}
result = await run
}
if (select) {
const worktreeId =
this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ??
(await this.resolveWorktreeSelector(worktreeSelector)).id
this.applyMobileSessionTabNavigation(
this.getMobileSessionTabsForWorktree(worktreeId),
result.tab.id,
navigation,
opts.clientNavigationId
)
}
return result
}
}
@@ -0,0 +1,179 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer } from './orca-runtime-maybe-hydrate-headless-from-renderer'
import type { RuntimeHeadlessTerminal } from './runtime-terminal-state-records'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy'
import { isNativeWindowsConptyPty } from './terminal-model-query-authority'
import { getTerminalViewAttributes } from './terminal-view-attribute-store'
import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror'
export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer {
/** Shared factory for the per-PTY runtime emulators (seed, hydration, and
* lazy live-byte creation): wires the Phase-5 query-reply sink and the
* ConPTY DA1 override. The daemon emulator never goes through here. */
protected createPtyHeadlessTerminalState(
ptyId: string,
dims: { cols: number; rows: number }
): RuntimeHeadlessTerminal {
let state: RuntimeHeadlessTerminal | null = null
const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId))
const emulator = new HeadlessEmulator({
cols: dims.cols,
rows: dims.rows,
pathFlavor,
remotePosixFileUriAuthority:
!!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32',
wslDistro: this.ptysById.get(ptyId)?.connectionId
? undefined
: (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined),
// Why: replies take the provider input path (same entry as pty:write —
// daemon shell-ready gating and the SSH relay write apply unchanged),
// NOT writePtyInput, so renderer interactive-output metering never
// counts responder traffic as user-input echo.
onQueryReply: (reply) => {
// Why the identity check: queued writeChain links can parse after
// disposeHeadlessTerminal, and daemon respawns reuse session ids — a
// stale link's reply must never reach a successor PTY under this id.
if (state !== null && this.headlessTerminals.get(ptyId) === state) {
if (
!shouldForwardHeadlessTerminalQueryReply(this.ptysById.get(ptyId)?.launchAgent, reply)
) {
return
}
// Why this write is safe pre-shell-ready: daemon Session.write
// QUEUES (never drops) input while the POSIX shell-ready gate is
// pending and flushes at the ready marker or the 15s
// SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query
// reply is delayed at most that bound, not lost.
this.ptyController?.write(ptyId, reply)
}
}
})
if (isNativeWindowsConptyPty(ptyId)) {
emulator.installConptyPrimaryDeviceAttributesOverride()
}
// Why the lazy getter: replies must use the freshest renderer push at
// parse time, and stay silent (never default) before the first push.
emulator.installViewAttributeResponder(() => getTerminalViewAttributes())
const viewAttributes = getTerminalViewAttributes()
if (viewAttributes) {
emulator.applyPushedViewAttributes(viewAttributes)
}
const constructed: RuntimeHeadlessTerminal = {
emulator,
outputSequence: 0,
writeChain: Promise.resolve(),
ownership: new PtyShellOwnershipMirror(async () => {
const controller = this.ptyController
const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId)
if (
!controller?.confirmShellForeground ||
this.headlessTerminals.get(ptyId) !== constructed
) {
return false
}
const confirmed = await controller.confirmShellForeground(ptyId)
return (
confirmed &&
this.headlessTerminals.get(ptyId) === constructed &&
this.getPtyLifecycleGeneration(ptyId) === lifecycleGeneration
)
})
}
state = constructed
return state
}
/** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via
* markNativeWindowsConptyPty when the spawn mark lands after daemon stream
* data already created this PTY's emulator. Idempotent emulator-side. */
protected ensureNativeWindowsConptyDa1Override(ptyId: string): void {
if (isNativeWindowsConptyPty(ptyId)) {
this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride()
}
}
protected getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal {
const existing = this.headlessTerminals.get(ptyId)
if (existing) {
return existing
}
const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state = this.createPtyHeadlessTerminalState(ptyId, size)
this.headlessTerminals.set(ptyId, state)
return state
}
protected replaceHeadlessTerminalAfterExecutionContextChange(ptyId: string): void {
this.disposeHeadlessTerminal(ptyId)
this.providerSnapshotPreferredPtys.add(ptyId)
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
this.headlessTerminals.set(ptyId, state)
state.writeChain = state.writeChain
.then(async () => {
const snapshot = await this.serializeProviderTerminalBuffer(ptyId)
if (!snapshot) {
return
}
const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
// Why: a newer live OSC 7 can arrive while the snapshot is in flight;
// only seed metadata while no post-correction CWD has won the race.
if (!this.terminalCwdByPtyId.has(ptyId)) {
this.recordOsc7MetadataForPty(ptyId, data)
}
await state.emulator.write(data)
if (snapshot.cwd !== undefined) {
state.emulator.setCwd(snapshot.cwd)
if (!this.terminalCwdByPtyId.has(ptyId) && snapshot.cwd?.trim()) {
this.terminalCwdByPtyId.set(ptyId, snapshot.cwd)
}
}
if (snapshot.oscLinks !== undefined) {
state.emulator.setRestoredOscLinks(snapshot.oscLinks)
}
state.ownership.seedOwner(snapshot.terminalOwner, {
alternateScreen: state.emulator.isAlternateScreen
})
state.outputSequence = snapshot.seq
})
.catch(() => {
// Best-effort: live bytes already chain behind this replacement state.
})
.finally(() => {
this.providerSnapshotPreferredPtys.delete(ptyId)
})
}
protected resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
return
}
// Why: terminal reflow is a parser operation. It must sit in the same
// per-PTY stream as output bytes or restore snapshots can bake in wraps
// from the wrong terminal width.
state.writeChain = state.writeChain
.then(() => {
state.emulator.resize(cols, rows)
})
.catch(() => {
// Best-effort mirror tracking; live PTY streaming must continue even
// if xterm rejects a raced resize during teardown.
})
}
// Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile
// mirror or a resubscribing mobile client resurrects the cleared scrollback.
async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
return
}
// Why: headless writes are queued to preserve xterm parser order. Clear
// must join that same chain or an earlier PTY chunk can finish after the
// clear request and repopulate mobile scrollback.
state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback())
await state.writeChain
}
}
@@ -0,0 +1,161 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './orca-runtime-resolve-mobile-session-terminal-command'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type {
RuntimeMobileSessionCreateTerminalResult,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
import { randomUUID } from 'node:crypto'
import { parsePaneKey } from '../../shared/stable-pane-id'
import {
buildHeadlessMobileSessionTabGroups,
buildMaterializedHeadlessParentLayout,
getHeadlessMobileSessionGroupId
} from './mobile-session-layout-projection'
export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends OrcaRuntimeWithResolveMobileSessionTerminalCommand {
protected async createRuntimeOwnedMobileSessionTerminal(
worktreeId: string,
activate: boolean,
afterTabId?: string,
opts: {
command?: string
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
identity?: { tabId: string; leafId: string; sessionId?: string }
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
launchConfig?: SleepingAgentLaunchConfig
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
// Why: SshPtyProvider treats sessionId as a relay reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn.
const stableSessionId =
opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`)
const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined
const terminal = await this.createTerminal(`id:${worktreeId}`, {
focus: false,
command: opts.command,
cwd,
env: opts.env,
envToDelete: opts.envToDelete,
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: opts.startupCommandDelivery,
...(opts.identity
? {
tabId: opts.identity.tabId,
leafId: opts.identity.leafId,
...(stableSessionId ? { sessionId: stableSessionId } : {})
}
: stableSessionId
? { sessionId: stableSessionId }
: {}),
...(isNewSession ? { isNewSession: true } : {}),
persistHostSessionBinding: true,
// Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash.
deferMobileSessionPublish: true,
signal: opts.signal
})
const livePty = this.getLivePtyForHandle(terminal.handle)
if (!livePty) {
throw new Error('terminal_handle_stale')
}
const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}`
const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID()
if (opts.viewMode) {
// Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default.
this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode })
}
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const existingSurface =
existing?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' &&
candidate.parentTabId === parentTabId &&
candidate.leafId === leafId
) ?? null
const parentLayout = buildMaterializedHeadlessParentLayout(
leafId,
livePty.pty.ptyId,
existingSurface?.parentLayout
)
const tab: RuntimeMobileSessionTerminalTab = {
type: 'terminal',
id: `${parentTabId}::${leafId}`,
parentTabId,
leafId,
ptyId: livePty.pty.ptyId,
title: terminal.title ?? livePty.pty.title ?? 'Terminal',
...(cwd ? { startupCwd: cwd } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
parentLayout,
isActive: activate
}
const tabs = (existing?.tabs ?? [])
.filter((candidate) => candidate.id !== tab.id)
.map((candidate) => ({
...candidate,
...(candidate.type === 'terminal' && candidate.parentTabId === parentTabId
? { parentLayout }
: {}),
isActive: activate ? false : candidate.isActive
}))
const insertAfter = afterTabId ? tabs.findIndex((candidate) => candidate.id === afterTabId) : -1
if (insertAfter >= 0) {
tabs.splice(insertAfter + 1, 0, tab)
} else {
tabs.push(tab)
}
const next: RuntimeMobileSessionTabsSnapshot = {
worktree: worktreeId,
publicationEpoch: `headless:${Date.now().toString(36)}`,
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
// Why: activating the new tab also focuses its group, so a "+" targeting a specific split group makes that group active too.
activeGroupId:
activate && opts.targetGroupId
? opts.targetGroupId
: (existing?.activeGroupId ?? getHeadlessMobileSessionGroupId(worktreeId)),
activeTabId: activate ? tab.id : (existing?.activeTabId ?? null),
activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null),
tabGroups: buildHeadlessMobileSessionTabGroups(
worktreeId,
tabs,
activate ? tab : null,
existing?.tabGroups,
opts.targetGroupId ? { tabId: parentTabId, groupId: opts.targetGroupId } : undefined
),
// Why: keep group split geometry on new-tab creation, else opening a terminal while split loses the arrangement.
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
const result = this.toMobileSessionTabsResult(next)
const changeSequence = ++this.mobileSessionTabsChangeSequence
for (const subscription of this.mobileSessionTabListeners) {
subscription.listener(
this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId),
changeSequence
)
}
const created = result.tabs.find((candidate) => candidate.id === tab.id)
if (!created || created.type !== 'terminal') {
throw new Error('terminal_handle_stale')
}
return {
tab: created,
publicationEpoch: result.publicationEpoch,
snapshotVersion: result.snapshotVersion
}
}
}
@@ -0,0 +1,26 @@
export type { TerminalCreateOptions } from './runtime-terminal-contracts'
export type { RuntimeTerminalCreate } from '../../shared/runtime-types'
export {
createTerminalRevealWarning,
ownerSurfacing,
resolveTerminalPresentation
} from './orca-runtime-core'
export { isValidHostTerminalTabId } from '../../shared/terminal-tab-id'
export { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
export { randomUUID } from 'node:crypto'
export {
copySleepingAgentLaunchConfig,
inferCapturedClaudeAgentTeamsMode,
mergeTerminalEnvDeletionKeys
} from './runtime-agent-launch-resolution'
export { buildClaudeAgentTeamsLaunchPlan } from './claude-agent-teams-shim-env'
export {
addClaudeTeammateModeAuto,
addClaudeTeammateModeInProcess
} from '../../shared/claude-agent-teams-tmux-compat'
export { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
export { getTerminalViewColorQueryReplyColors } from './terminal-view-attribute-store'
export type { RuntimePtyController } from './runtime-pty-controller-contract'
export { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
export { getRuntimeDesktopSurface } from './runtime-desktop-surface'
export type { IpcMainEvent } from 'electron'
@@ -0,0 +1,81 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import * as dependencies from './orca-runtime-create-terminal-dependencies'
import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal'
import type { RuntimeTerminalPresentation } from '../../shared/runtime-types'
export async function createDesktopTerminal(
runtime: OrcaRuntimeWithCreateTerminal,
worktreeSelector: string | undefined,
opts: dependencies.TerminalCreateOptions,
presentation: RuntimeTerminalPresentation | undefined,
rendererWindow: Electron.BrowserWindow | null
): Promise<dependencies.RuntimeTerminalCreate> {
runtime.assertGraphReady()
const win = rendererWindow ?? runtime.getAuthoritativeWindow()
const workspace = worktreeSelector
? await runtime.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
: null
const launchOpts = workspace
? await runtime.resolveAgentTerminalCreateOptions(workspace, opts)
: opts
const worktreeId = workspace?.id
const cwd = workspace
? runtime.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd)
: launchOpts.cwd
const requestId = dependencies.randomUUID()
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
dependencies.getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
reject(new Error('Terminal creation timed out'))
}, 10000)
const handler = (
event: dependencies.IpcMainEvent,
response: {
requestId: string
tabId?: string
title?: string
error?: string
}
): void => {
if (event.sender !== win.webContents || response.requestId !== requestId) {
return
}
clearTimeout(timer)
dependencies.getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
if (response.error) {
reject(new Error(response.error))
} else {
resolve({ tabId: response.tabId!, title: response.title ?? launchOpts.title ?? '' })
}
}
dependencies.getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
command: launchOpts.command,
cwd,
...(launchOpts.env ? { env: launchOpts.env } : {}),
...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}),
...(launchOpts.resumeProviderSession
? { resumeProviderSession: launchOpts.resumeProviderSession }
: {}),
...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
startupCommandDelivery: launchOpts.startupCommandDelivery,
title: launchOpts.title,
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...dependencies.ownerSurfacing(opts.surfaceOwner !== false)
})
})
const handle = await runtime.waitForTerminalHandle(reply.tabId)
return {
handle,
tabId: reply.tabId,
worktreeId: worktreeId ?? '',
title: reply.title,
...runtime.getPtyExecutionHostMetadata(runtime.handles.get(handle)?.ptyId ?? null),
surface: 'visible'
}
}
@@ -0,0 +1,192 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithApplyTrackedPtyTitle } from './orca-runtime-apply-tracked-pty-title'
import type {
RuntimePtyTitleTrackerEntry,
RuntimePtyWorktreeRecord
} from './runtime-terminal-state-records'
import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status'
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import type { ProcessedAgentStatusChunk } from '../../shared/agent-status-osc'
import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection'
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle {
protected createTerminalSideEffectCommandCodeDetector(
ptyId: string
): NonNullable<RuntimePtyTitleTrackerEntry['commandCodeDetector']> {
return createCommandCodeOutputStatusDetector({
startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null,
onWorking: (prompt) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt })
},
onDone: (prompt) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt })
}
})
}
protected extractLastOsc7CwdForPty(
ptyId: string,
data: string
): { path: string; hostname: string } | null {
const previousTail = this.osc7ScanTailByPtyId.get(ptyId)
if (!previousTail && !data.includes('\x1b]7;')) {
return null
}
const input = `${previousTail ?? ''}${data}`
const scanTail = extractOscScanTail(input, 4096)
if (scanTail.length > 0) {
this.osc7ScanTailByPtyId.set(ptyId, scanTail)
} else {
this.osc7ScanTailByPtyId.delete(ptyId)
}
const uri = extractLastOsc7Uri(input)
const pty = this.ptysById.get(ptyId)
const pathFlavor = this.pathFlavorForPty(pty)
return uri
? parseFileUriPathParts(uri, {
pathFlavor,
remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32',
wslDistro: pty?.connectionId
? undefined
: (this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? undefined)
})
: null
}
protected recordOsc7MetadataForPty(
ptyId: string,
data: string
): { cwd: string | null; cwdChanged: boolean } {
const osc7 = this.extractLastOsc7CwdForPty(ptyId, data)
const cwd = osc7?.path ?? null
const cwdChanged =
cwd !== null && cwd.trim().length > 0 && this.terminalCwdByPtyId.get(ptyId) !== cwd
if (cwdChanged) {
this.terminalCwdByPtyId.set(ptyId, cwd)
}
if (osc7) {
if (osc7.hostname) {
this.terminalFileUriHostnameByPtyId.set(ptyId, osc7.hostname)
} else {
this.terminalFileUriHostnameByPtyId.delete(ptyId)
}
}
return { cwd, cwdChanged }
}
protected pathFlavorForPty(pty?: RuntimePtyWorktreeRecord | null): 'posix' | 'win32' {
if (!pty?.connectionId) {
return process.platform === 'win32' ? 'win32' : 'posix'
}
const worktreePath = splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath
return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix'
}
/** Returns true when any retained agent-row snapshot changed in a
* client-visible way, so the caller can republish session snapshots. */
protected emitTerminalAgentStatusEvents(
ptyId: string,
chunk: ProcessedAgentStatusChunk
): boolean {
// Why: snapshot retention (for mobile worktree.ps) must run even when no
// renderer listener is attached, so we don't early-return on a missing
// onTerminalAgentStatus — only the per-target emit below is gated on it.
if (chunk.payloads.length === 0) {
return false
}
const targets = new Map<
string,
{
source: 'mounted-leaf' | 'pty-record'
paneKey: string
tabId?: string
worktreeId?: string
connectionId?: string | null
}
>()
const pty = this.ptysById.get(ptyId)
const connectionId = pty?.connectionId ?? null
for (const leaf of this.getLeavesForPty(ptyId)) {
const paneKey = this.makeRuntimePaneKey(leaf)
targets.set(paneKey, {
source: 'mounted-leaf',
paneKey,
tabId: leaf.tabId,
worktreeId: leaf.worktreeId,
connectionId
})
}
if (targets.size === 0 && pty?.paneKey) {
targets.set(pty.paneKey, {
source: 'pty-record',
paneKey: pty.paneKey,
tabId: pty.tabId ?? undefined,
worktreeId: pty.worktreeId,
connectionId
})
}
let retainedChanged = false
for (const payload of chunk.payloads) {
this.recordAgentPromptLifecycleState(
ptyId,
mapExplicitAgentStateToRuntimeTerminalStatus(payload.state)
)
for (const target of targets.values()) {
retainedChanged =
this.retainAgentRowSnapshot(
ptyId,
target.paneKey,
target.worktreeId,
target.tabId,
target.connectionId ?? null,
payload
) || retainedChanged
if (!this.onTerminalAgentStatus) {
continue
}
try {
this.onTerminalAgentStatus({
ptyId,
...target,
payload
})
} catch (err) {
console.error('[runtime] terminal agent status listener threw', {
ptyId,
paneKey: target.paneKey,
state: payload.state,
agentType: payload.agentType,
err
})
}
}
}
return retainedChanged
}
protected retainAgentRowSnapshot(
ptyId: string,
paneKey: string,
worktreeId: string | undefined,
tabId: string | undefined,
connectionId: string | null,
payload: ParsedAgentStatusPayload
): boolean {
return this.agentRows.retain({
ptyId,
paneKey,
worktreeId,
tabId,
connectionId,
payload
})
}
protected clearAgentRowSnapshotsForPty(ptyId: string): void {
this.agentRows.clearPty(ptyId)
}
}
@@ -0,0 +1,301 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithTerminalCreateDeduplication } from './orca-runtime-terminal-create-deduplication'
import * as dependencies from './orca-runtime-create-terminal-dependencies'
import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop'
import { buildRuntimeAgentTeamsLaunchPlan } from './orca-runtime-agent-teams-launch-plan'
import { createPtySpawnCommitReporter } from './orca-runtime-report-pty-spawn-commit'
export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication {
async createTerminal(
worktreeSelector?: string,
opts: dependencies.TerminalCreateOptions = {}
): Promise<dependencies.RuntimeTerminalCreate> {
if (opts.startupAgent && worktreeSelector === undefined) {
throw new Error(`startupAgent ${opts.startupAgent} requires a workspace selector.`)
}
const presentation = dependencies.resolveTerminalPresentation(opts)
const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true
const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow()
const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null
const shouldCreateInBackground =
worktreeSelector !== undefined &&
(Boolean(opts.agentSessionClaim) ||
(!requiresRendererFocus && opts.rendererBacked !== true) ||
availableAuthoritativeWindow === null)
if (shouldCreateInBackground) {
if (!this.ptyController?.spawn) {
throw new Error('runtime_unavailable')
}
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts)
const reportPtySpawnCommitted = createPtySpawnCommitReporter(launchOpts.onPtySpawnCommitted)
const cwd =
this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path
let preAllocatedHandle =
launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle()
const hintedTabId = launchOpts.tabId?.trim()
const canAdoptPaneIdentity =
hintedTabId !== undefined &&
dependencies.isValidHostTerminalTabId(hintedTabId) &&
launchOpts.leafId !== undefined &&
dependencies.isTerminalLeafId(launchOpts.leafId)
let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : dependencies.randomUUID()
let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : dependencies.randomUUID()
let paneKey = dependencies.makePaneKey(tabId, leafId)
const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({
worktreeId: workspace.id,
connectionId: workspace.connectionId,
tabId,
leafId
})
let stablePaneCreateReleased = false
const releaseStablePaneCreate = (): void => {
if (stablePaneCreateReleased) {
return
}
stablePaneCreateReleased = true
claimedStablePaneCreate?.()
}
try {
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({
cols: 120,
rows: 40,
cwd,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId,
leafId
})
const launchToken = launchOpts.launchConfig
? (launchOpts.launchToken ?? dependencies.randomUUID())
: undefined
const baseEnv = {
...launchOpts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
let agentTeamsPlan: Awaited<ReturnType<typeof dependencies.buildClaudeAgentTeamsLaunchPlan>>
let sequencedStartupCommand: string | undefined
let effectiveLaunchConfig = launchOpts.launchConfig
try {
const agentTeams = await buildRuntimeAgentTeamsLaunchPlan({
launchConfig: launchOpts.launchConfig,
command: launchOpts.command,
claudeAgentTeamsSourceCommand: launchOpts.claudeAgentTeamsSourceCommand,
claudeAgentTeamsMode,
baseEnv: { ...process.env, ...baseEnv },
adoptedBeforeLaunch,
createTeamEnv: (shimDir, shimBin) =>
this.claudeAgentTeams.createLaunchEnv({
leaderHandle: preAllocatedHandle,
baseEnv: { ...process.env, ...baseEnv },
shimDir,
shimBin
}).env
})
agentTeamsPlan = agentTeams.plan
sequencedStartupCommand = agentTeams.sequencedStartupCommand
effectiveLaunchConfig = agentTeams.effectiveLaunchConfig
} catch (error) {
releaseStablePaneCreate?.()
throw error
}
const env = this.buildTerminalWorkspaceEnv(
workspace,
{
...baseEnv,
...(sequencedStartupCommand
? { [dependencies.SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
: {})
},
paneKey,
tabId,
agentTeamsPlan?.env
)
const terminalColorQueryReplies =
launchOpts.terminalColorQueryReplies ??
dependencies.getTerminalViewColorQueryReplyColors()
if (launchOpts.signal?.aborted) {
throw new Error('client_disconnected')
}
let result: Awaited<ReturnType<NonNullable<dependencies.RuntimePtyController['spawn']>>>
try {
result = await this.ptyController.spawn({
cols: 120,
rows: 40,
cwd,
command: sequencedStartupCommand
? launchOpts.command
: (agentTeamsPlan?.command ?? launchOpts.command),
launchAgent: launchOpts.launchAgent,
commandDelivery: 'provider',
startupCommandDelivery: launchOpts.startupCommandDelivery,
env,
envToDelete: dependencies.mergeTerminalEnvDeletionKeys(
launchOpts.envToDelete,
agentTeamsPlan?.envToDelete
),
resumeProviderSession: launchOpts.resumeProviderSession,
telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId,
leafId,
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
...(launchOpts.agentSessionClaim
? {
agentSessionEnsure: {
claim: launchOpts.agentSessionClaim,
surface: {
worktreeId: workspace.id,
tabId,
leafId,
terminalHandle: preAllocatedHandle
}
}
}
: {}),
...(launchOpts.agentSessionCreateOperationId
? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId }
: {}),
...(launchOpts.signal ? { signal: launchOpts.signal } : {}),
...(launchOpts.onPtySpawnCommitted
? { onPtySpawnCommitted: reportPtySpawnCommitted }
: {}),
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}),
persistHostSessionBinding: true
})
} finally {
releaseStablePaneCreate?.()
}
if (!result.stablePaneOwner) {
reportPtySpawnCommitted()
}
const adoptedStablePane = Boolean(result.stablePaneOwner)
if (result.agentSessionEnsure) {
const canonicalSurface = result.agentSessionEnsure.owner.surface
preAllocatedHandle = canonicalSurface.terminalHandle
tabId = canonicalSurface.tabId
leafId = canonicalSurface.leafId
paneKey = dependencies.makePaneKey(tabId, leafId)
} else if (result.stablePaneOwner) {
preAllocatedHandle = result.stablePaneOwner.handle
tabId = result.stablePaneOwner.tabId
leafId = result.stablePaneOwner.leafId
paneKey = dependencies.makePaneKey(tabId, leafId)
}
try {
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
} catch (error) {
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
}
throw error
}
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
}
this.registerPty(result.id, workspace.id, workspace.connectionId, {
tabId,
leafId,
terminalHandle: preAllocatedHandle,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
if (launchOpts.structuredAgentSessionId) {
dependencies.agentSessionPtyWriteGate.bindPty(
result.id,
launchOpts.structuredAgentSessionId
)
}
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
pty.runtimeSessionOwned = true
if (!adoptedStablePane) {
if (launchOpts.title) {
const observedAt = this.nextTitleObservationSequence()
pty.title = launchOpts.title
pty.titleUpdatedAt = observedAt
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
} else {
pty.title = null
pty.titleUpdatedAt = null
}
pty.launchConfig = effectiveLaunchConfig
? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchIncarnationId = launchToken ? pty.incarnationId : null
pty.launchAgent = launchOpts.launchAgent ?? null
}
pty.tabId = tabId
pty.paneKey = paneKey
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
tabId,
leafId,
title: launchOpts.title ?? null,
activate: presentation === 'focused',
selectIfNoActiveTab: presentation !== 'background',
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
})
}
let surface: dependencies.RuntimeTerminalCreate['surface'] = 'background'
let warning: string | undefined
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
try {
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: launchOpts.title ?? null,
...(cwd !== workspace.path ? { cwd } : {}),
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
...(launchToken ? { launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...dependencies.ownerSurfacing(opts.surfaceOwner !== false),
tabId,
leafId
})
surface = 'visible'
} catch (err) {
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
warning = dependencies.createTerminalRevealWarning(handle, err)
}
} else if (presentation !== 'background') {
warning = dependencies.createTerminalRevealWarning(handle)
}
return {
handle,
tabId,
paneKey,
ptyId: result.id,
worktreeId: workspace.id,
title: pty?.title ?? launchOpts.title ?? null,
...this.getPtyExecutionHostMetadata(result.id),
surface,
...(result.pid ? { processId: result.pid } : {}),
...(result.agentSessionEnsure
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
: {}),
...(adoptedStablePane ? { isReattach: true as const } : {}),
...(warning ? { warning } : {})
}
} finally {
releaseStablePaneCreate()
}
}
return createDesktopTerminal(this, worktreeSelector, opts, presentation, rendererWindow)
}
}
@@ -0,0 +1,189 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithResolveExitWaiters } from './orca-runtime-resolve-exit-waiters'
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
import { formatMessagePointer } from './orchestration/formatter'
import { isCursorAgentOrchestrationTarget } from './orca-runtime-core'
export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolveExitWaiters {
// Why: normal delivery stays event-driven; the bounded mailbox retry only repairs missed liveness edges.
protected deliverPendingMessages(
leaf: RuntimeLeafRecord,
options: {
mailboxHandle?: string
reservedTypes?: ReadonlySet<string>
skipAbsenceProbe?: boolean
} = {}
): void {
if (!this._orchestrationDb) {
return
}
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (!handle) {
return
}
const mailboxHandle = options.mailboxHandle ?? handle
if (leaf.ptyId && this.messageDeliveryFlightsByPtyId.has(leaf.ptyId)) {
let parked = this.parkedMessageRedeliveriesByPtyId.get(leaf.ptyId)
if (!parked) {
parked = new Map()
this.parkedMessageRedeliveriesByPtyId.set(leaf.ptyId, parked)
}
const priorReservedTypes = parked.get(mailboxHandle)?.reservedTypes
const reservedTypes =
priorReservedTypes || options.reservedTypes
? new Set([...(priorReservedTypes ?? []), ...(options.reservedTypes ?? [])])
: undefined
parked.set(mailboxHandle, { leaf, reservedTypes })
return
}
// Why filter here and not at the trigger: the push reads every pending row,
// not just the one that woke it, so a row a pull has claimed would be typed
// into the pane AND returned by that pull's check. Live waiters cover the
// still-blocked case; reservedTypes carries the notify-time snapshot for a
// waiter resolved later in the same drain, which is already gone from the map.
const unread = this._orchestrationDb
.getUndeliveredUnreadMessages(mailboxHandle)
.filter(
(message) =>
!options.reservedTypes?.has(message.type) &&
!this.messageWaiters.typeHasLiveWaiter(mailboxHandle, message.type)
)
if (unread.length === 0) {
return
}
const watermark = this.lastPointedMessageSequenceByHandle.get(mailboxHandle) ?? -1
const priorPointedIds = this.pointedMessageIdsByHandle.get(mailboxHandle)
if (
!unread.some(
(message) => message.sequence > watermark || priorPointedIds?.has(message.id) !== true
)
) {
return
}
if (!leaf.writable || !leaf.ptyId) {
return
}
const newestSequence = unread.at(-1)?.sequence
if (newestSequence === undefined) {
return
}
if (
!options.skipAbsenceProbe &&
this.ptyController?.probePtyLiveness &&
!this.controllerKnowsPtyIsLive(leaf.ptyId)
) {
// Why: a fire-and-forget write to a prior process's ptyId reports success
// and would mark these delivered while losing them. Proven absence keeps
// them queued for a future surface; unknown liveness still delivers.
const probedPtyId = leaf.ptyId
// Why: triggers arriving mid-probe must not each arm a continuation — the
// Every continuation would re-read the same unread rows. The single armed
// continuation re-reads fresh rows when it fires, so nothing is lost.
if (this.probeDeferredDeliveryPtyIds.has(probedPtyId)) {
return
}
this.probeDeferredDeliveryPtyIds.add(probedPtyId)
void this.isLeafPtyProvenAbsent(probedPtyId)
.then((absent) => {
this.probeDeferredDeliveryPtyIds.delete(probedPtyId)
if (!absent && leaf.ptyId === probedPtyId) {
// Why a macrotask and not the stale reservation snapshot: a `remote:`
// pty answers the probe null before its first await, so this chain can
// settle in microtasks and overtake the resumption of a check resolved
// meanwhile — that check's waiter is already out of the map and its
// rows are not yet read, so the push would inject what it returns.
// Yielding the turn lets every queued check mark its rows read first;
// re-reading then (rather than replaying a reservation this probe may
// have outlived) is what keeps an orphaned row from stranding.
setTimeout(() => {
// Why current state, not the closure: the gate that authorized this
// push ran before the probe. A same-id cold restore inside the probe
// window keeps ptyId identical and makes the leaf writable again, so
// an id-only check would type the pointer plus Enter into a process
// whose idle was never observed. Re-read the live-idle gate.
const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (
currentLeaf?.ptyId === probedPtyId &&
currentLeaf.lastAgentStatus === 'idle' &&
currentLeaf.lastAgentStatusObservedLive
) {
this.deliverPendingMessages(currentLeaf, {
mailboxHandle,
skipAbsenceProbe: true
})
}
}, 0)
}
})
.catch(() => {
this.probeDeferredDeliveryPtyIds.delete(probedPtyId)
})
return
}
const deliveryPtyId = leaf.ptyId
const flight: { enterTimer: ReturnType<typeof setTimeout> | null } = { enterTimer: null }
this.messageDeliveryFlightsByPtyId.set(deliveryPtyId, flight)
// Why: every sync outcome — failed write, Cursor branch, or a throw —
// must end the flight here, or a leaked flag parks this pty's deliveries
// forever. Only an armed Enter hands settling to its own callback.
let settlesInEnterCallback = false
try {
const payload = formatMessagePointer(unread.length, mailboxHandle)
const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false
if (!wrote) {
return
}
this.lastPointedMessageSequenceByHandle.set(
mailboxHandle,
Math.max(watermark, newestSequence)
)
const pointedIdsAfterWrite =
this.pointedMessageIdsByHandle.get(mailboxHandle) ?? new Set<string>()
for (const message of unread) {
pointedIdsAfterWrite.add(message.id)
}
this.pointedMessageIdsByHandle.set(mailboxHandle, pointedIdsAfterWrite)
const tabTitle = this.tabs.get(leaf.tabId)?.title
if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) {
// Why: Cursor Agent treats injected PTY text as editable prompt input, so submitting must stay under user control.
return
}
// Why: agent TUIs can swallow a \r in the same PTY write; submit separately after a delay.
flight.enterTimer = setTimeout(() => {
try {
// Why current state, not the closure: graph resync replaces leaf
// objects, so the captured record can read writable=true after the
// pty died, and an exit retire may have superseded this flight.
if (this.messageDeliveryFlightsByPtyId.get(deliveryPtyId) !== flight) {
return
}
const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) {
return
}
this.ptyController?.write(deliveryPtyId, '\r')
} catch {
// Terminal may have closed during the delay; mail remains queued for check.
} finally {
// Why finally: every outcome — submit, refusal, throw — ends the flight,
// and settle re-runs any trigger parked during it so nothing strands.
this.settlePendingMessageDelivery(deliveryPtyId, flight)
}
}, 500)
settlesInEnterCallback = true
} finally {
if (!settlesInEnterCallback) {
this.settlePendingMessageDelivery(deliveryPtyId, flight)
}
}
}
}
@@ -0,0 +1,192 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithScheduleWaitBlockedCheck } from './orca-runtime-schedule-wait-blocked-check'
import type { PtyTransientFact } from '../providers/types'
import type {
TerminalSideEffectBatch,
TerminalSideEffectFact
} from '../../shared/terminal-side-effect-facts'
import { isCursorNativeAgentTitle, normalizeTerminalTitle } from '../../shared/agent-detection'
import type { TerminalTitleFactMeta } from '../../shared/terminal-output-side-effects'
export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithScheduleWaitBlockedCheck {
/** A transient fact the daemon detected while it held scan authority —
* emitted through the same fact channel as byte-scanned facts. Arrives
* between chunks, so recordTerminalSideEffectFact emits it immediately. */
emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void {
switch (fact.kind) {
case 'bell':
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
return
case 'command-finished':
this.retirePtyAgentLaunchAuthority(ptyId)
this.recordTerminalSideEffectFact(ptyId, {
kind: 'command-finished',
exitCode: fact.exitCode
})
return
case 'pr-link':
this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link })
return
case '2031-subscribe':
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
return
case '2031-unsubscribe':
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' })
}
}
/** The daemon keep-tail dropped this PTY's oldest undelivered output; the
* next delivered chunk is discontinuous. Reset every cross-chunk parse
* carry so a half-open escape from before the gap cannot corrupt what
* follows, and drop the mobile headless mirror — it rebuilds from the
* delivered tail / snapshot seeds instead of parsing a gapped stream. */
notePtyDataGap(ptyId: string, droppedChars = 0): void {
if (droppedChars > 0) {
// Why: the daemon snapshot's seq counts bytes its monitoring stream
// dropped. Advancing without parsing preserves that absolute domain so
// post-snapshot live chunks can be reconciled instead of duplicated.
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + droppedChars
this.ptyOutputSequenceById.set(ptyId, outputSequence)
}
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
if (pty) {
pty.tailPendingAnsi = ''
}
for (const leaf of this.getLeavesForPty(ptyId)) {
leaf.tailPendingAnsi = ''
}
this.oscTitleScanTailByPtyId.delete(ptyId)
this.osc7ScanTailByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.disposeHeadlessTerminal(ptyId)
}
/** Record one derived side-effect fact: batched per chunk while applying
* bytes, emitted immediately for between-chunk facts (stale-title timer). */
protected recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void {
if (!this.terminalSideEffectConsumerAvailable) {
return
}
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
if (entry?.applyingChunk) {
entry.pendingFacts.push(fact)
return
}
this.emitTerminalSideEffectBatch(ptyId, [fact])
}
protected emitTerminalSideEffectBatch(
ptyId: string,
facts: TerminalSideEffectFact[],
options: { replay?: boolean } = {}
): void {
if (!this.terminalSideEffectConsumerAvailable || facts.length === 0) {
return
}
const batch: TerminalSideEffectBatch = {
ptyId,
seq: this.ptyOutputSequenceById.get(ptyId) ?? 0,
facts,
...(options.replay ? { replay: true } : {}),
...this.resolveTerminalSideEffectAttribution(ptyId)
}
if (this.terminalSideEffectLocalConsumerAvailable) {
try {
this.onTerminalSideEffects?.(batch)
} catch (err) {
console.error('[runtime] terminal side-effect listener threw', { ptyId, err })
}
}
if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) {
this.emitClientEvent({ type: 'terminalSideEffects', batch })
}
}
/** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the
* first mounted leaf, fall back to the spawn-time PTY record binding. */
protected resolveTerminalSideEffectAttribution(ptyId: string): {
worktreeId?: string
tabId?: string
paneKey?: string
connectionId?: string | null
} {
const pty = this.ptysById.get(ptyId)
const connectionId = pty?.connectionId ?? null
for (const leaf of this.getLeavesForPty(ptyId)) {
return {
worktreeId: leaf.worktreeId,
tabId: leaf.tabId,
paneKey: this.makeRuntimePaneKey(leaf),
connectionId
}
}
if (pty?.paneKey) {
return {
worktreeId: pty.worktreeId,
...(pty.tabId ? { tabId: pty.tabId } : {}),
paneKey: pty.paneKey,
connectionId
}
}
return {}
}
/** Title-only replay batch for renderer (re)attach — the no-attention-replay
* rule: snapshots restore title state, never historical bells/completions. */
getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null {
const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker
const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle
const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null
// Why: a record-fallback snapshot must not replay the bare cursor-agent literal over a
// tracker title Orca synthesized from hooks — but with no tracker title it is the pane's
// only Cursor identity, so restored/mobile tabs keep it (#10258).
const rawTitle =
recordTitle && (normalizedTitle === null || !isCursorNativeAgentTitle(recordTitle))
? recordTitle
: null
if (normalizedTitle === null && !rawTitle) {
return null
}
return {
ptyId,
seq: this.ptyOutputSequenceById.get(ptyId) ?? 0,
replay: true,
facts: [
{
kind: 'title',
normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!),
rawTitle: rawTitle ?? normalizedTitle!
}
],
...this.resolveTerminalSideEffectAttribution(ptyId)
}
}
/** Raw last title from main's tracked PTY/leaf records — the title surface
* the tracker (live bytes + synthetic frames) keeps current. */
protected getTrackedRawTitleForPty(ptyId: string): string | null {
const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle
if (recordTitle) {
return recordTitle
}
for (const leaf of this.getLeavesForPty(ptyId)) {
if (leaf.lastOscTitle) {
return leaf.lastOscTitle
}
}
return null
}
protected isLiveCursorNativeTitle(rawTitle: string, meta?: TerminalTitleFactMeta): boolean {
return isCursorNativeAgentTitle(rawTitle) && meta?.staleWorkingTitleClear !== true
}
/** Display fallback for identities intentionally omitted from liveness records. */
protected getTrackedDisplayTitleForPty(ptyId: string): string | null {
return (
this.getTrackedRawTitleForPty(ptyId) ??
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ??
null
)
}
}

Some files were not shown because too many files have changed in this diff Show More