Merge nwparker/term-speed-2-architecture-docs into orca-performance base

Revives the terminal model/view architecture chain (hidden view parking,
hidden delivery gate, side-effect authority in main, model query authority
— all kill-switched, defaults on) on top of orca-performance's terminal
performance fixes. 34 conflicted files resolved with these decisions:

- Both sides' features kept and kill-switchable. orca-performance's fixes
  preserved exactly: parse-clocked scheduler drains (9e8bb2243), windowed
  retained-tail redraw (4e08a28cd), throttled wait-blocked check
  (66f20258e), #7139/#7150 cooperative drain, backlog caps, wedge guards,
  and probe-certified replay release.
- orca-runtime.ts onPtyData: chain's per-PTY title tracker + side-effect
  facts replace the inline OSC title blocks; our scheduleWaitBlockedCheck
  and redraw-cursor plumbing retained; refreshPtyForegroundAgent re-grafted
  into applyTrackedPtyTitle on status transitions.
- pty.ts: chain's hidden-gate drop sites, restore markers, initiallyHidden
  spawn marking, ConPTY DA1 record, and view-attribute/delivery-interest IPC
  wired through our newer spawn/delivery structure. The chain's flat 2 MB
  pending cap was NOT taken: our scrollback-scaled cap (#7150, empty-drop +
  droppedOutput sentinel) wins and now also emits the chain's one-per-episode
  pty:modelRestoreNeeded 'pending-cap' marker when the gate is enabled. Our
  8 MB renderer in-flight high water kept over the chain's 2 MB.
- pty-connection.ts: chain's gate sync, model-restore channel, side-effect
  fact consumer, and post-restore seq reconciliation composed with our
  hidden-startup query grammar, synchronized-frame latency machinery, and
  probe-certified restore. Phase 6 (skip-grammar deletion) intentionally NOT
  taken: our side evolved that grammar past the chain's base and #7150
  machinery builds on it; with the gate on it is dormant (main drops hidden
  bytes), with switches off it remains the byte-identical fallback.
- Shared module moves adopted (agent-title-*, github-links, bell detector,
  command-code, PR-link detector) with our newer bounded regex-free OSC
  parsers wired through the chain's scanner classes; our pi-synthetic/Devin/
  MiMo agent additions ported into the split modules.
- daemon-pty-adapter: chain's sleep-restore cache + our checkpoint cooldown,
  teardown flag, and oscLinks-carrying cold-restore payload combined.
- rpc/methods/terminal.ts: chain's ACK-gated multiplex output + recovery
  snapshots composed with our budgeted mobile snapshots and resize re-stream.
- Tests adapted only where they encoded superseded cadence: watermark counts
  (2 MB->8 MB), pending-cap trim -> empty-drop sentinel, initial snapshot
  frame seq (layout seq -> snapshot output seq), mock module paths.

Contract tests: 881 passed. Renderer terminal suites: 1895 passed.
Full main runtime+ipc: 2974 passed. Typecheck (3 configs) clean.
Pre-existing baseline failures (pty-subprocess WSL, PR sidebar, Linear
prompt, SidebarToolbar) are unrelated and fail identically on 7839fb9db.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 01:44:20 -04:00
co-authored by Orca
144 changed files with 20305 additions and 1455 deletions
@@ -18,6 +18,7 @@ if (reportPaths.length === 0) {
const BUDGETS = {
maxMedianKeyLatencyMs: 75,
maxWorstKeyLatencyMs: 300,
maxRevisitLatencyMs: 300,
maxTimerDriftMs: 150,
maxScrollLatencyMs: 150,
maxRestoreLatencyMs: 1000,
@@ -80,6 +81,12 @@ function validateRow(row) {
BUDGETS.maxWorstKeyLatencyMs,
'ms'
)
addBudgetCheck(
'revisit latency',
parseMs(row.revisit, 'revisit', row, failures),
BUDGETS.maxRevisitLatencyMs,
'ms'
)
addBudgetCheck(
'timer drift',
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
@@ -113,6 +120,14 @@ function validateRow(row) {
parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures),
BUDGETS.maxRendererDroppedBacklogs
)
// Why: parked-memory rows carry heap/view-count metrics with no latency
// budget; recognize them so memory-only scenarios pass the gate instead of
// tripping the "no recognized budget metrics" guard.
for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) {
if (parseCount(row[fieldName], fieldName, row, failures) != null) {
checkedMetricCount += 1
}
}
if (checkedMetricCount === 0) {
failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`)
}
@@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => {
'frames=180',
'median=2.9ms',
'worst=5.9ms',
'revisit=42.0ms',
'maxTimerDrift=12.1ms',
'scroll=149.9ms',
'restore=642.0ms',
@@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => {
'frames=60',
'median=76.0ms',
'worst=301.0ms',
'revisit=301.0ms',
'maxTimerDrift=151.0ms',
'scroll=151.0ms',
'restore=1001.0ms',
@@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => {
expect(result.status).toBe(1)
expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms')
expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms')
expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms')
expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms')
expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms')
expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms')
@@ -116,6 +119,31 @@ describe('check-terminal-perf-report-budgets', () => {
expect(result.stderr).toContain('no recognized budget metrics found')
})
it('accepts revisit-only marker rows as budgeted perf evidence', () => {
const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184')
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('accepts parked-memory rows that carry only heap and view-count metrics', () => {
const reportPath = writeReport(
'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1',
'opencode-parked-memory'
)
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('fails OpenCode annotation rows that contain no budget metrics', () => {
const reportPath = writeReport('panes=1 frames=60')
@@ -0,0 +1,484 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import {
budgetFailures,
collectTerminalPerfRows,
compareScenarios,
escapeHtml,
formatLargeValue,
formatMs,
readJsonReport,
scenarioTitle
} from './terminal-perf-report-rows.mjs'
import { basename, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html'
// Why: every tracked metric is lower-is-better, so delta coloring and the
// regression table share one direction rule.
const MS_METRICS = [
{ key: 'medianMs', label: 'Typing median', chart: true },
{ key: 'worstMs', label: 'Typing worst', chart: true },
{ key: 'scrollMs', label: 'Active scroll', chart: true },
{ key: 'restoreMs', label: 'Restore', chart: true },
{ key: 'revisitMs', label: 'Revisit marker', chart: true },
{ key: 'maxTimerDriftMs', label: 'Timer drift', chart: false }
]
const COUNT_METRICS = [
{ key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' },
{ key: 'mainPeakInFlightChars', label: 'Main in-flight chars' },
{ key: 'mainPeakPendingChars', label: 'Main pending chars' },
{ key: 'hiddenSkippedChars', label: 'Hidden skipped chars' },
{ key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' },
// Why: parked-memory scenarios are table-only — heap/view counts have no
// ms trend story, so they stay out of the charts.
{ key: 'heapUsedMB', label: 'Renderer JS heap (MB)' },
{ key: 'liveTerminals', label: 'Live xterm instances' },
{ key: 'livePaneManagers', label: 'Live pane managers' }
]
const SERIES_COLORS = {
medianMs: '#2563eb',
worstMs: '#dc2626',
scrollMs: '#d97706',
restoreMs: '#7c3aed',
revisitMs: '#0d9488'
}
const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/
export function parseHtmlReportArgs(argv, env = process.env) {
const args = [...argv]
if (args[0] === '--') {
args.shift()
}
const inputs = []
let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--output' || arg === '-o') {
const next = args[index + 1]
if (!next || next.startsWith('-')) {
throw new Error(`${arg} requires a path`)
}
outputPath = next
index += 1
continue
}
if (arg.startsWith('--output=')) {
outputPath = arg.slice('--output='.length)
continue
}
const labeled = arg.match(LABELED_INPUT_RE)
if (labeled) {
inputs.push({ label: labeled[1], path: labeled[2] })
} else {
inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg })
}
}
if (inputs.length === 0) {
throw new Error(
'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]<playwright-json>... --output <report.html>'
)
}
return { inputs, outputPath }
}
// ── Trend data ────────────────────────────────────────────────────────────
function buildMatrix(revisions) {
const scenarios = new Map()
for (const revision of revisions) {
for (const row of revision.rows) {
if (!scenarios.has(row.scenario)) {
scenarios.set(row.scenario, new Map())
}
scenarios.get(row.scenario).set(revision.label, row)
}
}
const orderedScenarios = [...scenarios.keys()].sort(compareScenarios)
return { scenarios, orderedScenarios }
}
function niceCeil(value) {
if (value <= 0) {
return 1
}
const magnitude = 10 ** Math.floor(Math.log10(value))
for (const step of [1, 2, 2.5, 5, 10]) {
if (value <= step * magnitude) {
return step * magnitude
}
}
return 10 * magnitude
}
// ── Rendering ─────────────────────────────────────────────────────────────
function renderTrendChart({ scenario, byRevision, revisions, title }) {
const metrics = MS_METRICS.filter(
(metric) =>
metric.chart &&
revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null)
)
if (metrics.length === 0) {
return ''
}
const width = 560
const height = 230
const pad = { left: 52, right: 14, top: 30, bottom: 38 }
const plotW = width - pad.left - pad.right
const plotH = height - pad.top - pad.bottom
const maxValue = Math.max(
1,
...metrics.flatMap((metric) =>
revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0)
)
)
const yMax = niceCeil(maxValue * 1.15)
const xFor = (index) =>
pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1))
const yFor = (value) => pad.top + plotH - (plotH * value) / yMax
const parts = []
parts.push(
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
)
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
// Horizontal gridlines + y labels
const ticks = 4
for (let tick = 0; tick <= ticks; tick += 1) {
const value = (yMax * tick) / ticks
const y = yFor(value)
parts.push(
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
)
parts.push(
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
)
}
// X labels
revisions.forEach((revision, index) => {
parts.push(
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
)
})
// Series
for (const metric of metrics) {
const color = SERIES_COLORS[metric.key] ?? '#475569'
const points = revisions
.map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] }))
.filter((point) => point.value != null)
if (points.length === 0) {
continue
}
const path = points
.map(
(point, order) =>
`${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}`
)
.join(' ')
parts.push(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
for (const point of points) {
const x = xFor(point.index)
const y = yFor(point.value)
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
parts.push(
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
)
}
}
parts.push('</svg>')
const legend = metrics
.map((metric) => {
const color = SERIES_COLORS[metric.key] ?? '#475569'
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
})
.join('')
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
}
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
if (baseline == null || latest == null) {
return '<td class="delta">—</td>'
}
const diff = latest - baseline
const pct = baseline === 0 ? null : (diff / baseline) * 100
let cls = 'neutral'
if (zeroBudget) {
cls = latest > 0 ? 'worse' : 'better'
} else if (pct != null && Math.abs(pct) >= 5) {
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
} else if (baseline === 0 && diff !== 0) {
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
}
const pctLabel =
pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%`
const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}`
return `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
}
function renderScenarioTable({ scenario, byRevision, revisions, title }) {
const metricRows = []
const allMetrics = [...MS_METRICS, ...COUNT_METRICS]
for (const metric of allMetrics) {
const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key])
if (values.every((value) => value == null)) {
continue
}
const isMs = MS_METRICS.includes(metric)
const format = isMs ? formatMs : formatLargeValue
const cells = values
.map((value) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
.join('')
const baseline = values.find((value) => value != null)
const latest = [...values].reverse().find((value) => value != null)
metricRows.push(
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
zeroBudget: metric.key === 'rendererDroppedBacklogs'
})}</tr>`
)
}
if (metricRows.length === 0) {
return ''
}
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
return `<section class="scenario-block">
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
<table class="trend-table">
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
<tbody>${metricRows.join('')}</tbody>
</table>
</section>`
}
function renderHeadline(revisions, matrix) {
if (revisions.length < 2) {
return ''
}
const first = revisions[0]
const last = revisions.at(-1)
const cards = []
for (const scenario of matrix.orderedScenarios) {
const byRevision = matrix.scenarios.get(scenario)
const baseRow = byRevision.get(first.label)
const lastRow = byRevision.get(last.label)
if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) {
continue
}
const diff = lastRow.medianMs - baseRow.medianMs
const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100
const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse'
cards.push(`<div class="card ${cls}">
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}</div>
<div class="card-sub">typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
</div>`)
}
if (cards.length === 0) {
return ''
}
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
}
function renderBudgets(latestRevision) {
const failures = []
for (const row of latestRevision.rows) {
for (const failure of budgetFailures(row)) {
failures.push(`${row.scenario}: ${failure}`)
}
}
const status =
failures.length === 0 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
const failureList =
failures.length === 0
? ''
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
}
function renderInputsMeta(revisions) {
const items = revisions
.map((revision) => {
const stats = revision.stats
const statsLabel = stats
? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky`
: ''
const failNote =
stats && stats.unexpected > 0
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
: ''
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
})
.join('')
return `<ol class="inputs">${items}</ol>`
}
function renderRawDetails(revisions) {
return revisions
.map((revision) => {
const rows = revision.rows
.map(
(row) =>
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
)
.join('')
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
<table class="trend-table">
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
<tbody>${rows}</tbody></table></details>`
})
.join('')
}
const PAGE_CSS = `
:root { color-scheme: light; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; }
h1 { font-size: 24px; margin-bottom: 4px; }
h2 { font-size: 18px; margin: 28px 0 10px; }
h3 { font-size: 15px; margin: 18px 0 6px; }
.meta { color: #64748b; font-size: 13px; }
.inputs { font-size: 13px; color: #334155; padding-left: 20px; }
.meta-warn { color: #b45309; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; }
.card.better { border-left-color: #16a34a; }
.card.worse { border-left-color: #dc2626; }
.card.neutral { border-left-color: #94a3b8; }
.card-title { font-size: 12px; color: #64748b; }
.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; }
.card-sub { font-size: 11px; color: #94a3b8; }
.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; }
.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
.trend-chart { width: 100%; height: auto; }
.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; }
.gridline { stroke: #e2e8f0; stroke-width: 1; }
.axis-label { font-size: 10px; fill: #64748b; }
.point-label { font-size: 10px; font-weight: 600; }
.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.legend-unit { color: #94a3b8; margin-left: auto; }
.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; }
.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; }
table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; }
table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; }
table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; }
table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; }
td.delta.better { color: #15803d; font-weight: 600; }
td.delta.worse { color: #b91c1c; font-weight: 600; }
td.delta.neutral { color: #64748b; }
.delta-abs { font-weight: 400; color: #94a3b8; }
.pass { color: #15803d; font-weight: 700; }
.fail { color: #b91c1c; font-weight: 700; }
details { margin: 8px 0; }
summary { cursor: pointer; font-size: 13px; color: #334155; }
`
function renderHtml({ generatedAt, revisions }) {
const matrix = buildMatrix(revisions)
const charts =
revisions.length >= 2
? matrix.orderedScenarios
.map((scenario) => {
const byRevision = matrix.scenarios.get(scenario)
const anyRow = [...byRevision.values()][0]
return renderTrendChart({
scenario,
byRevision,
revisions,
title: scenarioTitle(scenario, anyRow)
})
})
.filter(Boolean)
.join('')
: ''
const tables = matrix.orderedScenarios
.map((scenario) => {
const byRevision = matrix.scenarios.get(scenario)
const anyRow = [...byRevision.values()][0]
return renderScenarioTable({
scenario,
byRevision,
revisions,
title: scenarioTitle(scenario, anyRow)
})
})
.filter(Boolean)
.join('')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Terminal Performance Over Time</title>
<style>${PAGE_CSS}</style>
</head>
<body>
<h1>Terminal Performance Over Time</h1>
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
${renderInputsMeta(revisions)}
${renderHeadline(revisions, matrix)}
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
<section><h2>Metric detail by scenario</h2>${tables}</section>
${renderBudgets(revisions.at(-1))}
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
</body>
</html>
`
}
export function generateTerminalPerfHtmlReport({
inputs,
inputPaths,
outputPath,
now = new Date()
}) {
// Why: older callers (the scale report gate) pass bare inputPaths.
const resolvedInputs =
inputs ??
(inputPaths ?? []).map((path) => ({
label: basename(path).replace(/\.json$/i, ''),
path
}))
const revisions = resolvedInputs.map(({ label, path }) => {
const report = readJsonReport(path)
return {
label,
path,
stats: report.stats ?? null,
rows: collectTerminalPerfRows(report, label)
}
})
const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0)
if (totalRows === 0) {
throw new Error('No opencode terminal perf annotations found in the provided reports')
}
const html = renderHtml({ generatedAt: now.toISOString(), revisions })
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, html)
const latestFailures = revisions
.at(-1)
.rows.reduce((sum, row) => sum + budgetFailures(row).length, 0)
return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures }
}
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
if (isMain) {
try {
const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2))
const result = generateTerminalPerfHtmlReport({ inputs, outputPath })
console.log(
`Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).`
)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
@@ -0,0 +1,272 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
generateTerminalPerfHtmlReport,
parseHtmlReportArgs
} from './generate-terminal-perf-html-report.mjs'
const tempDirs = []
function makeTempDir() {
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-'))
tempDirs.push(dir)
return dir
}
function writeReport(
annotationDescription,
annotationType = 'opencode-scale-same-workspace-25',
reportName = 'report.json'
) {
const dir = makeTempDir()
const reportPath = join(dir, reportName)
writeFileSync(
reportPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: annotationType,
description: annotationDescription
},
{
type: 'browser-unrelated',
description: 'median=999.0ms'
}
]
}
]
}
]
}
]
})
)
return reportPath
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('generate-terminal-perf-html-report', () => {
it('parses labeled and bare input paths plus output flags', () => {
expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({
inputs: [
{ label: 'a', path: 'a.json' },
{ label: 'b', path: 'b.json' }
],
outputPath: 'out.html'
})
expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual(
{
inputs: [
{ label: 'main', path: 'runs/0-main.json' },
{ label: '#5038 final', path: 'runs/4-final.json' }
],
outputPath: 'test-results/terminal-perf-impact-report.html'
}
)
expect(
parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' })
).toEqual({
inputs: [{ label: 'a', path: 'a.json' }],
outputPath: 'env.html'
})
expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path')
expect(() => parseHtmlReportArgs([])).toThrow('Usage:')
})
it('writes a single-run report with scenario tables and budget status', () => {
const reportPath = writeReport(
[
'panes=25',
'frames=60',
'median=12.4ms',
'worst=44.8ms',
'revisit=28.6ms',
'scroll=61.0ms',
'restore=320.0ms',
'maxTimerDrift=8.0ms',
'rendererPeakQueuedChars=2048',
'mainPeakInFlightChars=4096',
'heldAckChars=1024',
'hiddenSkippedChars=512',
'rendererDroppedBacklogs=0'
].join(' ')
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({
inputPaths: [reportPath],
outputPath,
now: new Date('2026-06-09T10:00:00.000Z')
})
const html = readFileSync(outputPath, 'utf8')
expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 })
expect(html).toContain('<!doctype html>')
expect(html).toContain('Terminal Performance Over Time')
expect(html).toContain('2026-06-09T10:00:00.000Z')
expect(html).toContain('Same workspace panes — 25 panes')
expect(html).toContain('opencode-scale-same-workspace-25')
expect(html).toContain('28.6ms')
expect(html).toContain('Pass')
// Why: one run has no over-time story; the trend section must not render.
expect(html).not.toContain('Trends across revisions')
expect(html).not.toContain('browser-unrelated')
})
it('renders parked-memory heap and live view counts as table metrics', () => {
const reportPath = writeReport(
'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1',
'opencode-parked-memory'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
const html = readFileSync(outputPath, 'utf8')
// Why: heapUsedMB has no budget — a memory row alone must not fail gates.
expect(result.budgetFailureCount).toBe(0)
expect(html).toContain('Parked hidden terminal memory — 8 panes')
expect(html).toContain('Renderer JS heap (MB)')
expect(html).toContain('142.5')
expect(html).toContain('Live xterm instances')
expect(html).toContain('Live pane managers')
})
it('marks over-budget rows as failures for the latest run', () => {
const reportPath = writeReport(
[
'panes=100',
'median=80.0ms',
'worst=301.0ms',
'revisit=301.0ms',
'rendererPeakQueuedChars=2097153',
'rendererDroppedBacklogs=1'
].join(' '),
'opencode-scale-cross-workspace-100'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
const html = readFileSync(outputPath, 'utf8')
expect(result.budgetFailureCount).toBe(5)
expect(html).toContain('Fail')
expect(html).toContain('medianMs 80 &gt; 75')
expect(html).toContain('Cross-workspace hidden panes')
})
it('renders ordered revisions with trend charts and baseline deltas', () => {
const mainReport = writeReport(
'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'main.json'
)
const middleReport = writeReport(
'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'backpressure.json'
)
const finalReport = writeReport(
'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'final.json'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({
inputs: [
{ label: 'main', path: mainReport },
{ label: 'backpressure', path: middleReport },
{ label: 'final', path: finalReport }
],
outputPath
})
const html = readFileSync(outputPath, 'utf8')
expect(result.rowCount).toBe(3)
expect(html).toContain('Baseline vs latest')
expect(html).toContain('Trends across revisions')
expect(html).toContain('trend-chart')
expect(html).toContain('>main<')
expect(html).toContain('>backpressure<')
expect(html).toContain('>final<')
// Why: median 50 -> 20 is a 60% improvement and must read as better.
expect(html).toContain('delta better')
expect(html).toContain('-60%')
expect(html).toContain('50.0ms → 20.0ms')
})
it('renders missing scenarios at older revisions as gaps, not zeros', () => {
const mainReport = writeReport(
'panes=25 median=50.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'main.json'
)
const finalReport = makeTempDir()
const finalPath = join(finalReport, 'final.json')
writeFileSync(
finalPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale-same-workspace-25',
description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0'
},
{
type: 'opencode-revisit-pressure',
description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0'
}
]
}
]
}
]
}
]
})
)
const outputPath = join(makeTempDir(), 'report.html')
generateTerminalPerfHtmlReport({
inputs: [
{ label: 'main', path: mainReport },
{ label: 'final', path: finalPath }
],
outputPath
})
const html = readFileSync(outputPath, 'utf8')
expect(html).toContain('Revisit under pressure')
expect(html).toContain('<td>—</td>')
})
it('fails when reports contain no terminal perf annotations', () => {
const reportPath = writeReport('median=12.0ms', 'browser-unrelated')
expect(() =>
generateTerminalPerfHtmlReport({
inputPaths: [reportPath],
outputPath: join(makeTempDir(), 'report.html')
})
).toThrow('No opencode terminal perf annotations found')
})
})
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json'
const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html'
export function parseReportGateArgs(argv, env = process.env) {
const forwardedArgs = [...argv]
@@ -114,7 +115,20 @@ export function runTerminalScalePerfReportGate({
spawnSyncImpl,
env
)
return exitCode(budgetResult)
const budgetExitCode = exitCode(budgetResult)
if (budgetExitCode !== 0) {
return budgetExitCode
}
const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH
const htmlResult = runNodeScript(
'config/scripts/generate-terminal-perf-html-report.mjs',
[reportPath, '--output', htmlReportPath],
'inherit',
spawnSyncImpl,
env
)
return exitCode(htmlResult)
}
if (process.argv[1] === import.meta.filename) {
@@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => {
expect(calls.map((call) => call.args[0])).toEqual([
'config/scripts/run-terminal-scale-perf-e2e.mjs',
'config/scripts/summarize-terminal-perf-report.mjs',
'config/scripts/check-terminal-perf-report-budgets.mjs'
'config/scripts/check-terminal-perf-report-budgets.mjs',
'config/scripts/generate-terminal-perf-html-report.mjs'
])
expect(calls[0].args).toEqual([
'config/scripts/run-terminal-scale-perf-e2e.mjs',
@@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => {
'config/scripts/check-terminal-perf-report-budgets.mjs',
reportPath
])
expect(calls[3].args).toEqual([
'config/scripts/generate-terminal-perf-html-report.mjs',
reportPath,
'--output',
'test-results/terminal-perf-impact-report.html'
])
})
it('uses the report path from env when no flag is provided', () => {
@@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => {
expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath])
})
it('uses the HTML report path from env when provided', () => {
const reportPath = tempReportPath()
const { calls, spawnSyncImpl } = makeSpawnSync()
const status = runTerminalScalePerfReportGate({
env: {
...process.env,
ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html',
ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath
},
spawnSyncImpl
})
expect(status).toBe(0)
expect(calls[3].args).toEqual([
'config/scripts/generate-terminal-perf-html-report.mjs',
reportPath,
'--output',
'tmp/terminal-report.html'
])
})
it('preserves the report when Playwright clears the target report directory', () => {
const reportPath = tempReportPath()
const { spawnSyncImpl } = makeSpawnSync({
@@ -25,6 +25,7 @@ function printMarkdownTable(rows) {
['Frames', 'frames'],
['Median', 'median'],
['Worst', 'worst'],
['Revisit', 'revisit'],
['Scroll', 'scroll'],
['Restore', 'restore'],
['Max Drift', 'maxTimerDrift'],
@@ -0,0 +1,199 @@
import { readFileSync } from 'node:fs'
const BUDGETS = {
medianMs: 75,
worstMs: 300,
revisitMs: 300,
maxTimerDriftMs: 150,
scrollMs: 150,
restoreMs: 1000,
rendererQueuedChars: 2 * 1024 * 1024,
rendererPeakQueuedChars: 2 * 1024 * 1024,
rendererDroppedBacklogs: 0
}
const SCENARIO_LABELS = [
['opencode-scale-same-workspace', 'Same workspace panes'],
['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'],
['opencode-scale-pressure', 'ACK-backpressured PTYs'],
['opencode-scale-hidden-pressure', 'Hidden real PTYs'],
['opencode-cross-workspace-typing', 'Cross-workspace typing'],
['opencode-main-pressure', 'Main renderer pressure'],
['opencode-hidden-pressure', 'Hidden pressure'],
['opencode-revisit-pressure', 'Revisit under pressure'],
// Why: the prefix also matches opencode-parked-memory-disabled, so both
// parked-memory scenarios group under one label.
['opencode-parked-memory', 'Parked hidden terminal memory']
]
export function readJsonReport(path) {
const raw = readFileSync(path, 'utf8')
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error(`${path}: no JSON object found`)
}
return JSON.parse(raw.slice(start, end + 1))
}
function parseAnnotationDescription(description) {
const values = {}
for (const part of description.split(/\s+/)) {
const index = part.indexOf('=')
if (index === -1) {
continue
}
values[part.slice(0, index)] = part.slice(index + 1)
}
return values
}
export function collectTerminalPerfRows(report, source) {
const rows = []
const visitSuite = (suite) => {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const annotation of test.annotations ?? []) {
if (!annotation.type.startsWith('opencode-')) {
continue
}
rows.push(
normalizeRow({
source,
scenario: annotation.type,
...parseAnnotationDescription(annotation.description ?? '')
})
)
}
}
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of report.suites ?? []) {
visitSuite(suite)
}
return rows
}
function parseMs(value) {
const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/)
return match ? Number(match[1]) : null
}
function parseCount(value) {
if (value == null || value === '') {
return null
}
const count = Number(value)
return Number.isFinite(count) ? count : null
}
function normalizeRow(row) {
return {
...row,
group: scenarioGroup(row.scenario),
panes: parseCount(row.panes),
frames: parseCount(row.frames),
medianMs: parseMs(row.median),
worstMs: parseMs(row.worst),
revisitMs: parseMs(row.revisit),
maxTimerDriftMs: parseMs(row.maxTimerDrift),
scrollMs: parseMs(row.scroll),
restoreMs: parseMs(row.restore),
rendererQueuedChars: parseCount(row.rendererQueuedChars),
rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars),
rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs),
mainPeakPendingChars: parseCount(row.mainPeakPendingChars),
mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars),
heldAckChars: parseCount(row.heldAckChars),
hiddenSkippedChars: parseCount(row.hiddenSkippedChars),
// Why: parked-memory annotations report a fractional MB heap figure plus
// live renderer view counts; Number() keeps the MB float intact.
heapUsedMB: parseCount(row.heapUsedMB),
liveTerminals: parseCount(row.liveTerminals),
livePaneManagers: parseCount(row.livePaneManagers)
}
}
export function scenarioGroup(scenario) {
for (const [prefix, label] of SCENARIO_LABELS) {
if (scenario.startsWith(prefix)) {
return label
}
}
return 'Other terminal scenarios'
}
function scenarioSortKey(scenario) {
const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix))
const paneMatch = scenario.match(/-(\d+)$/)
return [
prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex,
paneMatch ? Number(paneMatch[1]) : 0,
scenario
]
}
export function compareScenarios(a, b) {
const ka = scenarioSortKey(a)
const kb = scenarioSortKey(b)
if (ka[0] !== kb[0]) {
return ka[0] - kb[0]
}
if (ka[1] !== kb[1]) {
return ka[1] - kb[1]
}
return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0
}
export function scenarioTitle(scenario, row) {
const group = scenarioGroup(scenario)
if (row?.panes != null) {
return `${group} — ${row.panes} panes`
}
return group
}
export function budgetFailures(row) {
const failures = []
for (const [key, budget] of Object.entries(BUDGETS)) {
const value = row[key]
if (value == null) {
continue
}
if (value > budget) {
failures.push(`${key} ${value} > ${budget}`)
}
}
return failures
}
export function formatMs(value) {
if (value == null) {
return '—'
}
return `${value.toFixed(1)}ms`
}
export function formatLargeValue(value) {
if (value == null) {
return '—'
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)}M`
}
if (value >= 1024) {
return `${Math.round(value / 1024)}k`
}
return String(value)
}
export function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
@@ -0,0 +1,115 @@
# Terminal Hidden View Parking
Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch
`terminalHiddenViewParking` (default on). See
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the
invariants this design extends and the full phase list.
## Problem
Hidden terminal panes keep a full renderer xterm instance alive (buffer,
scrollback, DOM, addons). At many-worktree scale this is the dominant renderer
memory cost, and it forces every hidden byte through renderer-side write/skip
decisions. The main-process model (daemon + runtime headless emulators) already
ingests every byte and can serve restorable snapshots, so the renderer view for
a long-hidden pane is redundant state.
A previous attempt shipped and was reverted the same day. The post-mortem
finding: parking unmounted the pane component, which also tore down the
renderer's PTY byte parsers — and those parsers are the only source of bell
notifications, title-transition agent-complete notifications, and tab titles.
A parked worktree whose agent finished would never notify. This design keeps
those side effects alive while parked.
## Design
### Park policy (renderer)
A pure policy module decides which hidden terminal tabs may park:
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
- Hot-retain working set: recently visible worktrees/tabs are retained
(5 minutes, bounded count) so quick tab switches never pay a re-hydrate.
- Eligibility excludes: visible panes, hidden-measuring startup probes,
activity-portal panes, tabs with pending startup commands or pending
activation spawns, floating-panel tabs, and any tab whose PTY is not
snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded).
- Kill switch: `settings.terminalHiddenViewParking === false` disables parking
entirely.
### Park mechanics
Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer
renders null for parked tabs). This is the same teardown that tab-group moves
already exercise: transports detach but the PTY session, daemon model, and tab
state all survive. The xterm instance, its buffers, DOM, and WebGL/addon
resources are released.
### Parked watcher (the piece the reverted attempt lacked)
While a tab is parked, a pane-less watcher
(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its
consumption mode is decided once at watcher start:
- **Main side-effect authority on (default):** the watcher is purely
fact-driven — it registers exactly one `pty:sideEffect` fact consumer and
parses no bytes. Titles, agent working/idle/exited transitions, BEL
attention, and PR links arrive as main-tracker facts and drive the same
policy callbacks a mounted pane uses. With the hidden-delivery gate also on,
the watcher marks the PTY hidden so main stops renderer byte delivery
entirely; the DECSET 2031 color-scheme subscribe arrives as main's
`2031-subscribe` fact and the watcher replies out-of-band via
`transport.sendInput`.
- **Kill switch off:** the watcher subscribes to raw bytes through the
dispatcher sidecar mechanism (the same mechanism background agent launches
use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2
titles (all-titles ordering, live-path normalization), the title-transition
agent tracker (completion notification, prompt-cache timer), the OSC-aware
stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031
byte responder (`parked-terminal-mode2031-responder.ts`, whose
`subscribeToPtyData` registration doubles as the delivery-interest signal).
The two modes drive one shared policy-callback block, so flipping the kill
switch never changes notification semantics. Main's synthetic
agent-title/permission frames feed the main tracker directly and arrive as
facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off
mode.
Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside
hidden-dropped chunks are answered by main's model responder
([`terminal-query-authority.md`](./terminal-query-authority.md)); in
kill-switch-off byte mode only the 2031 reply is answered and Command Code
output is not scraped, matching the pre-gate status quo.
### Reveal
Revealing a parked tab remounts the pane subtree and rides the existing
reattach path: fresh xterm via `openTerminal` (unicode provider activation
before any write), daemon model snapshot > relay replay > cold restore
precedence, replay-guarded so snapshot-embedded queries never answer, then
`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is
disposed before the pane handlers re-register.
## Invariants
1. PTY reads never stop; parking only changes renderer-side view lifetime.
2. Bell, agent-completion, title, and PR-link side effects keep working while
parked (watcher parity tests).
3. Reveal shows model-correct output (visual gates: hidden TUI restore, long
table, rendering golden) and accepts input immediately.
4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers
or strand parked state.
5. Memory: parked tabs hold no xterm buffers; renderer memory scales with
visible panes.
## Relation to later phases (all shipped)
Side-effect authority in main (Phase 3) replaced the watcher's byte parsing
with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4)
stops hidden byte delivery in main, moving the parked 2031 reply from the
byte sidecar to the `2031-subscribe` fact; the model query responder
(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser
mode survives only behind the kill switches. Parking still excludes
remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher
would return as a byte parser only if remote-runtime tabs — whose bytes never
transit local main — ever became parkable.
@@ -0,0 +1,218 @@
# Terminal Model/View Contract
## Goal
Terminal output should have one authoritative model path and many disposable
views. A renderer xterm is the fast interactive view, but it must not be the
only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists.
This contract defines the boundary the shipped terminal stack implements — and
that future terminal work must preserve — without changing the query-response
behavior that real shells and TUIs depend on. See [Architecture
Status](#architecture-status) for the shipped phases.
## Terms
- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY,
or remote runtime PTY.
- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this
is mostly the headless emulator plus retained read transcript state.
- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop
subscriber, or CLI read page consuming model state and live output.
- **Snapshot:** A bounded model serialization that can restore a view without
replaying an unbounded byte log.
- **Transcript:** The retained output contract for `orca terminal read`; it is
line/cursor oriented and distinct from a screen snapshot.
## Non-Negotiable Invariants
1. PTY reads do not stop to protect renderer performance. Backpressure may bound
delivery to views, but terminal state, notifications, titles, and agent
status keep advancing from the PTY stream.
2. Active visible terminal input/output stays on the lowest-latency path. Bulk
hidden or background output must not delay keystroke-sized foreground redraws.
3. Hidden views do not own unbounded output memory. Main's hidden-delivery
gate drops renderer-bound bytes for hidden-marked PTYs after model
ingestion and emits an out-of-band restore marker
(`pty:modelRestoreNeeded`) so the view restores from the model on reveal.
With the gate's kill switches off, hidden bytes ride a bounded renderer
queue whose overflow latches the same model restore.
4. Returning to a hidden or slept terminal must show model-correct output. A
stale or replaced view may be cleared and replayed from a snapshot, but it
must not show a warning fallback when model recovery is available.
5. Snapshots and live bytes have ordering metadata. A view restore must not
duplicate bytes already included in the snapshot or drop bytes that arrived
after it. Main buffer snapshots report the pending-delivery start sequence
(`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a
restore without misreading foreign sequence domains as duplicates.
6. Terminal query authority is singular and structural: the party that
writes a chunk into a live terminal answers its queries. Visible renderer
and remote views keep xterm authority. Chunks dropped by the
hidden-delivery gate are answered exactly once by the main model
responder, from runtime-emulator state plus renderer-pushed view
attributes. Replayed, seeded, or snapshot bytes are answered by no one.
The daemon emulator never answers. (Amended by Phase 5 — see
[`terminal-query-authority.md`](./terminal-query-authority.md).)
7. The transcript contract stays separate from screen restore. `orca terminal
read` must preserve bounded previews, cursor pagination, partial-line rules,
truncation flags, and total counts even if view snapshots change shape.
8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy
the same model/view contract or explicitly report that model recovery is
unavailable.
## Current Owners
| Responsibility | Current owner |
| --- | --- |
| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` |
| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` |
| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` |
| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` |
| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` |
| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` |
| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` |
| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` |
| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` |
| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` |
## Snapshot Contract
A model snapshot must include:
- terminal dimensions used to produce the snapshot;
- enough ANSI state to rehydrate xterm before snapshot content;
- bounded screen and scrollback content;
- title and cwd metadata when known;
- source metadata that distinguishes headless/model snapshots from renderer
fallback snapshots;
- monotonic ordering metadata for live-output reconciliation when available.
A snapshot must not:
- include unbounded transcript history;
- answer terminal queries while replaying into the model;
- overwrite newer live view output with older model output;
- hide that recovery was unavailable for a PTY surface.
## View Contract
A renderer or remote view may:
- write active visible output immediately;
- budget visible inactive output;
- stop receiving hidden output entirely while main's hidden-delivery gate owns
the bytes (model restore on reveal);
- request fresh snapshots for restore, mobile subscription, or explicit remote
snapshot recovery.
A view must:
- keep live-output buffers bounded while a snapshot is in flight;
- apply generation or sequence checks before replaying a snapshot;
- refresh/repaint after replay when xterm/WebGL needs an explicit paint;
- keep side effects such as title, bell, cwd, and agent status flowing from the
PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte
delivery is budgeted, gated, or parked.
## Transcript Contract
The retained read transcript is not a screen dump. It must preserve:
- uncursored bounded latest preview behavior;
- cursor reads over completed retained lines;
- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`;
- partial-line duplication rules;
- `truncated`, `limited`, and total count metadata;
- bounded memory for long partial lines and large output bursts.
Snapshot optimizations must be tested against this transcript contract instead
of assuming xterm scrollback serialization can replace it.
## Required Contract Tests
Before moving more runtime behavior behind the model/view boundary, add or
extend tests that prove:
- headless snapshots rehydrate rich alternate-screen TUI state;
- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive
queries (the `session.test.ts` pins are permanent);
- the main runtime responder answers queries only from live chunks the
hidden-delivery gate dropped — never delivered, replayed, seeded, or
remote-subscribed chunks;
- hidden renderer overflow restores from model state without duplicate live
output;
- sleep/wake and worktree revisit restore from model-correct state;
- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs;
- remote runtime multiplex output remains ACK bounded and can request recovery
snapshots;
- mobile subscribers receive bounded snapshots without unbounded pending live
output;
- retained terminal reads remain pageable and bounded after large output.
Current coverage is spread across:
- `src/main/daemon/headless-emulator.test.ts`
- `src/main/daemon/session.test.ts`
- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers,
`pendingDeliveryStartSeq`)
- `src/main/ipc/pty-hidden-delivery-gate.test.ts`
- `src/main/runtime/mobile-subscribe-integration.test.ts`
- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts`
- `src/main/runtime/rpc/terminal-multiplex.test.ts`
- `src/main/runtime/orca-runtime.test.ts`
- `src/main/runtime/terminal-query-responder.test.ts`
- `src/shared/terminal-output-side-effects.test.ts`
- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts`
- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts`
- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts`
- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts`
- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts`
- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`
- `tests/e2e/terminal-hidden-view-parking.spec.ts`
- `tests/e2e/terminal-parked-memory.spec.ts`
- `tests/e2e/terminal-sleep-wake-restore.spec.ts`
- `tests/e2e/terminal-output-scheduler.spec.ts`
- `tests/e2e/artificial-opencode-terminal-load.spec.ts`
## Architecture Status
All six phases of the terminal model/view architecture are shipped; the kill
switches noted in parentheses default on:
1. **Hidden view parking** — "Park hidden terminal views behind a byte
watcher": hidden terminal tabs unmount their xterm after a cold-park
hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects
alive while parked (`terminalHiddenViewParking`). See
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md).
2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory":
renderer heap and live-terminal counts gate parking in the perf suite
(`tests/e2e/terminal-parked-memory.spec.ts`).
3. **Side-effect authority in main** — "Track terminal titles in main with
all-titles ordering", "Move terminal side-effect authority to a main facts
channel", "Complete terminal side-effect facts coverage", "Finish terminal
side-effect authority migration": every local/daemon/SSH PTY byte is
side-effect-parsed once in main and delivered as `pty:sideEffect` facts
(`terminalMainSideEffectAuthority`). See
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md).
4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views":
main drops renderer-bound bytes for hidden-marked PTYs after model
ingestion; delivery-interest registrations exempt sidecar byte consumers,
out-of-band restore markers latch model restore, and
`pendingDeliveryStartSeq` reconciles live output racing a restore
(`terminalHiddenDeliveryGate`).
5. **Model query authority** — "Answer hidden terminal queries from the
model", "Bridge renderer view attributes to the model responder", "Align
query authority contract and spawn-time ownership": hidden-dropped queries
are answered by the runtime emulator plus renderer-pushed view attributes,
and hidden-at-spawn PTYs are marked before byte one
(`terminalModelQueryAuthority`). See
[`terminal-query-authority.md`](./terminal-query-authority.md).
6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the
renderer's per-chunk hidden-skip eligibility grammar and the 10s codex
startup query window are deleted; the kill-switch-off fallback is the
bounded background queue with overflow-latched model restore.
Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a
local repaint quirk. Renderer fallback paths retire only when their kill
switches do, and only after the equivalent model path has platform and TUI
golden coverage.
+326
View File
@@ -0,0 +1,326 @@
# Terminal Query Authority
Status: Shipped — Phase 5 of the terminal model/view architecture, kill
switch `terminalModelQueryAuthority` (default on). Builds on
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this
phase **amends invariant 6**),
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md)
(Phase 3), and the Phase-4 hidden-delivery gate
(`src/main/ipc/pty-hidden-delivery-gate.ts`).
## Problem
Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion
(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`).
Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks
waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang
TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4
hidden skip latch had the same hole (only mode 2031 and the 10s codex startup
window answered), so this is not a regression — it is the long-standing gap
this phase closes.
Contract invariant 6 ("the model must never answer queries") was written
against a real bug: the daemon emulator replying ahead of the renderer with
default-xterm values (the OSC-11 default-black-background race,
`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger
was never "the model answers" — it was **two answerers for the same bytes**,
one of them with wrong values. Phase 5 keeps the singularity and fixes the
values.
## Decision: the delivery decision is the reply decision
Main answers a query **iff main dropped the chunk that carried it**. The same
per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that
decides renderer delivery decides reply ownership, evaluated once,
synchronously, at ingestion:
- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via
`Terminal.onData` → `transport.sendInput`, unchanged.
- Hidden-marked, no delivery interest → chunk dropped → main answers from the
runtime headless emulator, via the provider input path (`provider.write`,
same path as `pty:write`; daemon shell-ready write gating and the SSH relay
write apply unchanged).
- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both
sides).
This is structurally exactly-one-responder: a chunk is delivered or dropped,
never both, and each side only answers bytes it actually parsed live. The
mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist
from Phase 4 and are reused, not duplicated.
Rejected alternatives:
- **Fact-based renderer replies per query class** (the mode-2031 pattern
generalized): needs a main-side detection grammar per query, a fact round
trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the
emulator is the only state for a hidden pane. The 2031 fact stays because it
is subscription registration, not a state query.
- **Emulator always answers**: re-creates the OSC-11 double-reply race for
visible panes. Never.
## Mechanism: forwarded emulator onData, not a new grammar
`HeadlessEmulator` has `onData` wiring behind a per-write capture flag.
For static and model-state queries, xterm core **is** the query grammar: the
runtime emulator runs the same xterm version with equivalent options as the
renderer pane, so main's reply set equals the visible renderer's by
construction — verified empirically against the bundled headless build:
DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including
DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS
(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching
visible behavior today. The headless build has **no theme service**: OSC
4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option
set, so the view-attribute class is answered by responder-registered parser
handlers instead (below) — never by core defaults.
Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and
attached to the emulator `writeChain` link (the mark can flip between
ingestion and an async write; the decision must not be re-read at reply time):
1. gate enabled (`terminalMainSideEffectAuthority` and
`terminalHiddenDeliveryGate` both on) AND new kill switch
`terminalModelQueryAuthority !== false`;
2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData`
— same module state, same tick as the drop sites);
3. the write is live PTY data — never `seedHeadlessTerminal`,
`maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay
(main-side replay guard, mirror of the renderer's `replay-guard.ts`);
4. no remote view subscriber is attached to the PTY (runtime terminal-RPC
subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop
xterm receiving the multiplexed stream answers with view authority, exactly
like a visible local pane. Legacy JSON `terminal.subscribe` streams **do**
register as view subscribers and suppress, even when the consumer is a
read-only watcher — deliberately conservative, because the stream may feed
an older live xterm view and a withheld reply (the pre-Phase-5 status quo)
is strictly safer than a double reply. Consumers that never register a
stream (CLI `terminal.read`, automation observers) do not suppress — they
also do not answer; that bounded no-reply case matches today's behavior.
Everything the emulator emits outside a forwarding window is discarded, which
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
triggered by option mutations).
## Reply classes
| Class | Queries | Answer source |
| --- | --- | --- |
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
### View-attribute bridge
Renderer→main push, `pty:terminalViewAttributes` — one global snapshot,
not per-PTY: the composed terminal `ITheme` (from
`applyTerminalAppearance`, `terminal-appearance.ts`),
`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme
mode (`resolveTerminalColorSchemeMode` — the same source as the existing
hidden 2031 reply). Pushed on renderer startup and on every theme/settings
apply.
Main consumes it two ways:
- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options
inside the replay guard; xterm core then answers DECRQSS DECSCUSR and
DECRQM 12 with renderer-true values (verified working headless).
- Palette and color-scheme replies come from responder-registered parser
handlers on the emulator (`registerOscHandler` 4/10/11/12,
`registerCsiHandler` for DSR ?996n), because the headless core cannot
answer them. The OSC handlers see SET payloads too, so runtime OSC
4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are
tracked per PTY and layered over the pushed base palette — matching what
the renderer's theme service reports for a visible pane.
Staleness rules: replies use the last push; a theme flip is stale for at most
one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push).
**Before the first push main answers no view-attribute query** — a fabricated
default would resurrect the default-black OSC-11 bug; silence is the
documented hidden status quo.
### Kitty keyboard flags
`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching
`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low:
for the write-only daemon use, keyboard state never alters serialization; the
change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring
them, and lets the responder answer `CSI ? u` with the flags the hidden app
actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes`
for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty
flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty
reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`)
stays authoritative. Slice 3 wires the re-seed consumer: the daemon
warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn
result into `seedHeadlessTerminal`, which applies them to the fresh runtime
emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding
window), so hidden `CSI ? u` reports the flags the hidden app actually
pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer
`?0u`; protocol-conformant programs re-push.
### ConPTY DA1 variant
The provider kind is known main-side: mirror `isLocalNativeWindowsPty`
(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon
provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the
emulator parser (the main-side twin of
`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by
the forwarding predicate. The override is installed at emulator creation and
retrofitted when the spawn mark lands (daemon stream data can create the
emulator before the awaited spawn response marks the PTY). ConPTY blocking on
a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is
closed by the slice-3 `initiallyHidden` spawn flag (races section).
## Suppression: when main never replies
- Visible or unmarked PTY (chunk was delivered).
- Renderer delivery interest registered (chunk was delivered to a sidecar).
- Remote-runtime (`remote:`) PTYs — never markable
(`isHiddenDeliveryGateManagedPty`), bytes never transit local main.
- Remote view subscriber attached (mobile/web/remote desktop owns replies).
- Seed/hydration/snapshot writes into the emulator, and option pushes.
- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an
independent off switch for the responder alone.
- The **daemon** emulator: never, under any setting. The responder lives in
main's runtime only; `session.test.ts:163-190` stays pinned verbatim.
## Transition races
Worst cases, per direction:
- **visible→hidden**: chunks delivered between the visibility flip and the
mark landing in main are hidden-skipped by the renderer write path without
query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden
skip behavior, bounded by one renderer→main IPC hop. After the mark lands,
main answers everything it drops.
- **hidden→visible**: unmark consumes the drop latch and emits the restore
marker; the snapshot replay is replay-guarded, so queries main already
answered are never re-answered from the snapshot; post-unmark live chunks
are answered by xterm once (restore-queued live chunks reply late, not
twice).
- **Split queries across the drop/deliver boundary**: neither parser saw the
whole sequence → no reply; the restore marker resets renderer cross-chunk
state and replay hygiene resets the parser. At-most-once holds.
Safe-side rule per class: duplicates are structurally impossible (one decision
point per chunk); where the race costs anything it costs a missing reply.
That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or
tolerate silence, as they did for every hidden pane before this phase). The
one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible
pane answers it from the renderer xterm. A PTY spawned hidden previously had
no answerer until the renderer's hidden mark landed in main (one IPC hop
after spawn). Slice 3 closes that window with the `initiallyHidden`
spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn`
(never for remote-runtime transports), and main marks the PTY hidden before
the first byte — pre-spawn for
daemon-host sessions whose id is minted up front, immediately after
`provider.spawn` resolves otherwise — so the gate and responder own queries
from byte one. The pane's first visibility sync then re-marks or unmarks
through the existing Phase-4 machinery (unmark emits the restore marker for
any spawn-window drops).
## Invariants
1. Exactly one party may answer any query, chosen by the chunk's delivery
decision: delivered → the consuming live view's xterm; dropped → main's
model responder; replayed/seeded → no one. The decision is captured once,
synchronously, at ingestion.
2. Main answers only from live PTY bytes parsed by the runtime emulator —
never from snapshot, seed, hydration, or option-push writes.
3. View-attribute answers are renderer-true or absent: no reply is ever
fabricated from emulator defaults (the OSC-11 lesson).
4. The daemon emulator stays write-only; daemon subprocess query writes stay
zero (`session.test.ts` pins are permanent).
5. Reply parity is structural for static and model-state classes: same xterm
core, equivalent options, no hand-rolled grammar — the only overrides are
the documented ConPTY DA1 variant and the view-attribute parser handlers
the headless core cannot serve.
6. Remote views keep view authority; main yields whenever a remote view
subscriber is attached.
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
replaced by:
> 6. Terminal query authority is singular and structural: the party that
> writes a chunk into a live terminal answers its queries. Visible renderer
> and remote views keep xterm authority. Chunks dropped by the
> hidden-delivery gate are answered exactly once by the main model
> responder, from runtime-emulator state plus renderer-pushed view
> attributes. Replayed, seeded, or snapshot bytes are answered by no one.
> The daemon emulator never answers.
The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11,
or theme-sensitive queries" splits into: daemon emulator never answers
(unchanged pins) / runtime responder answers only hidden-dropped chunks. The
side-effect authority matrix row "DECSET 2031 reply — query authority stays
with the view (contract invariant 6)" gains a pointer here; its reply path is
otherwise untouched in this phase.
## Test strategy
- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs
interest-suppressed; each reply class; seed/hydrate silence; remote-
subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip
between ingestion and async emulator write (captured decision wins).
- Parity harness: shared query byte fixtures through a renderer-configured
xterm (onData capture) and through the responder; assert byte-identical
replies for static + model-state classes, and for view-attribute classes
after an attribute push.
- `session.test.ts:163-190`: assertions stay; the comment is updated to name
the main responder (not "the renderer") as the hidden answerer.
- E2E: hidden `claude /theme` reports the configured theme; hidden TUI
blocked on CPR/DA unblocks while gated; reveal shows no stray reply
fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and
`terminal-hidden-view-parking.spec.ts` stay green.
## Cut-offs (shipped as three stacked slices)
1. **Responder core.** Emulator onData wiring + per-write capture + main
replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`);
static + model-state classes; ConPTY DA1 override; remote-subscriber
suppression; `terminalModelQueryAuthority` switch; unit + parity tests.
Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock.
2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor
option application under the guard, responder OSC/DSR parser handlers with
per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e.
3. **Contract alignment.** Invariant-6 amendment in the contract doc, test
bullet split, `session.test.ts` comment, side-effect matrix pointer, and
the Phase 6 prerequisites below recorded as accepted.
## Phase 6 (delete skip grammar + startup window): prerequisites from this design
Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s
codex startup renderer-query window are deleted. Kill-switch-off hidden panes
fall back to the pre-grammar path — hidden bytes ride the bounded background
scheduler queue; overflow latches the model-snapshot restore — and never run
a per-chunk content scan.
Accepted and shipped in slice 3 (except where noted):
- **Mark-before-first-byte** (shipped): panes spawned without a visible view
are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn`
(spawn-record flag, not a renderer round trip) so startup queries —
including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6
removed the codex exclusion with the window: codex spawns are main-owned
from byte zero too, the responder answering their startup probes.
- **Attributes before spawn** (shipped): the renderer pushes composed view
attributes once at app start (right after settings load, before terminal
reconnect/spawn), so spawn-time view-attribute queries no longer fall into
the silent-until-push rule. Per-pane appearance applies keep re-publishing
through the same deduped publisher.
- **Daemon shell-ready write gating** (verified): responder replies through
`ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never
dropped, and the queue flushes at the shell-ready marker or the 15s
`SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed
with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches
as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains
worth adding.
- With the skip grammar deleted, every chunk is either written to a live
xterm or dropped — the delivered-but-skipped no-reply gap disappears and
the only remaining loss window is the mark IPC race.
- **2031 consolidation** (optional follow-up): move the subscription registry
into the responder (the headless core cannot serve 997 pushes any more than
it can ?996n) and push 997 flips from the attribute cache, retiring the
`2031-subscribe` fact reply, the parked responder, and the parked-tab
theme-flip gap.
@@ -0,0 +1,252 @@
# Terminal Side-Effect Authority
Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch
`terminalMainSideEffectAuthority` (default on). Builds on
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1).
## Problem
Main parses every local/daemon/SSH PTY byte before renderer delivery
(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`:
side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL
watchers; SSH feeds the same path from `wireUpPtyEvents` in
`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects
users see — bell unread/notifications, title transitions, agent-complete
notifications, command lifecycle, PR links — were derived a second time by
renderer byte parsers. That duplication forced Phase 1's watcher to parse
bytes, forced main to fabricate synthetic OSC title frames over `pty:data`
just so renderer parsers could see them, and blocked Phase 4 from ever
stopping hidden byte delivery. Phase 3 made main the side-effect parser for
every PTY whose bytes transit local main; the renderer byte parsers
(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte
mode) survive only for remote-runtime PTYs and the kill-switch-off fallback.
## Authority Matrix
"Main" means parsed once in `onPtyData` and delivered as derived facts.
Remote-runtime PTYs (`remote:`) never transit local main; the renderer
(`remote-runtime-pty-transport.ts:74`) stays their parser permanently.
| Side effect | local-daemon | SSH | remote-runtime |
| --- | --- | --- | --- |
| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) |
| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer |
| BEL attention (OSC-aware stateful detector) | main | main | renderer |
| OSC 133;D command-finished exit code | main | main | renderer |
| GitHub PR-link scan | main | main | renderer |
| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer |
| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer |
| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer |
## Main-Side Tracker
- The side-effect core shared with the renderer processor lives in
`src/shared/terminal-output-side-effects.ts`: all-titles ordering via
`extractAllOscTitles` (coalesced working→idle transitions are why last-title
is insufficient — issue #1083), `normalizeTerminalTitle`, the literal
`cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`,
`src/shared/agent-detection.ts`), the `createAgentStatusTracker`
transitions, the stale-working-title 3s timer
(`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector
(`src/shared/terminal-bell-detector.ts`).
- One tracker per PTY on `OrcaRuntimeService`, lazily created like
`agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the
stale-title timer).
- It replaced the chunk-level last-title extraction in `onPtyData`: titles
feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters,
and pending-message delivery see intermediate transitions instead of only
the chunk's last title. PTY/leaf records keep the **raw** last title
(worktree `ps` and mobile tab titles expect raw); emitted facts carry
`(normalizedTitle, rawTitle)` like `onTitleChange`.
- No deferred drain in main — the renderer's setTimeout(0) batching
(`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which
does not exist in main. Main applies synchronously and batches the IPC per
flush.
- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own
last-title scan, untouched: synthetic titles must never reach it.
## Event Transport: `pty:sideEffect`
One batched main→renderer channel (`window.api.pty.onSideEffect`,
`src/preload/index.ts`). It is **not** routed through the pty dispatcher:
the renderer fact-consumer registry
(`terminal-side-effect-facts-handler.ts`) subscribes directly via
`window.api.pty.onSideEffect` — one channel subscription per renderer, with
exactly one registered fact consumer per PTY. Events are **facts, not
decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title),
`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries
`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf
records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY
`outputSequence`.
Ordering rules:
1. Per-PTY in-order; facts from one chunk are emitted in byte order (status
payloads, then titles in sequence, then bell — the renderer drain's order).
2. Deliberately **not** synchronized with `pty:data`: side effects must keep
advancing while renderer delivery is ACK-gated (contract invariant 1). A
completion title may reach the store before the visible xterm paints the
final output; that is acceptable — attention/title state is out-of-band UI
state, and today's renderer drain already decouples by many batches under
timer throttling.
3. No attention replay: facts emitted while no renderer is subscribed are
dropped. On transport attach/park-handoff the renderer pulls a title-only
snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces
the eager-buffer behavior where replay restores titles but is barred from
bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The
store handler ignores a replay title older (by `outputSequence`) than the
last live title fact it applied.
## Renderer Store Handler (policy stays in the renderer)
Notification semantics, all preserved across the authority flip:
- BEL marks worktree+tab unread unconditionally — including the focused pane
(`onBell`, `pty-connection.ts`); pane unread only behind
`experimentalTerminalAttention`; keydown clears unread
(`onTerminalKeyDown`, `pty-connection.ts`).
- BEL's OS notification is delayed 250 ms and yields to a pending
agent-task-complete (`scheduleTerminalBellNotification`,
`pty-connection.ts`).
- working→idle starts the Claude cache timer (null settings = not hydrated,
treat enabled) and schedules completion with 250 ms grace + 1500 ms max
wait + detail-wait store subscription
(`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` /
`AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`,
`agent-task-complete-policy.ts`).
- Completion unread is suppressed only for the exact visible foreground pane
(`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread
has no such check.
- Dispatch-time liveness/staleness guards
(`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's
5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`,
`src/main/ipc/notifications.ts`) remain the final gates.
These need live renderer store state (PTY/layout maps, pane visibility,
settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer:
the pane-independent per-paneKey handler module
(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and
subsumes both `pty-connection.ts`'s callbacks and the parked watcher's
callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) —
one policy path whether the tab is mounted, hidden, or parked. Main holds
**no** notification timers; only the stale-title timer (parser state) lives
in main.
## Synthetic Frame Reroute
`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`,
`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`,
so synthetic agent-title/BEL frames enter the per-PTY tracker directly —
**not** `onPtyData`, so emulator state, tails, transcripts, and stats never
see them. The decorative-frame visibility gating
(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data`
copy survives only in kill-switch-off mode, where renderer parsers still
need the bytes. The visible xterm renders nothing from titles, but
`pane.terminal.onTitleChange` feeds `registerPtyTitleSource`
(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile
parity); main prefers its own tracked title over renderer snapshot
`lastTitle` in both serialize paths. Under main authority synthetic frames
no longer produce phantom ACKs for bytes main never metered (`ackPtyData`,
`pty-dispatcher.ts`).
## Migration Switch and Double-Fire Prevention
Authority is structural per PTY kind — the predicate is "bytes transit local
main", exactly the `shouldOwnAgentStatusInRenderer` split
(`pty-connection.ts`). One renderer-consulted kill switch
(`settings.terminalMainSideEffectAuthority`, default on, mirroring
`terminalHiddenViewParking`): when on, IPC transports and the parked watcher
do not register byte parsers for local/SSH and the store handler consumes
`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect`
events are ignored. Main always parses and emits (its internal consumers need
the tracker regardless); main consults the same setting only to keep the
legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly
one consumer per fact at any time — decided at transport/watcher creation, so
no per-chunk race.
## Sidecar Consumers and Phase 4
Keep renderer byte access (input pacing / raw-output consumers, not side
effects): `agent-paste-draft.ts` (DECSET 2004 readiness),
`launch-agent-background-session.ts` (startup-injection pacing, onData
passthrough), `automation-session-observer.ts` (onData passthrough), and
`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for
parked tabs while the delivery gate is off). Their duplicated local OSC 9999
store writes are gated off under main authority (the `onAgentStatus`
automation callbacks still fire; only the racing `setAgentStatus` store
writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active
`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a
ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main
authoritative, the parked watcher is purely fact-driven: byte parsing exists
only in kill-switch-off mode, and the 2031 reply comes from the
`2031-subscribe` fact when the gate is on (the byte responder sidecar only
when it is off). The watcher file is deleted outright only when the kill
switch retires — it returns as a byte parser only if remote-runtime tabs
ever become parkable.
## Invariants
1. Every byte is side-effect-parsed exactly once, by exactly one authority,
chosen structurally per PTY kind.
2. Attention facts never replay: snapshot/eager/attach replays restore title
state only.
3. Notification policy (grace timers, yielding, suppression, dispatch guards)
lives with the renderer store; main emits facts with ordering metadata.
4. Side-effect facts keep flowing while renderer byte delivery is
backpressured, parked, or stopped by the hidden-delivery gate.
5. Synthetic agent frames feed the model tracker, never the emulator, tails,
transcripts, or stats.
## Test Strategy
- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte
fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning
OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under
fake timers, OSC 133;D, split PR URLs) run through the renderer
`createPtyOutputProcessor` and the main tracker; assert identical ordered
fact sequences.
- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle
parity, tui-idle waiter transitions, synthetic ingestion); store-handler
tests reusing `parked-terminal-byte-watcher.test.ts` scenarios.
- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring,
`parked-terminal-byte-watcher.test.ts` (retires with the watcher);
`pty-transport*.test.ts` stay (processor remains for remote + kill switch).
- E2E gates that must stay green throughout: `terminal-attention.spec.ts`,
`droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`,
`terminal-parked-memory.spec.ts`; add main-authority bell/completion cases
(parked tab, focused-pane suppression, kill switch off). SSH parity is
exercised manually per the SSH test procedure before each slice ships.
## Cut-Offs (shipped as four stacked slices)
1. **Shared tracker in main.** Extract the processor core to shared, run the
per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity
tests. Main-internal consumers only; no IPC or renderer change.
2. **Authority flip.** `pty:sideEffect` channel, renderer store handler,
titles/bell/tracker authority to main for local+SSH behind the kill
switch; parked watcher stops byte parsing for those kinds.
3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`;
OSC 133;D and PR-link facts; mobile `lastTitle` source preference.
4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked
watcher shrunk to fact-driven mode (deletion waits on kill-switch
retirement), Phase 4 delivery-interest registration documented in the gate
design.
## Open Items
- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic
checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon
`Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is
derived from real PTY bytes only — synthetic hook title frames never reach
the daemon process, so that field cannot carry hook-driven titles. Today no
restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots
surface only the ANSI payload), so there is nothing to fix. Main-side
consumers of the renderer serializer's `lastTitle` (mobile snapshot reads
and the headless hydration seed) prefer main's tracked title. If a future
consumer starts reading checkpoint `lastTitle`, it must route through the
same tracked-title preference.
- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is
removed, the parked watcher's byte-parser mode, the renderer transport
parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all
become dead code and the watcher byte path can be deleted outright.
+11
View File
@@ -1,5 +1,9 @@
# Terminal Main-Owned State
This document covers the hidden-output recovery slice. The broader terminal
model/view boundary is defined in
[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md).
## Problem
Hidden and background terminal panes cannot rely on renderer memory as the only
@@ -51,6 +55,13 @@ already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for
local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated
and can serialize it.
Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default
on — see the contract's Architecture Status), main drops hidden renderer-bound
bytes after model ingestion and emits an out-of-band restore marker, so a
gated hidden pane accumulates no renderer backlog at all. The overflow path
below is the fallback for kill-switch-off mode and for hidden PTYs with an
active delivery-interest sidecar.
The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded:
1. The scheduler replaces the queued backlog with a small warning fallback.
+1
View File
@@ -78,6 +78,7 @@
"test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs",
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
@@ -877,6 +877,105 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
})
it('checkpoints before keep-history shutdown so sleep can cold restore latest output', async () => {
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
adapterClass.CHECKPOINT_INTERVAL_MS = 10_000
try {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const { id } = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: 'sleep-checkpoint'
})
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
lastSubprocess._simulateData('latest before sleep\r\n')
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
expect(checkpointSpy).toHaveBeenCalledWith(
id,
expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') })
)
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
const restored = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: id
})
expect(restored.coldRestore?.scrollback).toContain('latest before sleep')
historyAdapter.ackColdRestore(id)
const remountAfterAck = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: id
})
expect(remountAfterAck.coldRestore).toBeUndefined()
} finally {
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
}
})
it('cold restores the second sleep/wake cycle with post-wake output', async () => {
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
adapterClass.CHECKPOINT_INTERVAL_MS = 10_000
try {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const { id } = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: 'sleep-wake-cycles'
})
lastSubprocess._simulateData('first cycle content\r\n')
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json')
const checkpointPath = join(historyDir, getHistorySessionDirName(id), 'checkpoint.json')
// Why: keep-history sleep stays unclean so cold restore remains eligible;
// the final checkpoint is the deterministic handoff signal.
expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull()
expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain(
'first cycle content'
)
const firstWake = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: id
})
expect(firstWake.coldRestore?.scrollback).toContain('first cycle content')
historyAdapter.ackColdRestore(id)
expect(historyAdapter.hasPty(id)).toBe(true)
lastSubprocess._simulateData('second cycle content\r\n')
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull()
expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain(
'second cycle content'
)
const secondWake = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: id
})
expect(secondWake.coldRestore?.scrollback).toContain('second cycle content')
} finally {
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
}
})
it('writes meta.json with endedAt on exit', async () => {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
+56 -17
View File
@@ -7,6 +7,7 @@ import { DaemonClient } from './client'
import { getMacDaemonSystemResolverHealth } from './daemon-health'
import { HistoryManager } from './history-manager'
import { HistoryReader } from './history-reader'
import type { ColdRestoreInfo } from './history-reader'
import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
import { supportsPtyStartupBarrier } from './shell-ready'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
@@ -80,6 +81,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// mount → ??? The sticky cache returns the same cold restore data on the
// second mount until the renderer explicitly acknowledges it.
private coldRestoreCache = new Map<string, ColdRestorePayload>()
private sleepRestoreSessionIds = new Set<string>()
private activeSessionIds = new Set<string>()
private dirtySessionVersions = new Map<string, number>()
// Why: a cold-restored session is a fresh shell whose on-disk checkpoint and
@@ -200,6 +202,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
// but should still return the cached cold restore data.
const cachedRestore = this.coldRestoreCache.get(sessionId)
if (cachedRestore) {
// Why: wake after sleep also lands here, and the slept session's active
// tracking and history writer were dropped when sleep killed the PTY.
// Without re-registering both, checkpoints stop after wake and the
// second sleep/wake cycle restores a blank terminal.
this.activeSessionIds.add(sessionId)
if (this.historyManager) {
this.historyManager.reopenSession(sessionId)
}
return {
id: sessionId,
pid,
@@ -215,17 +225,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// an unclean shutdown → return saved scrollback so the renderer can
// display the previous terminal content.
if (result.isNew && restoreInfo) {
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
// (no rehydrateSequences — they start with \x1b[?1049h, which the
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
// normal scrollback. An empty snapshot still yields null → no-op.
const isAltScreen = restoreInfo.modes.alternateScreen
const scrollback = isAltScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
const coldRestore = this.buildColdRestorePayload(restoreInfo)
// Why: use registerWriter (not openSession) to avoid deleting the
// existing checkpoint.json. If the revived daemon crashes again before
// the next 5s tick, the checkpoint is the only recovery data available.
@@ -237,8 +237,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// within one adapter) must not defer this re-anchor.
this.lastFullCheckpointAt.delete(sessionId)
}
if (scrollback) {
const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks }
if (coldRestore) {
this.coldRestoreCache.set(sessionId, coldRestore)
return { id: sessionId, pid, coldRestore }
}
@@ -277,12 +276,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
const isAltScreen = result.snapshot.modes.alternateScreen
const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi
// Why kitty flags ride beside the payload, not inside it: the snapshot
// string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's
// deliberate kitty reset must win. Only the runtime emulator re-seed
// consumes the flags (terminal-query-authority.md §kitty).
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
return {
id: sessionId,
pid,
snapshot: snapshotPayload,
snapshotCols: result.snapshot.cols,
snapshotRows: result.snapshot.rows,
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
: {}),
isReattach: true,
isAlternateScreen: isAltScreen
}
@@ -313,15 +320,27 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
// Why: sleep/exact-stop must preserve restorable terminal history,
// so force a final checkpoint before killing the daemon session.
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
// Force a final snapshot so wake can restore the pane users left.
if (opts.keepHistory) {
if (this.checkpointInFlight) {
await this.checkpointInFlight
}
await this.checkpointSessions([id], { final: true, teardown: true })
const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
if (coldRestore) {
this.coldRestoreCache.set(id, coldRestore)
this.sleepRestoreSessionIds.add(id)
}
}
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
this.activeSessionIds.delete(id)
this.dirtySessionVersions.delete(id)
this.coldRestoreCache.delete(id)
if (!opts.keepHistory) {
this.coldRestoreCache.delete(id)
this.sleepRestoreSessionIds.delete(id)
}
// Why: the !keepHistory close path doesn't take a final checkpoint, so a
// session stranded in sessionsNeedingFullCheckpoint would never be cleared.
// (Under keepHistory the final checkpoint above already cleared the flag, so
@@ -357,12 +376,30 @@ export class DaemonPtyAdapter implements IPtyProvider {
ackColdRestore(sessionId: string): void {
this.coldRestoreCache.delete(sessionId)
this.sleepRestoreSessionIds.delete(sessionId)
}
clearTombstone(sessionId: string): void {
this.killedSessionTombstones.delete(sessionId)
}
private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
// Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer
// (vim/less/htop); normal sessions use the full snapshot + rehydrate.
// Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback)
// would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone*
// (no rehydrateSequences — they start with \x1b[?1049h, which the
// renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as
// normal scrollback. An empty snapshot still yields null → no-op.
const scrollback = restoreInfo.modes.alternateScreen
? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
if (!scrollback) {
return null
}
return { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks }
}
async sendSignal(id: string, signal: string): Promise<void> {
await this.client.request('signal', { sessionId: id, signal })
}
@@ -976,7 +1013,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
} else if (event.event === 'exit') {
this.activeSessionIds.delete(event.sessionId)
this.dirtySessionVersions.delete(event.sessionId)
this.coldRestoreCache.delete(event.sessionId)
if (!this.sleepRestoreSessionIds.has(event.sessionId)) {
this.coldRestoreCache.delete(event.sessionId)
}
// Why: an exited session can never be checkpointed again, so its pending
// full-checkpoint flag is dead state. Without this, a cold-restored
// session that exits before its first checkpoint leaks a permanent entry.
+86
View File
@@ -98,6 +98,49 @@ describe('HeadlessEmulator', () => {
uri: 'https://example.com/issue/1234'
})
})
it('serializes split synchronized rich TUI frames for model-backed replay', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 12 })
const richFrame = [
'\x1b[?2026h',
'\x1b[?1049h',
'\x1b[2J\x1b[H',
'\x1b[?25l',
'\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n',
'\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n',
'\x1b[2;36m│ status streaming │\x1b[0m\r\n',
'\x1b[2;36m╰────────────────────────────╯\x1b[0m',
'\x1b[6;4H\x1b[?25h',
'\x1b[?2026l'
].join('')
// Why: hidden rich TUI bytes may arrive split across DEC 2026 frame
// boundaries; model/view work needs the headless model to preserve the
// final visible state before renderer writes can be removed.
await emulator.write(richFrame.slice(0, 17))
await emulator.write(richFrame.slice(17, 91))
await emulator.write(richFrame.slice(91))
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.alternateScreen).toBe(true)
expect(snapshot.snapshotAnsi).toContain('Codex rich restore')
expect(snapshot.snapshotAnsi).toContain('🟢')
expect(snapshot.snapshotAnsi).toContain('███░')
expect(snapshot.snapshotAnsi).toContain('╭')
expect(snapshot.snapshotAnsi).not.toContain('\x1b[?2026h')
const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows })
try {
await replay.write(snapshot.rehydrateSequences + snapshot.snapshotAnsi)
const replayed = replay.getSnapshot()
expect(replayed.modes.alternateScreen).toBe(true)
expect(replayed.snapshotAnsi).toContain('Codex rich restore')
expect(replayed.snapshotAnsi).toContain('🟢')
expect(replayed.snapshotAnsi).toContain('███░')
} finally {
replay.dispose()
}
})
})
describe('OSC-7 CWD tracking', () => {
@@ -305,6 +348,49 @@ describe('HeadlessEmulator', () => {
expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false)
})
it('tracks kitty keyboard flags for emulator re-seed parity', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0)
await emulator.write('\x1b[=5;1u')
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5)
})
it('round-trips a pushed CSI > 1 u flag through the core-internals read path', async () => {
// Why: getKittyKeyboardFlags reads _core.coreService.kittyKeyboard.flags,
// a private xterm surface. If an xterm upgrade breaks that path this
// must fail loudly instead of the responder silently answering ?0u.
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[>1u')
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(1)
})
it('snapshots the active-buffer kitty flags (alt screen keeps its own set)', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
// Kitty flags are per screen buffer: entering the alt screen swaps to
// its own (empty) flag set, exactly what a CSI ? u reply would report.
await emulator.write('\x1b[=5;1u\x1b[?1049h')
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0)
await emulator.write('\x1b[=3;1u')
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(3)
await emulator.write('\x1b[?1049l')
expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5)
})
it('never pushes kitty flags into rehydrateSequences', async () => {
// Why: POST_REPLAY_REATTACH_RESET's deliberate kitty reset must stay
// authoritative for renderer replays (terminal-query-authority.md).
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1049h\x1b[=5;1u')
const snapshot = emulator.getSnapshot()
expect(snapshot.modes.kittyKeyboardFlags).toBe(5)
expect(snapshot.rehydrateSequences).not.toContain('u')
})
it('tracks split SGR mouse reporting sequences', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
+199 -143
View File
@@ -3,10 +3,14 @@ import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
import { extractLastOscTitle } from '../../shared/agent-detection'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
import { parseFileUriPath } from './osc7-file-uri'
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
import {
installTerminalViewAttributeResponder,
type TerminalViewAttributeResponder
} from './terminal-view-attribute-responder'
import type { TerminalSnapshot, TerminalModes } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
@@ -14,33 +18,54 @@ export type HeadlessEmulatorOptions = {
cols: number
rows: number
scrollback?: number
/** Phase-5 model query responder sink (terminal-query-authority.md).
* When set, xterm-core auto-replies generated while parsing a write
* flagged `forwardQueryReplies` are forwarded here; all other emissions
* (seeds, hydration, snapshot replay, unsolicited core pushes) are
* discarded. The daemon Session must NEVER pass this — its emulator
* stays write-only forever (contract invariant: the daemon never
* answers). */
onQueryReply?: (reply: string) => void
}
export type HeadlessEmulatorWriteOptions = {
/** Reply ownership captured at ingestion for this exact chunk. Default
* false is the main-side replay guard (twin of the renderer's
* replay-guard.ts): seed/hydration/snapshot writes never forward. */
forwardQueryReplies?: boolean
}
type TerminalWithSynchronousWrite = Terminal & {
_core?: {
writeSync?: (data: string) => void
// Why: kitty keyboard flags are not on the public IModes; read the core
// service state the CSI =/>/< u handlers mutate.
coreService?: {
kittyKeyboard?: { flags?: number }
}
}
}
const DEFAULT_SCROLLBACK = 5000
const OSC_SCAN_TAIL_LIMIT = 4096
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
// Keep in sync with the renderer twin in terminal-capability-replies.ts
// (main must not import renderer modules).
const CONPTY_DA1_RESPONSE = '\x1b[?61;4c'
export class HeadlessEmulator {
private terminal: Terminal
private serializer: SerializeAddon
private cwd: string | null = null
private lastTitle: string | null = null
private oscScanTail = ''
private privateModeScanTail = ''
private mouseTrackingMode: MouseTrackingMode = 'none'
private sgrMouseMode = false
private sgrMousePixelsMode = false
private oscText = new TerminalOscCwdTitleScanner()
private mouseModes = new TerminalMouseModeMirror()
private restoredOscLinks: TerminalOscLinkRange[] = []
private disposed = false
private onQueryReply: ((reply: string) => void) | null
private conptyDa1OverrideInstalled = false
private viewAttributeResponder: TerminalViewAttributeResponder | null = null
// Why: replies must be scoped to the exact write that carried the query.
// The window opens around the parse of a forward-flagged chunk and closes
// with it, so seeds/snapshots and unsolicited core emissions (e.g. native
// 997 pushes from option mutations) can never leak to the PTY.
private queryReplyForwardingDepth = 0
constructor(opts: HeadlessEmulatorOptions) {
this.terminal = new Terminal({
@@ -48,7 +73,12 @@ export class HeadlessEmulator {
rows: opts.rows,
scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK,
allowProposedApi: true,
logLevel: 'off'
logLevel: 'off',
// Why: parity with the renderer's buildDefaultTerminalOptions — parse
// CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app
// actually pushed. Write-only daemon use is unaffected: keyboard state
// never alters serialization (terminal-query-authority.md §kitty).
vtExtensions: { kittyKeyboard: true }
})
this.serializer = new SerializeAddon()
@@ -62,33 +92,136 @@ export class HeadlessEmulator {
this.terminal.loadAddon(new Unicode11Addon())
activateOrcaTerminalUnicodeProvider(this.terminal)
// Why no onData wiring: this emulator exists purely for state tracking
// (snapshots, cwd, mode flags). It MUST NOT respond to terminal query
// sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses
// data in-process synchronously before `handleSubprocessData` forwards
// it to the renderer over IPC, so any reply it emits would land on the
// shell's stdin ahead of the renderer's xterm reply and win the race.
// The renderer is the authoritative responder (it has the real theme,
// cursor position, and paste mode); a daemon-side reply would be a
// double-reply with wrong values. OSC 11 was the visible casualty:
// Claude Code's /theme auto always saw the emulator's default-black
// background regardless of Orca's configured terminal theme.
// Why onData is gated behind onQueryReply: by default this emulator is
// pure state tracking and MUST NOT respond to terminal query sequences
// (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The daemon emulator parses data
// in-process synchronously before `handleSubprocessData` forwards it to
// the renderer over IPC, so any reply it emitted would land on the
// shell's stdin ahead of the renderer's xterm reply and win the race —
// a double-reply with default-xterm values (OSC 11 default-black was
// the visible casualty). Only main's runtime per-PTY emulators pass a
// sink, and even then replies flow only for chunks the hidden-delivery
// gate DROPPED, where the renderer never sees the bytes and main is the
// single answerer. See docs/reference/terminal-query-authority.md.
this.onQueryReply = opts.onQueryReply ?? null
if (this.onQueryReply) {
this.terminal.onData((reply) => this.emitQueryReply(reply))
}
}
write(data: string): Promise<void> {
/** Main-side twin of the renderer's terminal-capability-replies.ts:
* ConPTY 1.22+ blocks at spawn waiting for a DA1 reply, and the override
* variant (`CSI ?61;4c`) must win. Returning true consumes the query so
* xterm core's default `?1;2c` cannot double-reply (custom CSI handlers
* run before core's; false falls through). The reply still routes through
* the forwarding window, so replayed/seeded bytes never answer. */
installConptyPrimaryDeviceAttributesOverride(): void {
// Why idempotent: the spawn mark can land after daemon stream data
// already created the emulator, so the override is installed both at
// creation and retrofitted at mark time — never stacked.
if (this.conptyDa1OverrideInstalled) {
return
}
this.conptyDa1OverrideInstalled = true
this.terminal.parser.registerCsiHandler({ final: 'c' }, (params) => {
const isPrimaryQuery = params.length === 0 || (params.length === 1 && params[0] === 0)
if (!isPrimaryQuery) {
return false
}
this.emitQueryReply(CONPTY_DA1_RESPONSE)
return true
})
}
/** Phase-5 slice-2 view-attribute bridge: the headless core has no theme
* service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the
* renderer's pushed attributes via these parser handlers — never from
* emulator defaults. Runtime-only, like onQueryReply: the daemon Session
* must NEVER call this (its emulator stays write-only forever). */
installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void {
if (this.viewAttributeResponder) {
return
}
this.viewAttributeResponder = installTerminalViewAttributeResponder({
parser: this.terminal.parser,
getBaseAttributes,
// emitQueryReply keeps replies inside the per-chunk forwarding window,
// so seeded/replayed view-attribute queries answer no one.
emitReply: (reply) => this.emitQueryReply(reply)
})
}
/** Applies a renderer view-attribute push: cursor options make xterm core
* answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC
* color overrides are dropped because a theme apply overwrites mutated
* colors on visible panes too (ThemeService._setTheme parity). Option
* writes happen outside any forwarding window, so any core emission they
* trigger is discarded (main-side replay guard). */
applyPushedViewAttributes(attributes: TerminalViewAttributes): void {
if (this.disposed) {
return
}
this.terminal.options.cursorStyle = attributes.cursorStyle
this.terminal.options.cursorBlink = attributes.cursorBlink
this.viewAttributeResponder?.clearColorOverrides()
}
/** Re-seed parity for snapshot `modes.kittyKeyboardFlags`
* (terminal-query-authority.md §kitty): replays the persisted flags
* through the same `CSI = flags ; 1 u` parse a live push uses, so hidden
* `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write —
* outside any forwarding window, it can never answer anything — and never
* into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty
* reset stays authoritative). */
applyKittyKeyboardFlags(flags: number): Promise<void> {
if (!Number.isInteger(flags) || flags <= 0) {
return Promise.resolve()
}
return this.write(`\x1b[=${flags};1u`)
}
private emitQueryReply(reply: string): void {
if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) {
this.onQueryReply(reply)
}
}
/** Severs the reply sink at PTY teardown. Queued writeChain links may
* still parse after dispose is requested, and daemon respawns reuse
* session ids — a late reply must never reach a successor PTY. */
disableQueryReplyForwarding(): void {
this.onQueryReply = null
}
write(data: string, opts: HeadlessEmulatorWriteOptions = {}): Promise<void> {
if (this.disposed) {
return Promise.resolve()
}
if (this.tryWriteSync(data)) {
const forwardQueryReplies = opts.forwardQueryReplies === true
if (this.tryWriteSync(data, { forwardQueryReplies })) {
return Promise.resolve()
}
this.scanInputForOscState(data)
this.oscText.scan(data)
// Why the sentinel: xterm parses queued writes asynchronously, so opening
// the window at enqueue time would leak it over earlier queued unflagged
// chunks (seed/hydration bytes parsing while depth > 0). Write callbacks
// fire in FIFO parse order, so a zero-byte write whose callback opens the
// window brackets the parse of exactly this chunk; the data callback
// closes it.
if (forwardQueryReplies) {
this.terminal.write('', () => {
this.queryReplyForwardingDepth += 1
})
}
return new Promise<void>((resolve) => {
this.terminal.write(data, () => {
if (forwardQueryReplies) {
this.queryReplyForwardingDepth -= 1
}
// Why: snapshots combine serialized xterm state with mirrored mouse
// modes. Commit the mirror only after xterm has parsed the same bytes.
this.scanPrivateModes(data)
this.mouseModes.scan(data)
resolve()
})
})
@@ -105,27 +238,27 @@ export class HeadlessEmulator {
return this.tryWriteSync(data)
}
private tryWriteSync(data: string): boolean {
private tryWriteSync(data: string, opts: HeadlessEmulatorWriteOptions = {}): boolean {
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
if (typeof writeSync !== 'function') {
return false
}
this.scanInputForOscState(data)
this.oscText.scan(data)
const forwardQueryReplies = opts.forwardQueryReplies === true
if (forwardQueryReplies) {
this.queryReplyForwardingDepth += 1
}
// Why: hidden renderer restore snapshots are requested immediately after
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
this.scanPrivateModes(data)
return true
}
private scanInputForOscState(data: string): void {
const oscInput = this.oscScanTail + data
this.oscScanTail = this.extractOscScanTail(oscInput)
this.scanOsc7(oscInput)
const lastTitle = extractLastOscTitle(oscInput)
if (lastTitle !== null) {
this.lastTitle = lastTitle
try {
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
} finally {
if (forwardQueryReplies) {
this.queryReplyForwardingDepth -= 1
}
}
this.mouseModes.scan(data)
return true
}
resize(cols: number, rows: number): void {
@@ -159,12 +292,12 @@ export class HeadlessEmulator {
this.restoredOscLinks
),
rehydrateSequences: this.buildRehydrateSequences(modes),
cwd: this.cwd,
cwd: this.oscText.cwd,
modes,
cols: this.terminal.cols,
rows: this.terminal.rows,
scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows,
lastTitle: this.lastTitle ?? undefined
lastTitle: this.oscText.lastTitle ?? undefined
}
}
@@ -182,15 +315,15 @@ export class HeadlessEmulator {
}
getCwd(): string | null {
return this.cwd
return this.oscText.cwd
}
setCwd(cwd: string | null): void {
this.cwd = cwd
this.oscText.cwd = cwd
}
setLastTitle(title: string): void {
this.lastTitle = title
this.oscText.lastTitle = title
}
setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void {
@@ -207,88 +340,6 @@ export class HeadlessEmulator {
this.terminal.dispose()
}
private scanOsc7(data: string): void {
scanOsc7Uris(data, (uri) => {
this.parseOsc7Uri(uri)
})
}
private extractOscScanTail(input: string): string {
return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT)
}
private scanPrivateModes(data: string): void {
const input = this.privateModeScanTail + data
this.privateModeScanTail = this.extractPrivateModeScanTail(input)
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
let match: RegExpExecArray | null
while ((match = privateModeRe.exec(input)) !== null) {
if (match[0] === '\x1bc') {
this.mouseTrackingMode = 'none'
this.sgrMouseMode = false
this.sgrMousePixelsMode = false
continue
}
const params = match[1] ?? match[3]
const enabled = (match[2] ?? match[4]) === 'h'
for (const rawParam of params.split(';')) {
if (rawParam === '') {
continue
}
const param = Number(rawParam)
if (!Number.isInteger(param)) {
continue
}
if (param === 9) {
this.mouseTrackingMode = enabled ? 'x10' : 'none'
}
if (param === 1000) {
this.mouseTrackingMode = enabled ? 'vt200' : 'none'
}
if (param === 1002) {
this.mouseTrackingMode = enabled ? 'drag' : 'none'
}
if (param === 1003) {
this.mouseTrackingMode = enabled ? 'any' : 'none'
}
if (param === 1006) {
this.sgrMouseMode = enabled
this.sgrMousePixelsMode = false
}
if (param === 1016) {
this.sgrMouseMode = false
this.sgrMousePixelsMode = enabled
}
}
}
}
private extractPrivateModeScanTail(input: string): string {
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
if (start === -1) {
return ''
}
const tail = input.slice(start)
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
return ''
}
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
return tail
}
if (tail.startsWith('\x1b[?')) {
return this.isIncompletePrivateModeParams(tail.slice(3)) ? tail : ''
}
if (tail.startsWith('\x9b?')) {
return this.isIncompletePrivateModeParams(tail.slice(2)) ? tail : ''
}
return ''
}
private isIncompletePrivateModeParams(params: string): boolean {
return /^[0-9;]*$/.test(params)
}
private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string {
if (!modes.alternateScreen) {
return snapshotAnsi
@@ -304,29 +355,34 @@ export class HeadlessEmulator {
return snapshotAnsi.slice(start + alternateScreenMarker.length)
}
private parseOsc7Uri(uri: string): void {
const parsed = parseFileUriPath(uri)
if (parsed) {
this.cwd = parsed
}
}
private getModes(): TerminalModes {
const buffer = this.terminal.buffer.active
const mouseTrackingMode = this.mouseTrackingMode
const mouseTrackingMode = this.mouseModes.mouseTrackingMode
return {
bracketedPaste: this.terminal.modes.bracketedPasteMode,
mouseTracking: mouseTrackingMode !== 'none',
mouseTrackingMode,
sgrMouseMode: this.sgrMouseMode,
sgrMousePixelsMode: this.sgrMousePixelsMode,
sgrMouseMode: this.mouseModes.sgrMouseMode,
sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode,
applicationCursor:
buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false,
alternateScreen: buffer.type === 'alternate'
alternateScreen: buffer.type === 'alternate',
kittyKeyboardFlags: this.getKittyKeyboardFlags()
}
}
private getKittyKeyboardFlags(): number {
const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard
?.flags
return typeof flags === 'number' ? flags : 0
}
private buildRehydrateSequences(modes: TerminalModes): string {
// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and
// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C
// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for
// emulator re-seed parity only; a re-seeded emulator answers ?0u and
// protocol-conformant programs re-push.
const seqs: string[] = []
if (modes.alternateScreen) {
seqs.push('\x1b[?1049h')
+18
View File
@@ -132,6 +132,24 @@ export class HistoryManager {
})
}
// Why: wake after sleep re-spawns a session whose history was closed by the
// sleep-time kill. Re-register the writer without deleting checkpoint.json
// (still the only recovery data until the next tick) and clear endedAt so
// the next sleep can cold-restore this session again.
reopenSession(sessionId: string): void {
this.disabledSessions.delete(sessionId)
this.registerWriter(sessionId)
const writer = this.writers.get(sessionId)
if (!writer) {
return
}
try {
this.updateMeta(writer.dir, { endedAt: null, exitCode: null })
} catch (err) {
this.handleWriteError(sessionId, err)
}
}
/** Appends one take batch to the incremental log. Returns 'needs-checkpoint'
* when the log is at capacity — the caller must take a full snapshot, which
* subsumes the un-appended records (they were already applied to the live
+13 -5
View File
@@ -166,14 +166,22 @@ describe('Session', () => {
describe('emulator does not reply to terminal queries', () => {
// Why: daemon emulator parses in-process synchronously — before
// handleSubprocessData forwards bytes to the renderer over IPC — so any
// auto-reply it emits races ahead of the renderer's xterm and clobbers
// it with default-xterm values (no theme, stale cursor). The renderer is
// the authoritative responder; a daemon-side reply to any query is a bug.
// handleSubprocessData forwards bytes onward — so any auto-reply it
// emits races ahead of the live answerer and clobbers it with
// default-xterm values (no theme, stale cursor). Query authority is
// structural (terminal-query-authority.md): a delivered chunk is
// answered by the consuming view's xterm, a hidden-dropped chunk by
// MAIN's runtime model responder. The daemon emulator is neither — it
// stays write-only forever, and these pins are permanent.
it.each([
['OSC 10 foreground-color', '\x1b]10;?\x07'],
['OSC 11 background-color', '\x1b]11;?\x07'],
['OSC 12 cursor-color', '\x1b]12;?\x1b\\'],
['DA1 device-attributes', '\x1b[c'],
['DSR cursor-position', '\x1b[6n']
['DA2 secondary device-attributes', '\x1b[>c'],
['DSR terminal status', '\x1b[5n'],
['DSR cursor-position', '\x1b[6n'],
['DECRPM bracketed-paste mode', '\x1b[?2004$p']
])('does not reply to %s query', async (_label, query) => {
createSession({ shellReadySupported: false })
subprocess.simulateData(query)
@@ -0,0 +1,104 @@
import type { TerminalModes } from './types'
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
/**
* Mirrors DECSET mouse-protocol/encoding state from the raw byte stream.
* xterm's public modes API does not expose which mouse protocol is active,
* so snapshots track it independently of the headless terminal; callers
* must feed `scan()` the same bytes the terminal parsed, in order.
*/
export class TerminalMouseModeMirror {
private scanTail = ''
private trackingModeState: MouseTrackingMode = 'none'
private sgrMouseModeState = false
private sgrMousePixelsModeState = false
get mouseTrackingMode(): MouseTrackingMode {
return this.trackingModeState
}
get sgrMouseMode(): boolean {
return this.sgrMouseModeState
}
get sgrMousePixelsMode(): boolean {
return this.sgrMousePixelsModeState
}
scan(data: string): void {
const input = this.scanTail + data
this.scanTail = this.extractScanTail(input)
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
let match: RegExpExecArray | null
while ((match = privateModeRe.exec(input)) !== null) {
if (match[0] === '\x1bc') {
this.trackingModeState = 'none'
this.sgrMouseModeState = false
this.sgrMousePixelsModeState = false
continue
}
const params = match[1] ?? match[3]
const enabled = (match[2] ?? match[4]) === 'h'
for (const rawParam of params.split(';')) {
if (rawParam === '') {
continue
}
const param = Number(rawParam)
if (!Number.isInteger(param)) {
continue
}
if (param === 9) {
this.trackingModeState = enabled ? 'x10' : 'none'
}
if (param === 1000) {
this.trackingModeState = enabled ? 'vt200' : 'none'
}
if (param === 1002) {
this.trackingModeState = enabled ? 'drag' : 'none'
}
if (param === 1003) {
this.trackingModeState = enabled ? 'any' : 'none'
}
if (param === 1006) {
this.sgrMouseModeState = enabled
this.sgrMousePixelsModeState = false
}
if (param === 1016) {
this.sgrMouseModeState = false
this.sgrMousePixelsModeState = enabled
}
}
}
}
private extractScanTail(input: string): string {
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
if (start === -1) {
return ''
}
const tail = input.slice(start)
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
return ''
}
if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
return tail
}
if (tail.startsWith('\x1b[?')) {
return this.isIncompleteParams(tail.slice(3)) ? tail : ''
}
if (tail.startsWith('\x9b?')) {
return this.isIncompleteParams(tail.slice(2)) ? tail : ''
}
return ''
}
private isIncompleteParams(params: string): boolean {
return /^[0-9;]*$/.test(params)
}
}
@@ -0,0 +1,30 @@
import { extractLastOscTitle } from '../../shared/agent-detection'
import { parseFileUriPath } from './osc7-file-uri'
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
const OSC_SCAN_TAIL_LIMIT = 4096
/** Mirror of the OSC sequences the emulator tracks outside xterm: OSC 7 cwd
* updates and OSC 0/2 titles. Keeps an unterminated-sequence tail so
* sequences split across PTY chunks still parse. Uses the bounded regex-free
* scanners so giant pasted chunks stay cheap. */
export class TerminalOscCwdTitleScanner {
private scanTail = ''
cwd: string | null = null
lastTitle: string | null = null
scan(data: string): void {
const input = this.scanTail + data
this.scanTail = extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT)
scanOsc7Uris(input, (uri) => {
const parsed = parseFileUriPath(uri)
if (parsed) {
this.cwd = parsed
}
})
const lastTitle = extractLastOscTitle(input)
if (lastTitle !== null) {
this.lastTitle = lastTitle
}
}
}
@@ -0,0 +1,191 @@
/**
* Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute
* bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime
* headless emulator. The headless xterm core has no theme service, so these
* handlers compute replies from the renderer's pushed attribute snapshot,
* with per-PTY OSC SET mutations layered on top — mirroring exactly what the
* renderer's ThemeService reports for a visible pane. Replies route through
* the caller's emit sink, which the slice-1 forwarding window already gates,
* so seeded/replayed bytes and delivered chunks never produce a reply.
*/
import type { Terminal } from '@xterm/headless'
import {
formatXColorRgbSpec,
parseXColorSpec,
TERMINAL_VIEW_ANSI_COLOR_COUNT,
type TerminalViewAttributes,
type TerminalViewRgb
} from '../../shared/terminal-view-attributes'
type ViewAttributeParser = Pick<Terminal['parser'], 'registerOscHandler' | 'registerCsiHandler'>
export type TerminalViewAttributeResponderDeps = {
parser: ViewAttributeParser
/** Last renderer push, or null before the first push. Null means SILENCE
* for every view-attribute query — a fabricated default would resurrect
* the default-black OSC-11 bug (design invariant 3). */
getBaseAttributes: () => TerminalViewAttributes | null
/** Must already be replay/forwarding-window gated by the caller. */
emitReply: (reply: string) => void
}
export type TerminalViewAttributeResponder = {
/** A changed renderer attribute push replaces the whole palette, exactly
* like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors
* on a visible pane's theme apply. Identical re-pushes (fresh renderer
* process) are filtered in main's store and never reach this. */
clearColorOverrides: () => void
}
type SpecialColorSlot = 'foreground' | 'background' | 'cursor'
// OSC 10/11/12 stack extra params onto consecutive slots (xterm's
// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background.
const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor']
const SPECIAL_COLOR_IDENTS: Record<SpecialColorSlot, string> = {
foreground: '10',
background: '11',
cursor: '12'
}
function isValidColorIndex(value: number): boolean {
return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT
}
// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) —
// the math CoreBrowserTerminal._reportColorScheme answers ?996n with.
function relativeLuminance([r, g, b]: TerminalViewRgb): number {
const linear = (channel: number): number => {
const c = channel / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722
}
export function installTerminalViewAttributeResponder(
deps: TerminalViewAttributeResponderDeps
): TerminalViewAttributeResponder {
// Why per-instance maps: SET mutations are per PTY (one emulator per PTY);
// they die with the emulator at teardown, like every other model state.
// They deliberately survive a reveal→re-hide cycle even though the revealed
// xterm restores without palette mutations (SerializeAddon emits no OSC
// color SETs): the TUI never reset its SET, so holding it is
// protocol-correct — the visible-side loss is the pre-existing restore
// limitation, not this model's.
const ansiOverrides = new Map<number, TerminalViewRgb>()
const specialOverrides = new Map<SpecialColorSlot, TerminalViewRgb>()
const reportColor = (ident: string, rgb: TerminalViewRgb): void => {
// Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the
// renderer xterm's reply (CoreBrowserTerminal._handleColorEvent).
deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`)
}
const handleSpecialColor = (data: string, offset: number): boolean => {
const slots = data.split(';')
for (let i = 0; i < slots.length; ++i, ++offset) {
if (offset >= SPECIAL_COLOR_SLOTS.length) {
break
}
const slot = SPECIAL_COLOR_SLOTS[offset]
if (slots[i] === '?') {
const base = deps.getBaseAttributes()
if (base) {
reportColor(SPECIAL_COLOR_IDENTS[slot], specialOverrides.get(slot) ?? base[slot])
}
} else {
const rgb = parseXColorSpec(slots[i])
if (rgb) {
specialOverrides.set(slot, rgb)
}
}
}
// True consumes the sequence; the headless core's own OSC 10/11/12
// handler only fires an onColor event nothing consumes.
return true
}
deps.parser.registerOscHandler(4, (data) => {
const slots = data.split(';')
while (slots.length > 1) {
const idx = slots.shift() as string
const spec = slots.shift() as string
if (!/^\d+$/.exec(idx)) {
continue
}
const index = parseInt(idx, 10)
if (!isValidColorIndex(index)) {
continue
}
if (spec === '?') {
const base = deps.getBaseAttributes()
if (base) {
reportColor(`4;${index}`, ansiOverrides.get(index) ?? base.ansi[index])
}
} else {
const rgb = parseXColorSpec(spec)
if (rgb) {
ansiOverrides.set(index, rgb)
}
}
}
return true
})
deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0))
deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1))
deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2))
// OSC 104/110/111/112 restore the themed color — dropping the override
// falls back to the pushed base, the model twin of ThemeService.restoreColor.
deps.parser.registerOscHandler(104, (data) => {
if (!data) {
ansiOverrides.clear()
return true
}
for (const slot of data.split(';')) {
if (/^\d+$/.exec(slot)) {
ansiOverrides.delete(parseInt(slot, 10))
}
}
return true
})
deps.parser.registerOscHandler(110, () => {
specialOverrides.delete('foreground')
return true
})
deps.parser.registerOscHandler(111, () => {
specialOverrides.delete('background')
return true
})
deps.parser.registerOscHandler(112, () => {
specialOverrides.delete('cursor')
return true
})
deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => {
if (params[0] !== 996) {
// Fall through to the core for every other private DSR (?6n CPR etc.).
return false
}
const base = deps.getBaseAttributes()
if (base) {
// Why luminance and not base.colorSchemeMode: a visible xterm answers
// ?996n from the relative luminance of the CURRENT (OSC-SET-mutated)
// background vs foreground (CoreBrowserTerminal._reportColorScheme),
// so a dark terminal theme in a light app mode still answers dark.
// colorSchemeMode is the app mode and feeds the 2031/997 path only.
const background = specialOverrides.get('background') ?? base.background
const foreground = specialOverrides.get('foreground') ?? base.foreground
const dark = relativeLuminance(background) < relativeLuminance(foreground)
deps.emitReply(`\x1b[?997;${dark ? 1 : 2}n`)
}
return true
})
return {
clearColorOverrides: () => {
ansiOverrides.clear()
specialOverrides.clear()
}
}
}
+9
View File
@@ -41,6 +41,15 @@ export type TerminalModes = {
sgrMousePixelsMode?: boolean
applicationCursor: boolean
alternateScreen: boolean
/** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed
* parity ONLY. Consumed by the daemon warm-reattach path: the spawn
* result threads them into seedHeadlessTerminal, which re-applies them to
* the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags)
* so hidden `CSI ? u` answers the real flags instead of ?0u.
* rehydrateSequences must never push these into a renderer xterm —
* POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative
* (terminal-query-authority.md §kitty). */
kittyKeyboardFlags?: number
}
// The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it
+27 -1
View File
@@ -158,6 +158,7 @@ import {
type SyntheticTitleSpinnerEntry
} from './synthetic-title-spinner'
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
import { isCrashReportReason } from '../shared/crash-reporting'
import {
getSyntheticAgentTitleProfile,
@@ -166,6 +167,7 @@ import {
} from '../shared/synthetic-agent-title'
import type { AgentStatusState } from '../shared/agent-status-types'
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
import { KeybindingService } from './keybindings/keybinding-service'
import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
@@ -1382,7 +1384,17 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool
) {
return
}
mainWindow.webContents.send('pty:data', { id: ptyId, data })
// Why: feed the per-PTY tracker directly (never onPtyData — emulator state,
// tails, transcripts, and stats must not see fabricated bytes) so synthetic
// titles/BELs reach pty:sideEffect consumers when main holds side-effect
// authority.
runtime?.ingestSyntheticTitleFrame(ptyId, data)
// Why: only the kill-switch-off renderer still byte-parses synthetic frames;
// under main authority the copy would just mint phantom ACKs for unmetered
// bytes (see synthetic-title-frame-routing.ts).
if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) {
mainWindow.webContents.send('pty:data', { id: ptyId, data })
}
}
function isSyntheticTitleWindowVisible(): boolean {
@@ -1656,6 +1668,20 @@ app.whenReady().then(async () => {
onTerminalAgentStatus: (event) => {
agentHookServer.ingestTerminalStatus(event)
},
// Why: derived title/bell/agent facts ride one batched main→renderer
// channel (terminal-side-effect-authority.md). The renderer's authority
// kill switch decides whether to consume. Headless serve never creates a
// window, so the dep is omitted entirely — the runtime then skips fact
// batch construction and the per-chunk bell walk.
...(isServeMode
? {}
: {
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:sideEffect', batch)
}
}
}),
// Why: hook-reported agent status is the same source the desktop sidebar
// reads. worktree.ps pulls it at query time so mobile shows the same agents.
getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(),
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
_resetHiddenRendererPtyDeliveryGateForTest,
clearHiddenRendererPtyDeliveryState,
getHiddenRendererPtyDeliveryDebug,
isHiddenPtyDeliveryGateEnabled,
markHiddenRendererPty,
recordHiddenRendererPtyDataDrop,
resetRendererScopedHiddenPtyDeliveryState,
setRendererPtyDeliveryInterest,
shouldDropHiddenRendererPtyData,
unmarkHiddenRendererPty
} from './pty-hidden-delivery-gate'
const PTY_ID = 'pty-1'
describe('pty hidden delivery gate', () => {
beforeEach(() => {
_resetHiddenRendererPtyDeliveryGateForTest()
})
it('only operates when both kill switches are on (default on)', () => {
expect(isHiddenPtyDeliveryGateEnabled(undefined)).toBe(true)
expect(isHiddenPtyDeliveryGateEnabled({})).toBe(true)
expect(isHiddenPtyDeliveryGateEnabled({ terminalHiddenDeliveryGate: false })).toBe(false)
expect(isHiddenPtyDeliveryGateEnabled({ terminalMainSideEffectAuthority: false })).toBe(false)
})
it('drops only hidden PTYs without registered delivery interest', () => {
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
markHiddenRendererPty(PTY_ID)
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true)
expect(shouldDropHiddenRendererPtyData(PTY_ID, { terminalHiddenDeliveryGate: false })).toBe(
false
)
setRendererPtyDeliveryInterest(PTY_ID, true)
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
setRendererPtyDeliveryInterest(PTY_ID, false)
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true)
})
it('requests the restore marker exactly once per drop episode, re-armed by unmark', () => {
markHiddenRendererPty(PTY_ID)
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true)
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(false)
// Why: unmark consumes the latch (and re-emits via its own return value);
// the next hidden period's first drop reports again.
unmarkHiddenRendererPty(PTY_ID)
markHiddenRendererPty(PTY_ID)
expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true)
})
it('keeps drop memory when an already-dropped PTY is re-marked hidden', () => {
// Why: a hidden remount or renderer reload re-marks without an unhide in
// between — clearing the latch there would make reveal skip the restore.
markHiddenRendererPty(PTY_ID)
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
markHiddenRendererPty(PTY_ID)
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
})
it('reports drops on unhide so reveal can heal a replaced renderer view', () => {
markHiddenRendererPty(PTY_ID)
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(false)
markHiddenRendererPty(PTY_ID)
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
})
it('clears renderer-scoped state on reload while preserving drop memory', () => {
markHiddenRendererPty(PTY_ID)
recordHiddenRendererPtyDataDrop(PTY_ID, 10)
setRendererPtyDeliveryInterest('pty-2', true)
markHiddenRendererPty('pty-2')
resetRendererScopedHiddenPtyDeliveryState()
// Hidden marks and interest holds died with the old renderer process.
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({
hiddenDeliveryGatedPtyCount: 0,
deliveryInterestPtyCount: 0
})
// pty-2's leaked interest is gone: re-marking gates it again.
markHiddenRendererPty('pty-2')
expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(true)
// Drop memory survives so the new renderer's first unhide still restores.
markHiddenRendererPty(PTY_ID)
expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true)
})
it('clears all per-PTY state on teardown and tracks debug counters', () => {
markHiddenRendererPty(PTY_ID)
setRendererPtyDeliveryInterest('pty-2', true)
recordHiddenRendererPtyDataDrop(PTY_ID, 7)
recordHiddenRendererPtyDataDrop(PTY_ID, 5)
expect(getHiddenRendererPtyDeliveryDebug()).toEqual({
hiddenDeliveryGatedPtyCount: 1,
deliveryInterestPtyCount: 1,
hiddenDeliveryDroppedChars: 12,
hiddenDeliveryDroppedChunks: 2
})
clearHiddenRendererPtyDeliveryState(PTY_ID)
clearHiddenRendererPtyDeliveryState('pty-2')
expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({
hiddenDeliveryGatedPtyCount: 0,
deliveryInterestPtyCount: 0
})
expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false)
})
})
+148
View File
@@ -0,0 +1,148 @@
/**
* Main-side hidden-delivery gate for renderer PTY byte delivery (Phase 4 of
* the terminal model/view architecture).
*
* The renderer marks a PTY hidden when no visible view consumes its bytes;
* main then drops renderer-bound delivery AFTER model ingestion — the runtime
* already parsed the chunk, and reveal restores from the model snapshot via
* the existing seq-guarded machinery. Any renderer party that still needs raw
* bytes (dispatcher sidecars, eager pre-mount buffers) registers delivery
* interest, which suppresses the gate for that PTY.
* See docs/reference/terminal-side-effect-authority.md (Open Items).
*/
import type { GlobalSettings } from '../../shared/types'
export type HiddenPtyDeliveryGateSettings = Pick<
GlobalSettings,
'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate'
>
const hiddenRendererPtys = new Set<string>()
// Why: sidecar consumers (paste-draft pacing, background agent launches,
// automation observers, the kill-switch-off parked 2031 responder) and eager
// pre-mount buffers need live bytes even while no visible view exists. Any
// registered interest suppresses the gate for that PTY.
const deliveryInterestRendererPtys = new Set<string>()
// Why: reveal must restore from the model only when bytes were actually
// dropped. Doubles as the one-shot marker latch: the first gated drop emits a
// restore marker, and the latch is consumed only by unmark (which re-emits)
// or full PTY teardown — never by re-marking hidden, so drop memory survives
// hidden remounts and renderer reloads.
const droppedSinceHiddenPtys = new Set<string>()
let droppedHiddenDeliveryChars = 0
let droppedHiddenDeliveryChunks = 0
/** Gate kill switches, both read main-side: the gate only operates under main
* side-effect authority AND the gate-specific setting (both default on). */
export function isHiddenPtyDeliveryGateEnabled(
settings: HiddenPtyDeliveryGateSettings | null | undefined
): boolean {
return (
settings?.terminalMainSideEffectAuthority !== false &&
settings?.terminalHiddenDeliveryGate !== false
)
}
/** Renderer-reported "no visible view needs bytes" bit. Never clears drop
* memory: a hidden remount or renderer reload re-marks an already-dropped
* PTY, and erasing the latch there would make the eventual reveal skip the
* restore. Unmark is the only consumer of the latch. */
export function markHiddenRendererPty(id: string): void {
hiddenRendererPtys.add(id)
}
/** Clears the hidden bit. Returns whether bytes were dropped while hidden so
* the caller can emit a restore marker to the now-visible renderer. */
export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean } {
hiddenRendererPtys.delete(id)
const droppedWhileHidden = droppedSinceHiddenPtys.delete(id)
return { droppedWhileHidden }
}
export function isHiddenRendererPty(id: string): boolean {
return hiddenRendererPtys.has(id)
}
/** Renderer-side ref-counted interest, surfaced as boolean transitions. */
export function setRendererPtyDeliveryInterest(id: string, interested: boolean): void {
if (interested) {
deliveryInterestRendererPtys.add(id)
} else {
deliveryInterestRendererPtys.delete(id)
}
}
export function shouldDropHiddenRendererPtyData(
id: string,
settings: HiddenPtyDeliveryGateSettings | null | undefined
): boolean {
return (
isHiddenPtyDeliveryGateEnabled(settings) &&
hiddenRendererPtys.has(id) &&
!deliveryInterestRendererPtys.has(id)
)
}
/** Record one gated drop. Returns whether the caller should emit the one-shot
* empty restore-marker chunk (first drop since this PTY went hidden). */
export function recordHiddenRendererPtyDataDrop(
id: string,
chars: number
): { shouldEmitRestoreMarker: boolean } {
droppedHiddenDeliveryChars += chars
droppedHiddenDeliveryChunks += 1
if (droppedSinceHiddenPtys.has(id)) {
return { shouldEmitRestoreMarker: false }
}
droppedSinceHiddenPtys.add(id)
return { shouldEmitRestoreMarker: true }
}
/** Renderer process replaced (reload / crash): its ref-counted interest
* holds and hidden marks died with it, so keeping them would gate (or
* force-feed) PTYs no live renderer party asked about. Drop memory is
* preserved — surviving daemon/SSH PTYs may have dropped bytes the old
* renderer never restored; the new renderer's first hidden/visible sync
* re-marks or unmarks and the unmark path re-emits the restore marker. */
export function resetRendererScopedHiddenPtyDeliveryState(): void {
hiddenRendererPtys.clear()
deliveryInterestRendererPtys.clear()
}
/** Full per-PTY teardown — wired into clearProviderPtyState so every exit
* path (local, daemon, SSH, connection teardown) releases gate state. */
export function clearHiddenRendererPtyDeliveryState(id: string): void {
hiddenRendererPtys.delete(id)
deliveryInterestRendererPtys.delete(id)
droppedSinceHiddenPtys.delete(id)
}
export type HiddenRendererPtyDeliveryDebug = {
hiddenDeliveryGatedPtyCount: number
deliveryInterestPtyCount: number
hiddenDeliveryDroppedChars: number
hiddenDeliveryDroppedChunks: number
}
export function getHiddenRendererPtyDeliveryDebug(): HiddenRendererPtyDeliveryDebug {
return {
hiddenDeliveryGatedPtyCount: hiddenRendererPtys.size,
deliveryInterestPtyCount: deliveryInterestRendererPtys.size,
hiddenDeliveryDroppedChars: droppedHiddenDeliveryChars,
hiddenDeliveryDroppedChunks: droppedHiddenDeliveryChunks
}
}
export function resetHiddenRendererPtyDeliveryDebugCounters(): void {
droppedHiddenDeliveryChars = 0
droppedHiddenDeliveryChunks = 0
}
/** Test seam: reset all module state between tests. */
export function _resetHiddenRendererPtyDeliveryGateForTest(): void {
hiddenRendererPtys.clear()
deliveryInterestRendererPtys.clear()
droppedSinceHiddenPtys.clear()
resetHiddenRendererPtyDeliveryDebugCounters()
}
+843 -16
View File
File diff suppressed because it is too large Load Diff
+321 -4
View File
@@ -87,6 +87,26 @@ import {
import { parseWslPath } from '../wsl'
import { mergePersistedWindowsPath } from '../pty/windows-environment-path'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import {
clearHiddenRendererPtyDeliveryState,
getHiddenRendererPtyDeliveryDebug,
isHiddenPtyDeliveryGateEnabled,
markHiddenRendererPty,
recordHiddenRendererPtyDataDrop,
resetHiddenRendererPtyDeliveryDebugCounters,
resetRendererScopedHiddenPtyDeliveryState,
setRendererPtyDeliveryInterest,
shouldDropHiddenRendererPtyData,
unmarkHiddenRendererPty
} from './pty-hidden-delivery-gate'
import {
clearNativeWindowsConptyPty,
isNativeWindowsLocalPtySpawn,
markNativeWindowsConptyPty
} from '../runtime/terminal-model-query-authority'
import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store'
import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes'
import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
@@ -1045,6 +1065,12 @@ export function clearProviderPtyState(id: string): void {
pendingHiddenRendererResizeOutputPtys.delete(id)
deliveredHiddenRendererResizeOutputPtys.delete(id)
clearStartupTerminalColorQueryReplies(id)
// Why: every PTY teardown path funnels through here (local exit, daemon
// shutdown, SSH exit/connection teardown) — hidden/interest gate bits must
// not outlive the PTY or a reused map entry could silently gate a new one.
clearHiddenRendererPtyDeliveryState(id)
// Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id.
clearNativeWindowsConptyPty(id)
const paneKey = ptyPaneKey.get(id)
const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false
// Why: drop the memory-collector registration so a dead PTY does not keep
@@ -1124,6 +1150,14 @@ let didFinishLoadHandler: (() => void) | null = null
let didFinishLoadWebContents: WebContents | null = null
let rendererLifecycleResetWebContents: WebContents | null = null
let rendererLifecycleResetHandler: (() => void) | null = null
// Why: the hidden-delivery gate's interest/hidden registries mirror renderer
// state (ref-counted holds, per-pane hidden marks). A reload or renderer
// crash destroys the owners without unregistering, so the registries are
// reset whenever the renderer process is replaced
// (resetRendererScopedHiddenPtyDeliveryState preserves drop memory).
let rendererGateResetLoadHandler: (() => void) | null = null
let rendererGateResetGoneHandler: (() => void) | null = null
let rendererGateResetWebContents: WebContents | null = null
// Why: the "Restart daemon" path needs to re-bind provider→renderer listeners
// against the freshly-created adapter after replaceDaemonProvider swaps the
@@ -1151,6 +1185,11 @@ export type PtyRendererDeliveryDebugSnapshot = {
peakRendererInFlightChars: number
peakMaxRendererInFlightCharsByPty: number
ackGatedFlushSkipCount: number
hiddenDeliveryGatedPtyCount: number
deliveryInterestPtyCount: number
hiddenDeliveryDroppedChars: number
hiddenDeliveryDroppedChunks: number
pendingDroppedChars: number
}
const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = {
@@ -1166,7 +1205,12 @@ const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapsh
peakMaxPendingCharsByPty: 0,
peakRendererInFlightChars: 0,
peakMaxRendererInFlightCharsByPty: 0,
ackGatedFlushSkipCount: 0
ackGatedFlushSkipCount: 0,
hiddenDeliveryGatedPtyCount: 0,
deliveryInterestPtyCount: 0,
hiddenDeliveryDroppedChars: 0,
hiddenDeliveryDroppedChunks: 0,
pendingDroppedChars: 0
}
let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({
@@ -1226,6 +1270,23 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void
webContents.on('destroyed', rendererLifecycleResetHandler)
}
function clearRendererGateResetHandlers(): void {
if (rendererGateResetWebContents) {
if (rendererGateResetLoadHandler) {
rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler)
}
if (rendererGateResetGoneHandler) {
rendererGateResetWebContents.removeListener(
'render-process-gone',
rendererGateResetGoneHandler
)
}
}
rendererGateResetLoadHandler = null
rendererGateResetGoneHandler = null
rendererGateResetWebContents = null
}
// Why: the "Restart daemon" flow needs to detach listeners from the current
// adapter *after* synthetic pty:exit events fan out (so the renderer receives
// them) but *before* replaceDaemonProvider swaps in the new adapter (so the
@@ -1277,6 +1338,7 @@ export function registerPtyHandlers(
ipcMain.removeHandler('pty:settlePaneSerializer')
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
ipcMain.removeHandler('pty:getMainBufferSnapshot')
ipcMain.removeHandler('pty:sideEffectSnapshot')
ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot')
ipcMain.removeHandler('pty:resetRendererDeliveryDebug')
ipcMain.removeHandler('pty:writeAccepted')
@@ -1370,10 +1432,14 @@ export function registerPtyHandlers(
}
const pendingData = new Map<string, PendingPtyData>()
// Why: one restore marker per overflow episode — cleared when the entry
// fully drains so a later overflow re-marks the renderer exactly once.
const pendingOverflowMarkedPtys = new Set<string>()
const rendererInFlightCharsByPty = new Map<string, number>()
const trustedTerminalHandleEnv = new Set<string>()
let flushTimer: ReturnType<typeof setTimeout> | null = null
let rendererInFlightTotalChars = 0
let pendingDroppedChars = 0
const PTY_BATCH_INTERVAL_MS = 8
const PTY_BATCH_DRAIN_CONTINUE_MS = 1
const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024
@@ -1421,6 +1487,7 @@ export function registerPtyHandlers(
pendingChars += chars
maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars)
}
const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug()
return {
pendingPtyCount: pendingData.size,
pendingChars,
@@ -1434,7 +1501,9 @@ export function registerPtyHandlers(
peakMaxPendingCharsByPty,
peakRendererInFlightChars,
peakMaxRendererInFlightCharsByPty,
ackGatedFlushSkipCount
ackGatedFlushSkipCount,
...hiddenDeliveryDebug,
pendingDroppedChars
}
}
@@ -1456,6 +1525,8 @@ export function registerPtyHandlers(
peakRendererInFlightChars = 0
peakMaxRendererInFlightCharsByPty = 0
ackGatedFlushSkipCount = 0
pendingDroppedChars = 0
resetHiddenRendererPtyDeliveryDebugCounters()
recordPtyRendererDeliveryPressure()
}
@@ -1563,6 +1634,26 @@ export function registerPtyHandlers(
deliveredHiddenRendererResizeOutputPtys.delete(id)
}
// Why: when main drops renderer delivery (hidden gate / pending cap), an
// explicit out-of-band pty:modelRestoreNeeded signal tells the renderer to
// latch model-restore-needed. It must NOT ride pty:data: an in-band empty
// chunk is indistinguishable from a chunk fully consumed by renderer-side
// OSC-9999 stripping, which spuriously restored visible panes.
function sendModelRestoreNeededMarker(
id: string,
reason: PtyModelRestoreReason,
markerSeq: number | undefined
): void {
if (mainWindow.isDestroyed()) {
return
}
mainWindow.webContents.send('pty:modelRestoreNeeded', {
id,
reason,
...(typeof markerSeq === 'number' ? { markerSeq } : {})
})
}
function getPendingPtyFlushEntries(): [string, PendingPtyData][] {
const entries = Array.from(pendingData.entries())
const active: [string, PendingPtyData][] = []
@@ -1597,6 +1688,15 @@ export function registerPtyHandlers(
capChars
})
}
// Why: with the hidden-delivery gate rolled out, the model snapshot can
// recover the dropped middle — emit the out-of-band restore marker once
// per overflow episode alongside the droppedOutput sentinel so a fresh
// or reloaded view latches restore too.
if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) {
pendingOverflowMarkedPtys.add(id)
sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id))
}
pendingDroppedChars += pending.data.length
// Why empty data, not a trimmed tail: a mid-stream gap would silently
// corrupt the pane. The droppedOutput sentinel routes the pane through
// hidden-output restore, which repaints from the authoritative main-owned
@@ -1653,16 +1753,29 @@ export function registerPtyHandlers(
flushTimer = null
if (mainWindow.isDestroyed()) {
pendingData.clear()
pendingOverflowMarkedPtys.clear()
rendererInFlightCharsByPty.clear()
rendererInFlightTotalChars = 0
recordPtyRendererDeliveryPressure()
return
}
const settings = getSettings?.()
let writes = 0
for (const [id, pending] of getPendingPtyFlushEntries()) {
if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) {
break
}
// Why: hidden-gated bytes are dropped, never re-queued — the model
// already ingested them; reveal restores from the snapshot+seq machinery.
if (shouldDropHiddenRendererPtyData(id, settings)) {
pendingData.delete(id)
pendingOverflowMarkedPtys.delete(id)
const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id))
}
continue
}
if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) {
continue
}
@@ -1687,6 +1800,8 @@ export function registerPtyHandlers(
nextPending.containsBackgroundOutput = true
}
pendingData.set(id, nextPending)
} else {
pendingOverflowMarkedPtys.delete(id)
}
sendPtyDataToRenderer(
id,
@@ -1759,6 +1874,7 @@ export function registerPtyHandlers(
)
pendingData.delete(payload.id)
}
pendingOverflowMarkedPtys.delete(payload.id)
lastInputAtByPty.delete(payload.id)
interactiveOutputCharsByPty.delete(payload.id)
rendererInFlightTotalChars = Math.max(
@@ -1821,11 +1937,25 @@ export function registerPtyHandlers(
flushTimer = null
}
pendingData.clear()
pendingOverflowMarkedPtys.clear()
rendererInFlightCharsByPty.clear()
rendererInFlightTotalChars = 0
recordPtyRendererDeliveryPressure()
return
}
const settings = getSettings?.()
// Why: hidden-delivery gate — runtime ingestion above already consumed
// the chunk; gated renderer delivery is DROPPED (never queued) and the
// reveal path restores from the model snapshot via the seq guard. The
// drop sits before the interactive bypass so gated PTYs take neither
// the immediate nor the batched renderer path.
if (shouldDropHiddenRendererPtyData(payload.id, settings)) {
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
}
return
}
if (rendererData.length === 0) {
return
}
@@ -1859,6 +1989,7 @@ export function registerPtyHandlers(
return
}
pendingData.delete(payload.id)
pendingOverflowMarkedPtys.delete(payload.id)
clearFlushTimerIfIdle()
// Why: agent TUIs redraw small prompt regions after every keystroke.
// Waiting for the throughput batch timer adds visible input latency.
@@ -1981,6 +2112,19 @@ export function registerPtyHandlers(
})
}
// Why: a reload (did-finish-load) or renderer crash replaces the process
// that owned every delivery-interest hold and hidden mark; surviving
// daemon/SSH PTYs would otherwise stay force-fed (leaked interest defeats
// the gate) or stay gated against a renderer that never marked them. Drop
// memory is preserved — each pane's first sync re-marks/unmarks and the
// unmark path re-emits the restore marker for unrestored drops.
clearRendererGateResetHandlers()
rendererGateResetLoadHandler = () => resetRendererScopedHiddenPtyDeliveryState()
rendererGateResetGoneHandler = () => resetRendererScopedHiddenPtyDeliveryState()
rendererGateResetWebContents = mainWindow.webContents
mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler)
mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler)
// Kill orphaned PTY processes from previous page loads when the renderer reloads.
// Why: only applies to LocalPtyProvider where PTYs live in the Electron main
// process and can become orphaned on page reload. Daemon-backed sessions
@@ -2240,6 +2384,18 @@ export function registerPtyHandlers(
}
}
ptyOwnership.set(result.id, args.connectionId ?? null)
// Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY
// determination from the spawn record before any byte reaches the
// runtime emulator, so its DA1 override exists from byte zero.
if (
isNativeWindowsLocalPtySpawn({
connectionId: args.connectionId,
cwd: args.cwd,
shellOverride: daemonShellOverride
})
) {
markNativeWindowsConptyPty(result.id)
}
const relayResultId = getRelayPtyId(args.connectionId, result.id)
const persistSshLease = (): void => {
if (!store || !args.connectionId) {
@@ -2296,6 +2452,10 @@ export function registerPtyHandlers(
if (args.worktreeId) {
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
}
// Why: arms main's per-PTY Command Code output detector from the launch
// command (renderer startupCommand parity); banner detection covers
// PTYs spawned without one.
runtime?.noteTerminalSpawnCommand?.(result.id, args.command ?? null)
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
@@ -2536,6 +2696,7 @@ export function registerPtyHandlers(
cwd?: string | null
lastTitle?: string
seq?: number
pendingDeliveryStartSeq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null> => {
@@ -2544,13 +2705,43 @@ export function registerPtyHandlers(
}
const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows)
try {
return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows })
const snapshot = await runtime.serializeHiddenOutputRecoveryBuffer(args.id, {
scrollbackRows
})
if (!snapshot || typeof snapshot.seq !== 'number') {
return snapshot
}
// Why: sampled after serialize — every byte at or below snapshot.seq
// that can still reach the renderer sits in this pending queue. The
// renderer's post-restore dedupe bounds its duplicate window with it;
// without the bound a stale baseline silently swallows genuinely-new
// chunks whose seq domain sits below the snapshot counter.
const pending = pendingData.get(args.id)
if (pending && typeof pending.startSeq !== 'number') {
// Why: a seq-less backlog cannot be bounded — stay conservative.
return snapshot
}
return {
...snapshot,
pendingDeliveryStartSeq: Math.min(pending?.startSeq ?? snapshot.seq, snapshot.seq)
}
} catch {
return null
}
}
)
// Why: with main holding side-effect authority the renderer no longer
// derives titles from replayed bytes on (re)attach. This title-only replay
// snapshot restores title state — never historical bells/completions (the
// no-attention-replay rule, terminal-side-effect-authority.md).
ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => {
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
return null
}
return runtime.getTerminalSideEffectSnapshot(args.id)
})
ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => {
return getPtyRendererDeliveryDebugSnapshot()
})
@@ -2582,6 +2773,11 @@ export function registerPtyHandlers(
foreground?: unknown
background?: unknown
}
// Why: hidden-at-spawn declaration (terminal-query-authority.md
// §races) — the renderer knows at spawn time that no visible view
// will consume this PTY's bytes, so main marks it hidden BEFORE the
// first byte and the gate + model responder own spawn-time queries.
initiallyHidden?: boolean
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
// ptyId) binding before pty:spawn returns. Only the renderer's
@@ -2940,6 +3136,20 @@ export function registerPtyHandlers(
return await existingPaneSpawn.promise
}
const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null
const initiallyHidden = args.initiallyHidden === true
// Why pre-spawn for daemon-host sessions (id minted up front): daemon
// PTYs can emit prompt bytes before spawn() resolves, and the hidden
// mark must beat the first byte so the gate + model responder own
// spawn-time queries (terminal-query-authority.md §races). Other
// providers cannot emit until spawn resolves; the post-spawn mark
// below is byte-zero-safe for them.
const preSpawnHiddenMarkId =
initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined
? effectiveSessionAppId
: null
if (preSpawnHiddenMarkId !== null) {
markHiddenRendererPty(preSpawnHiddenMarkId)
}
let result: PtySpawnResult
try {
try {
@@ -2958,6 +3168,11 @@ export function registerPtyHandlers(
result = await provider.spawn(spawnOptions)
spawnTiming.mark('provider_spawn')
} catch (err) {
// Why: a failed spawn must not leave a stale hidden mark on a session
// id a later visible attach may reuse.
if (preSpawnHiddenMarkId !== null) {
unmarkHiddenRendererPty(preSpawnHiddenMarkId)
}
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
if (preSpawnStartupTerminalColorReplyPtyId) {
@@ -3022,6 +3237,30 @@ export function registerPtyHandlers(
reattach: result.isReattach ?? false
})
ptyOwnership.set(result.id, args.connectionId ?? null)
if (initiallyHidden) {
// Why marked synchronously before any await below: local/SSH provider
// data events dispatch on later tasks, so this is still ahead of the
// first byte's delivery decision. Idempotent for daemon hosts already
// marked pre-spawn; the renderer's first visibility sync re-marks or
// unmarks (emitting the restore marker) through the Phase-4 path.
markHiddenRendererPty(result.id)
if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) {
// Defense: never strand a mark on an id the provider renamed.
unmarkHiddenRendererPty(preSpawnHiddenMarkId)
}
}
// Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY
// determination from the spawn record before the headless seed below,
// so the runtime emulator's DA1 override exists from byte zero.
if (
isNativeWindowsLocalPtySpawn({
connectionId: args.connectionId,
cwd: args.cwd,
shellOverride: effectiveShellOverride
})
) {
markNativeWindowsConptyPty(result.id)
}
if (startupTerminalColorQueryReplyColors) {
if (result.isReattach) {
if (preSpawnStartupTerminalColorReplyPtyId) {
@@ -3130,7 +3369,18 @@ export function registerPtyHandlers(
? { cols: result.snapshotCols, rows: result.snapshotRows }
: undefined
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize)
// Why kitty flags ride seed metadata: the snapshot string omits
// them by design (renderer kitty reset stays authoritative), but
// the re-seeded emulator must answer hidden `CSI ? u` with the
// flags the still-running app pushed (terminal-query-authority.md).
runtime.seedHeadlessTerminal(
result.id,
result.snapshot,
seedSize,
typeof result.snapshotKittyKeyboardFlags === 'number'
? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags }
: {}
)
} else if (
result.coldRestore &&
typeof result.coldRestore.scrollback === 'string' &&
@@ -3149,6 +3399,13 @@ export function registerPtyHandlers(
) {
runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null)
}
// Why: arms main's per-PTY Command Code output detector from the launch
// command (renderer startupCommand parity); banner detection covers
// PTYs spawned without one.
runtime?.noteTerminalSpawnCommand?.(
result.id,
typeof args.command === 'string' ? args.command : null
)
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
@@ -3532,6 +3789,66 @@ export function registerPtyHandlers(
}
})
ipcMain.removeAllListeners('pty:setHiddenRendererPty')
ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => {
if (typeof args.id !== 'string' || !args.id) {
return
}
if (args.hidden === true) {
markHiddenRendererPty(args.id)
// Why: bytes already queued for a newly hidden PTY are model-owned
// state; drop them now instead of holding them under ACK starvation.
// Reveal restores from the snapshot.
const pending = pendingData.get(args.id)
if (pending && shouldDropHiddenRendererPtyData(args.id, getSettings?.())) {
pendingData.delete(args.id)
pendingOverflowMarkedPtys.delete(args.id)
const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(
args.id,
'hidden-drop',
runtime?.getPtyOutputSequence(args.id)
)
}
recordPtyRendererDeliveryPressure()
}
return
}
const { droppedWhileHidden } = unmarkHiddenRendererPty(args.id)
// Why: a renderer reload or remount can replace the view that latched
// restore-needed from the first-drop marker. Re-emit on unhide so the
// (possibly fresh) visible view still pulls the model snapshot covering
// the dropped bytes. If the original view is still alive this can trigger
// a redundant second restore — accepted: a snapshot replay is cheap and
// idempotent, while a missed restore leaves a corrupt pane.
if (droppedWhileHidden) {
sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id))
}
})
ipcMain.removeAllListeners('pty:terminalViewAttributes')
ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => {
// Why validate-or-drop: the responder must never store a malformed
// palette — a wrong color reply breaks TUI theme detection worse than
// the documented silent-until-first-push behavior.
const attributes = validateTerminalViewAttributes(args)
if (attributes) {
setTerminalViewAttributes(attributes)
}
})
ipcMain.removeAllListeners('pty:setPtyDeliveryInterest')
ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => {
if (typeof args.id !== 'string' || !args.id) {
return
}
// Why: explicit delivery-interest signal from renderer sidecars / eager
// pre-mount buffers — any interest suppresses the hidden-delivery gate so
// raw-byte consumers keep receiving while the view is hidden or parked.
setRendererPtyDeliveryInterest(args.id, args.interested === true)
})
ipcMain.removeAllListeners('pty:signal')
ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => {
tryGetProviderForPty(args.id)
+20 -1
View File
@@ -5,6 +5,7 @@ const {
applyElectronProxySettingsMock,
browserWindowGetAllWindowsMock,
handleMock,
onMock,
previewGhosttyImportMock,
previewWarpThemeImportMock,
prepareLocalWorktreeRootsForReposMock,
@@ -14,6 +15,7 @@ const {
applyElectronProxySettingsMock: vi.fn(),
browserWindowGetAllWindowsMock: vi.fn(),
handleMock: vi.fn(),
onMock: vi.fn(),
previewGhosttyImportMock: vi.fn(),
previewWarpThemeImportMock: vi.fn(),
prepareLocalWorktreeRootsForReposMock: vi.fn(),
@@ -22,7 +24,7 @@ const {
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock },
ipcMain: { handle: handleMock },
ipcMain: { handle: handleMock, on: onMock },
nativeTheme: { themeSource: 'system' }
}))
@@ -70,6 +72,7 @@ const store = {
describe('registerSettingsHandlers', () => {
beforeEach(() => {
handleMock.mockClear()
onMock.mockClear()
applyAppIconMock.mockClear()
applyElectronProxySettingsMock.mockClear()
applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' })
@@ -89,6 +92,22 @@ describe('registerSettingsHandlers', () => {
expect(channels).toContain('settings:previewGhosttyImport')
})
it('answers the synchronous settings read with the persisted settings', () => {
// Why: panes can bind PTYs before async hydration; the side-effect
// authority kill switch needs the persisted value synchronously.
store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false })
registerSettingsHandlers(store as never)
const listener = onMock.mock.calls.find(
(call) => call[0] === 'settings:get-sync'
)?.[1] as (event: { returnValue: unknown }) => void
expect(listener).toBeTypeOf('function')
const event = { returnValue: undefined as unknown }
listener(event)
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
})
it('registers settings:previewWarpThemeImport handler', () => {
registerSettingsHandlers(store as never)
const channels = handleMock.mock.calls.map((call) => call[0])
+9
View File
@@ -66,6 +66,15 @@ export function registerSettingsHandlers(
return store.getSettings()
})
// Why: terminal panes can bind PTYs before async settings hydration
// completes. The side-effect authority kill switch is consulted once at
// transport creation, so the renderer needs the persisted value
// synchronously or pre-hydration bindings would always pick main authority
// (terminal-side-effect-authority.md, migration switch).
ipcMain.on('settings:get-sync', (event) => {
event.returnValue = store.getSettings()
})
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
// Why: Floating Workspace grants are trusted only when written by the
+5
View File
@@ -77,6 +77,11 @@ export type PtySpawnResult = {
* writing the snapshot so ANSI cursor positions land correctly. */
snapshotCols?: number
snapshotRows?: number
/** Kitty keyboard flags persisted in the daemon snapshot, threaded so the
* re-seeded runtime emulator answers hidden `CSI ? u` with the real flags
* (terminal-query-authority.md §kitty). Never replayed into a renderer
* xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */
snapshotKittyKeyboardFlags?: number
/** True when the spawn reattached to an existing daemon session. */
isReattach?: boolean
/** True when the reattached session uses the alternate screen buffer
+649
View File
@@ -49,6 +49,7 @@ import {
type RuntimeTerminalAgentStatusEvent
} from './orca-runtime'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
import {
TERMINAL_INPUT_CHUNK_MAX_BYTES,
TERMINAL_INPUT_MAX_BYTES,
@@ -5394,6 +5395,654 @@ describe('OrcaRuntimeService', () => {
})
})
it('resolves tui-idle when a completion title is coalesced with the next working title', async () => {
// Why: node-pty + the main batch window can coalesce "task done" and the
// next task's working title into one chunk. A last-title reader never
// sees the intermediate idle and the waiter hangs (issue #1083 class).
const runtime = createRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
const [terminal] = (await runtime.listTerminals()).terminals
const wait = runtime.waitForTerminal(terminal.handle, {
condition: 'tui-idle',
timeoutMs: 1_000
})
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07\x1b]0;Codex working\x07', 101)
await expect(wait).resolves.toMatchObject({
handle: terminal.handle,
condition: 'tui-idle',
status: 'running'
})
})
it('ignores the bare cursor-agent native title so synthesized spinner state survives', async () => {
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100)
// cursor-agent re-emits its bare native title on internal redraws while
// still working; it must not stomp the synthesized working title.
runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 101)
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
title: '⠋ Cursor Agent'
})
})
it('clears a stale working title after 3s of title-less output', async () => {
vi.useFakeTimers()
try {
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100)
runtime.onPtyData(ptyId, 'output without a title\r\n', 101)
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
title: 'Codex working'
})
await vi.advanceTimersByTimeAsync(3_000)
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
title: 'Codex'
})
} finally {
vi.useRealTimers()
}
})
it('cancels the stale-title timer when the PTY exits', async () => {
vi.useFakeTimers()
try {
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100)
runtime.onPtyData(ptyId, 'output without a title\r\n', 101)
runtime.onPtyExit(ptyId, 0)
await vi.advanceTimersByTimeAsync(4_000)
// The dead session keeps its factual last title — the disposed tracker's
// stale-title rewrite must not fire into the retained record.
expect((await runtime.listTerminals()).terminals[0]).toMatchObject({
title: 'Codex working'
})
} finally {
vi.useRealTimers()
}
})
it('keeps stale-title timers isolated per PTY', async () => {
vi.useFakeTimers()
try {
const ptyA = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a`
const ptyB = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-b`
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: ptyA, cwd: '/tmp/worktree-a', title: 'shell' },
{ id: ptyB, cwd: '/tmp/worktree-a', title: 'shell' }
]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.onPtyData(ptyA, '\x1b]0;Codex working\x07', 100)
runtime.onPtyData(ptyB, '\x1b]0;Aider working\x07', 100)
// Only A receives title-less output, so only A's stale timer arms.
runtime.onPtyData(ptyA, 'output without a title\r\n', 101)
await vi.advanceTimersByTimeAsync(3_000)
const { terminals } = await runtime.listTerminals()
expect(terminals.find((t) => t.tabId === `pty:${ptyA}`)).toMatchObject({ title: 'Codex' })
expect(terminals.find((t) => t.tabId === `pty:${ptyB}`)).toMatchObject({
title: 'Aider working'
})
} finally {
vi.useRealTimers()
}
})
// ─── pty:sideEffect channel (terminal-side-effect-authority.md, slice 2) ──
describe('terminal side-effect fact channel', () => {
function createSideEffectRuntime(): {
runtime: OrcaRuntimeService
batches: TerminalSideEffectBatch[]
} {
const batches: TerminalSideEffectBatch[] = []
const runtime = new OrcaRuntimeService(store, undefined, {
onTerminalSideEffects: (batch) => batches.push(batch)
})
return { runtime, batches }
}
it('emits one batched event per chunk with facts in byte order and attribution', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
const chunk = '\x1b]0;Codex working\x07response\x1b]0;Codex done\x07\x07'
runtime.onPtyData('pty-1', chunk, 100)
expect(batches).toHaveLength(1)
expect(batches[0]).toMatchObject({
ptyId: 'pty-1',
seq: chunk.length,
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-1',
paneKey: 'tab-1:1'
})
expect(batches[0].replay).toBeUndefined()
expect(batches[0].facts).toEqual([
{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' },
{ kind: 'agent-working' },
{ kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' },
{ kind: 'agent-idle', title: 'Codex done' },
{ kind: 'bell' }
])
})
it('keeps per-PTY ordering across chunks and accumulates seq', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
expect(batches.map((batch) => batch.facts[0]?.kind)).toEqual(['title', 'title'])
expect(batches[0].seq).toBeLessThan(batches[1].seq)
})
it('emits nothing for chunks without derived facts', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
// Plain output, a BEL-terminated non-title OSC split across chunks, and
// an Orca status payload: none of these is a title/bell/agent fact.
runtime.onPtyData('pty-1', 'plain output\r\n', 100)
runtime.onPtyData('pty-1', '\x1b]7;file://host', 101)
runtime.onPtyData('pty-1', '/tmp\x07', 102)
runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103)
expect(batches).toEqual([])
})
it('emits the stale-working-title rewrite as between-chunk fact batches', async () => {
vi.useFakeTimers()
try {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', 'output without a title\r\n', 101)
batches.length = 0
await vi.advanceTimersByTimeAsync(3_000)
// Timer facts fire outside a chunk, so each emits immediately —
// still strictly ordered per PTY. They carry staleWorkingTitleClear:
// the renderer must clear state without scheduling a task-complete
// notification main's unthrottled timer did not earn.
expect(batches.flatMap((batch) => batch.facts)).toEqual([
{
kind: 'title',
normalizedTitle: 'Codex',
rawTitle: 'Codex',
staleWorkingTitleClear: true
},
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
])
} finally {
vi.useRealTimers()
}
})
it('ingests synthetic title frames without touching the byte pipeline', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
expect(batches).toHaveLength(1)
expect(batches[0].facts).toEqual([
{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' },
// The synthesized spinner classifies as working — agent facts derive
// from synthetic frames the same as from real bytes.
{ kind: 'agent-working' }
])
// Synthetic frames are fabricated by main: they must not advance the
// metered output sequence the renderer ACK budget is based on.
expect(runtime.getPtyOutputSequence('pty-1')).toBe(0)
})
it('carries the synthetic permission BEL as a bell fact', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor needs your input\x07\x07')
expect(batches[0].facts.at(0)).toMatchObject({ kind: 'title' })
expect(batches[0].facts.at(-1)).toEqual({ kind: 'bell' })
})
it('emits command-finished facts with best-effort exit codes across chunk splits', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'output\x1b]133;D;13', 100)
expect(batches).toEqual([])
runtime.onPtyData('pty-1', '0\x07prompt $ ', 101)
runtime.onPtyData('pty-1', '\x1b]133;D\x07', 102)
expect(batches.flatMap((batch) => batch.facts)).toEqual([
{ kind: 'command-finished', exitCode: 130 },
{ kind: 'command-finished', exitCode: null }
])
})
it('emits pr-link facts once per URL with batch attribution', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'PR https://github.com/acme/orca/pull/4', 100)
runtime.onPtyData('pty-1', '2\r\nand https://github.com/acme/orca/pull/43 done\r\n', 101)
// Repeated URL: deduped per PTY, like the renderer byte detector.
runtime.onPtyData('pty-1', 'again https://github.com/acme/orca/pull/42\r\n', 102)
expect(batches).toHaveLength(1)
expect(batches[0]).toMatchObject({
ptyId: 'pty-1',
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-1'
})
expect(batches[0].facts).toEqual([
{
kind: 'pr-link',
link: {
url: 'https://github.com/acme/orca/pull/42',
slug: { owner: 'acme', repo: 'orca' },
number: 42
}
},
{
kind: 'pr-link',
link: {
url: 'https://github.com/acme/orca/pull/43',
slug: { owner: 'acme', repo: 'orca' },
number: 43
}
}
])
})
it('emits 2031-subscribe facts across chunk splits', () => {
// Why: hidden-delivery-gated views never receive the bytes — this fact
// is their only signal to send the DECSET 2031 color-scheme reply.
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b[?20', 100)
expect(batches).toEqual([])
runtime.onPtyData('pty-1', '31h', 101)
expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }])
})
it('prefers the tracked title over the renderer snapshot lastTitle', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({
data: 'visible content',
cols: 80,
rows: 24,
// The renderer xterm never saw the synthetic frame (it no longer
// rides pty:data), so its serializer reports a stale title.
lastTitle: 'stale shell title'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
hasRendererSerializer: () => true,
getSize: () => ({ cols: 80, rows: 24 })
})
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 })
expect(snapshot?.source).toBe('renderer')
expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent')
})
it('prefers the tracked title over the headless emulator lastTitle', async () => {
const { runtime } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07real output\r\n', 100)
// The hook-driven idle frame lands only in main's tracker — the
// emulator never sees fabricated bytes (invariant 5).
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07')
const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
expect(snapshot?.source).toBe('headless')
expect(snapshot?.lastTitle).toBe('Codex ready')
})
it('returns a title-only replay snapshot and never historical attention', () => {
const { runtime } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x07', 100)
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
ptyId: 'pty-1',
replay: true,
facts: [{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }]
})
expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull()
})
it('drops the cursor-agent literal from record-fallback snapshots', () => {
const { runtime } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'plain output\n', 100)
// Simulate a record title restored by a path that bypassed the tracker
// (the tracker itself refuses to store the bare native title).
const records = (
runtime as unknown as {
ptysById: Map<string, { lastOscTitle: string | null }>
}
).ptysById
records.get('pty-1')!.lastOscTitle = 'Cursor Agent'
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull()
})
it('emits the chunk agentStatus events before its side-effect batch', () => {
// Cross-channel contract order per chunk: status → titles → bell.
const order: string[] = []
const runtime = new OrcaRuntimeService(store, undefined, {
onTerminalAgentStatus: () => order.push('agentStatus:set'),
onTerminalSideEffects: () => order.push('pty:sideEffect')
})
syncSinglePty(runtime)
runtime.onPtyData(
'pty-1',
'\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex working\x07\x07',
100
)
expect(order).toEqual(['agentStatus:set', 'pty:sideEffect'])
})
it('still emits a throwing chunk’s facts under its own seq, not the next chunk’s', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
vi.spyOn(
runtime as unknown as { applyTrackedPtyTitle: (ptyId: string, title: string) => boolean },
'applyTrackedPtyTitle'
).mockImplementationOnce(() => {
throw new Error('tracker boom')
})
const first = '\x1b]0;Codex working\x07'
expect(() => runtime.onPtyData('pty-1', first, 100)).toThrow('tracker boom')
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
expect(batches).toHaveLength(2)
expect(batches[0].seq).toBe(first.length)
expect(batches[0].facts).toEqual([
{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }
])
// The next chunk's batch carries only its own facts (the throw aborted
// the first chunk's agent-tracker pass, so no working state was kept).
expect(batches[1].seq).toBeGreaterThan(batches[0].seq)
expect(batches[1].facts).toEqual([
{ kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }
])
})
it('parses synthetic frames statelessly so ticks cannot corrupt the bell detector', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;split ti', 100)
// An 80ms spinner tick lands between the two halves of the real OSC.
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
// Continuation: this BEL terminates the real OSC — it is NOT a bell.
runtime.onPtyData('pty-1', 'tle\x07', 101)
// A later standalone BEL is a real bell and must not be swallowed.
runtime.onPtyData('pty-1', 'ready\x07', 102)
expect(batches.flatMap((batch) => batch.facts)).toEqual([
{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' },
{ kind: 'agent-working' },
{ kind: 'title', normalizedTitle: 'split title', rawTitle: 'split title' },
{ kind: 'bell' }
])
})
it('touches mobile snapshots once for decorative spinner ticks, again on idle', () => {
const { runtime } = createSideEffectRuntime()
syncSinglePty(runtime)
const touchSpy = vi.spyOn(
runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void },
'touchMobileSessionSnapshotsForPty'
)
for (const frame of ['⠋', '⠙', '⠹', '⠸', '⠼']) {
runtime.ingestSyntheticTitleFrame('pty-1', `\x1b]0;${frame} Cursor Agent\x07`)
}
// Five ticks with the same de-spinnered title: one snapshot fan-out.
expect(touchSpy).toHaveBeenCalledTimes(1)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor ready\x07')
expect(touchSpy).toHaveBeenCalledTimes(2)
// Raw record titles still track every frame for worktree ps/mobile tabs.
expect(
(
runtime as unknown as {
ptysById: Map<string, { lastOscTitle: string | null }>
}
).ptysById.get('pty-1')?.lastOscTitle
).toBe('Cursor ready')
})
it('seeds the lazily created tracker from the daemon-snapshot title', async () => {
const { runtime, batches } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({
data: 'restored scrollback\n',
cols: 80,
rows: 24,
lastTitle: 'Codex working'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
hasRendererSerializer: () => true,
getSize: () => ({ cols: 80, rows: 24 })
})
syncSinglePty(runtime)
// First live chunk creates the tracker cold and kicks off hydration;
// the snapshot seed must land in the already-created tracker.
runtime.onPtyData('pty-1', 'plain output without a title\n', 100)
await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
batches.length = 0
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
// Without the seed the tracker never saw 'working', so this idle title
// could not produce a completion fact.
expect(batches.flatMap((batch) => batch.facts)).toContainEqual({
kind: 'agent-idle',
title: 'Codex done'
})
})
it('arms the stale-title timer for a seeded working title', async () => {
vi.useFakeTimers()
try {
const { runtime, batches } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({
data: 'restored scrollback\n',
cols: 80,
rows: 24,
lastTitle: 'Codex working'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
hasRendererSerializer: () => true,
getSize: () => ({ cols: 80, rows: 24 })
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'plain output\n', 100)
// Settle the async daemon-snapshot hydration that seeds the tracker.
await vi.advanceTimersByTimeAsync(0)
runtime.onPtyData('pty-1', 'still no title\n', 101)
batches.length = 0
await vi.advanceTimersByTimeAsync(3_000)
expect(batches.flatMap((batch) => batch.facts)).toEqual([
{
kind: 'title',
normalizedTitle: 'Codex',
rawTitle: 'Codex',
staleWorkingTitleClear: true
},
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
])
} finally {
vi.useRealTimers()
}
})
it('emits command-code-working facts only after the banner arms the scrape', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
// Generic status words without the Command Code banner must not arm.
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\nThinking...', 100)
expect(batches.flatMap((batch) => batch.facts)).toEqual([])
runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 101)
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m', 102)
expect(batches.at(-1)).toMatchObject({
ptyId: 'pty-1',
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-1'
})
expect(batches.at(-1)?.facts).toEqual([
{ kind: 'command-code-working', prompt: 'Fix the spinner' }
])
})
it('emits a command-code-done fact when the idle composer returns', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 100)
runtime.onPtyData('pty-1', '❯ say hi\r\n✻ Thinking...', 101)
runtime.onPtyData(
'pty-1',
'\r\n✻ Thought for 1 second\r\n:: Hi!\r\n❯ Ask your question...',
102
)
expect(batches.at(-1)?.facts).toEqual([{ kind: 'command-code-done', prompt: 'say hi' }])
})
it('arms the Command Code scrape from the noted spawn command', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
// Mirrors the renderer detector's startupCommand fast-arm: no banner
// needed when main saw the launch command at spawn time.
runtime.noteTerminalSpawnCommand('pty-1', 'command-code --trust')
runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n✻ Thinking...', 100)
expect(batches.flatMap((batch) => batch.facts)).toContainEqual({
kind: 'command-code-working',
prompt: 'Fix the spinner'
})
})
it('prefers the tracked title over a stale renderer lastTitle in the hydration seed', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({
data: 'renderer scrollback\n',
cols: 80,
rows: 24,
// The renderer xterm never saw the synthetic hook frame (it no longer
// rides pty:data), so its serializer reports the pre-agent title.
lastTitle: 'stale shell title'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
hasRendererSerializer: () => true,
getSize: () => ({ cols: 80, rows: 24 })
})
syncSinglePty(runtime)
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude working\x07')
// First live chunk kicks off renderer hydration; awaiting the snapshot
// below settles the seed write chain.
runtime.onPtyData('pty-1', 'plain output\n', 100)
await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 })
const leaves = (
runtime as unknown as { leaves: Map<string, { lastOscTitle: string | null }> }
).leaves
// The seed must not stomp the leaf record (worktree ps status source)
// back to the renderer's stale title.
expect([...leaves.values()][0]?.lastOscTitle).toBe('⠋ Claude working')
})
})
it('returns OSC titles from headless main terminal snapshots', async () => {
const runtime = createRuntime()
syncSinglePty(runtime, 'pty-1')
+640 -97
View File
@@ -2,14 +2,26 @@
/* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */
/* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */
import {
extractLastOscTitle,
detectAgentStatusFromTitle,
isClaudeManagementTitle,
isShellProcess
isCursorNativeAgentTitle,
isShellProcess,
normalizeTerminalTitle
} from '../../shared/agent-detection'
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
import type { AgentStatus } from '../../shared/agent-detection'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import {
createTerminalTitleTracker,
stripBrailleSpinnerGlyphs,
type TerminalTitleTracker
} from '../../shared/terminal-output-side-effects'
import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status'
import type {
TerminalSideEffectBatch,
TerminalSideEffectFact
} from '../../shared/terminal-side-effect-facts'
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusIpcPayload,
@@ -657,6 +669,15 @@ import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import {
isNativeWindowsConptyPty,
registerConptyDa1OverrideInstaller,
shouldModelAnswerHiddenPtyQueries
} from './terminal-model-query-authority'
import {
getTerminalViewAttributes,
registerTerminalViewAttributesApplier
} from './terminal-view-attribute-store'
import { killAllProcessesForWorktree } from './worktree-teardown'
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
import type { IFilesystemProvider, IPtyProvider } from '../providers/types'
@@ -792,6 +813,11 @@ type RuntimeStore = {
mobileEmulatorDefaultDeviceUdid?: string | null
voice?: VoiceSettings
claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode']
// Why: Phase-5 query responder kill switches — read per chunk in
// onPtyData to capture reply ownership at ingestion.
terminalMainSideEffectAuthority?: GlobalSettings['terminalMainSideEffectAuthority']
terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate']
terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority']
}
// Why: narrow to `unknown` return so test mocks can return void without
// a cast. The runtime never reads the return value — the persisted value
@@ -980,6 +1006,28 @@ export type RuntimeTerminalAgentStatusEvent = {
payload: ParsedAgentStatusPayload
}
type RuntimePtyTitleTrackerEntry = {
tracker: TerminalTitleTracker
// Why: onPtyData batches the mobile session-tab touch to once per chunk;
// the stale-working-title timer fires between chunks and must touch
// immediately. These flags route the tracker callback to the right mode.
applyingChunk: boolean
// Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the
// synthetic path gates mobile snapshot fan-out on a non-decorative title
// change (spinner glyph + status comparison key kept below).
applyingSyntheticFrame: boolean
lastMobileTitleGateKey: string | null
chunkTouchedSessionTabs: boolean
// Why: facts observed while applying a chunk are batched into one
// pty:sideEffect emission per chunk, preserving byte order (titles in
// sequence, then bell). Timer-fired facts emit immediately between chunks.
pendingFacts: TerminalSideEffectFact[]
// Why: Command Code lacks hooks, so its working/done state is scraped from
// TUI output. Null when no side-effect consumer exists (headless serve) —
// the scrape produces facts only.
commandCodeDetector: { observe: (data: string) => boolean } | null
}
// Why: the full OSC 9999 payload flows through emitTerminalAgentStatusEvents and
// is then forwarded to the renderer and dropped. Mobile is served by the main
// process and has no renderer store, so we retain the latest payload per pane
@@ -1006,6 +1054,10 @@ type RuntimeHeadlessTerminal = {
type HeadlessSeedMetadata = {
cwd?: string | null
oscLinks?: TerminalOscLinkRange[]
/** Persisted kitty flags from the daemon snapshot, re-applied to the fresh
* emulator so hidden `CSI ? u` answers the real flags instead of ?0u
* (terminal-query-authority.md §kitty). */
kittyKeyboardFlags?: number
}
type RuntimePtyController = {
@@ -1988,8 +2040,18 @@ export class OrcaRuntimeService {
string,
ReturnType<typeof createAgentStatusOscProcessor>
>()
// Why: per-PTY shared title trackers (all-titles ordering + stale-working
// timer) replace last-title-per-chunk scanning so main observes the same
// intra-chunk working→idle transitions the renderer does (issue #1083).
// Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit.
private ptyTitleTrackersByPtyId = new Map<string, RuntimePtyTitleTrackerEntry>()
// Why: the Command Code output detector arms early from the launch command
// when known (banner detection covers user-typed launches), mirroring the
// renderer detector's startupCommand seed.
private terminalSpawnCommandsByPtyId = new Map<string, string>()
// Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over
// SSH/relay buffering. Keep a small raw scan tail so status titles are not lost.
// SSH/relay buffering. Keep a small raw scan tail and feed reconstructed
// chunks into the title tracker instead of falling back to last-title scans.
private oscTitleScanTailByPtyId = new Map<string, string>()
// Why: latest agent-status payload per pane, retained so worktree.ps can serve
// mobile the same inline agent rows the desktop sidebar renders. Cleared on pty
@@ -2051,6 +2113,14 @@ export class OrcaRuntimeService {
>
>()
// Why: Phase-5 query-responder suppression — a terminal-RPC subscribe
// stream feeds a remote xterm view (mobile/web/remote desktop) that answers
// queries with view authority, so main must yield while one is attached
// (terminal-query-authority.md). Ref-counted per PTY because multiple
// streams can attach concurrently; mobileSubscribers is consulted too so
// grace-window mobile records keep suppressing.
private remoteTerminalViewSubscriberCounts = new Map<string, number>()
// Why: per-PTY driver state. The "driver" is whoever currently owns the
// input/resize floor. While `kind === 'mobile'` the desktop renderer drops
// xterm.onData/onResize and shows the lock banner; `terminal.send` /
@@ -2198,6 +2268,7 @@ export class OrcaRuntimeService {
private readonly getLocalProviderFn: (() => IPtyProvider) | null
private readonly onPtyStopped: ((ptyId: string) => void) | null
private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null
private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null
private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null
private readonly buildAgentHookPtyEnv: (() => Record<string, string>) | null
private accountServices: RuntimeAccountServices | null = null
@@ -2222,6 +2293,7 @@ export class OrcaRuntimeService {
getLocalProvider?: () => IPtyProvider
onPtyStopped?: (ptyId: string) => void
onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void
onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void
// Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC
// terminal output. worktree.ps reads this at query time so mobile shows the
// same inline agent rows the desktop sidebar does — same source, 1:1.
@@ -2245,6 +2317,20 @@ export class OrcaRuntimeService {
this.onPtyStopped = deps?.onPtyStopped ?? null
this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null
this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null
this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null
// Why: the ConPTY spawn mark can land after daemon stream data already
// created this PTY's emulator; the mark retrofits the DA1 override here
// (terminal-query-authority.md §ConPTY DA1).
registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId))
// Why: a renderer attribute push must reach already-live emulators too —
// cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color
// override reset a theme apply implies (terminal-query-authority.md
// §View-attribute bridge).
registerTerminalViewAttributesApplier((attributes) => {
for (const state of this.headlessTerminals.values()) {
state.emulator.applyPushedViewAttributes(attributes)
}
})
}
getLocalProvider(): IPtyProvider | null {
@@ -5049,6 +5135,16 @@ export class OrcaRuntimeService {
this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId })
}
/** Record the spawn launch command so the per-PTY Command Code detector can
* arm from it (renderer startupCommand parity). Best-effort: a chunk that
* beats this call falls back to the detector's banner arming. */
noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void {
const trimmed = typeof command === 'string' ? command.trim() : ''
if (trimmed.length > 0) {
this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed)
}
}
/**
* Handles incoming data from a PTY process, running agent detection,
* updating terminal tail buffers, and triggering foreground agent refreshes.
@@ -5069,6 +5165,12 @@ export class OrcaRuntimeService {
// panel can surface them in place of the kernel bind address.
advertisedUrlWatcher.ingest(ptyId, data, at)
serveSimStateWatcher.ingestPtyOutput(ptyId, data)
// Why: reply ownership is captured per chunk, here at ingestion — the
// same module state and tick as the hidden-gate drop sites — and rides
// the writeChain link. A mark/setting/subscriber flip before the queued
// emulator write runs must not change who answers (terminal-query-
// authority.md invariant 1).
const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId)
// Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer
// MUST run before trackHeadlessTerminalData so the eager-state pattern
// (set headlessTerminals + writeChain head = seedPromise) is in place
@@ -5077,17 +5179,9 @@ export class OrcaRuntimeService {
// that the later seed-resolve would overwrite, dropping the live byte.
// See docs/mobile-prefer-renderer-scrollback.md.
this.maybeHydrateHeadlessFromRenderer(ptyId)
this.trackHeadlessTerminalData(ptyId, data, outputSequence)
// Why: extract OSC title from raw PTY data before tail-buffer processing
// strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.)
// announce status via OSC 0/1/2 title sequences — this is the same
// detection path the renderer uses for notifications and sidebar badges.
const oscTitle = this.extractLastOscTitleForPty(ptyId, data)
const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null
this.trackHeadlessTerminalData(ptyId, data, outputSequence, forwardQueryReplies)
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
let shouldTouchPtyBackedSessionTabs = false
const ptyTailBefore = pty
? {
lines: pty.tailBuffer,
@@ -5119,26 +5213,6 @@ export class OrcaRuntimeService {
pty.tailLinesTotal += nextTail.newCompleteLines
pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine)
this.scheduleWaitBlockedCheck(ptyId, normalized.text, at)
if (oscTitle !== null) {
const prevStatus = pty.lastAgentStatus
const prevTitle = pty.lastOscTitle
const observedAt = this.nextTitleObservationSequence()
pty.lastOscTitle = oscTitle
pty.lastOscTitleAt = observedAt
pty.lastAgentStatus = agentStatus
this.setPtyManagementTitleFromObservedTitle(pty, oscTitle, observedAt)
shouldTouchPtyBackedSessionTabs =
prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolvePtyTuiIdleWaiters(pty, ptyId)
}
// 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 !== pty.lastAgentStatus) {
this.refreshPtyForegroundAgent(ptyId)
}
}
}
for (const leaf of this.getLeavesForPty(ptyId)) {
@@ -5206,39 +5280,50 @@ export class OrcaRuntimeService {
leaf.tailLinesTotal += nextTail.newCompleteLines
leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine)
}
if (oscTitle !== null) {
// 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.
leaf.lastOscTitle = oscTitle
leaf.lastOscTitleAt = this.nextTitleObservationSequence()
const prevStatus = leaf.lastAgentStatus
// 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
// 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)
this.deliverPendingMessages(leaf)
}
}
}
this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
if (shouldTouchPtyBackedSessionTabs) {
// Why: feed the chunk's OSC titles through the shared per-PTY tracker in
// byte order — the same ordering the renderer transport uses — so
// coalesced working→idle transitions reach tui-idle waiters and
// pending-message delivery instead of being masked by the chunk's last
// title (issue #1083). Uses the OSC 9999-stripped cleanData like the
// renderer, so pure status chunks don't perturb the stale-title probe.
const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId)
const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId)
const titleInput = previousTitleScanTail
? `${previousTitleScanTail}${agentStatusChunk.cleanData}`
: agentStatusChunk.cleanData
const nextTitleScanTail = extractOscTitleScanTail(titleInput)
if (nextTitleScanTail.length > 0) {
this.oscTitleScanTailByPtyId.set(ptyId, nextTitleScanTail)
} else {
this.oscTitleScanTailByPtyId.delete(ptyId)
}
titleTrackerEntry.applyingChunk = true
titleTrackerEntry.chunkTouchedSessionTabs = false
try {
titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, {
titleScanData: titleInput
})
// Why: the Command Code scrape rides the same per-chunk batch (its facts
// trail the tracker's). cleanData keeps OSC 9999 payloads out of the
// detector's bounded recent-text window; the detector strips remaining
// control sequences itself, exactly like the renderer byte path.
titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData)
} finally {
titleTrackerEntry.applyingChunk = false
try {
// Why: per-chunk cross-channel contract order is status → titles →
// bell — the chunk's agentStatus:set events must reach the renderer
// before its pty:sideEffect batch.
this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
} finally {
// Why: flushed in the finally so a throwing tracker callback cannot
// strand this chunk's facts to be emitted under the next chunk's seq.
this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry)
}
}
if (titleTrackerEntry.chunkTouchedSessionTabs) {
this.touchMobileSessionSnapshotsForPty(ptyId)
}
@@ -5326,19 +5411,357 @@ export class OrcaRuntimeService {
return processor(data)
}
private extractLastOscTitleForPty(ptyId: string, data: string): string | null {
const previousTail = this.oscTitleScanTailByPtyId.get(ptyId)
if (!previousTail && !data.includes('\x1b')) {
/** Emit the facts batched while applying one chunk/frame as a single
* pty:sideEffect batch, preserving byte order. */
private flushPendingTerminalSideEffectFacts(
ptyId: string,
entry: RuntimePtyTitleTrackerEntry
): void {
if (entry.pendingFacts.length === 0) {
return
}
const facts = entry.pendingFacts
entry.pendingFacts = []
this.emitTerminalSideEffectBatch(ptyId, facts)
}
/** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through
* the per-PTY tracker — NOT onPtyData, so emulator state, tails,
* transcripts, and stats never see synthetic bytes. Parsed via the
* tracker's stateless synthetic path: the shared chunk bell detector must
* never observe fabricated bytes, or a tick interleaved with a split real
* OSC corrupts its escape state (phantom/swallowed bells). While the
* side-effect kill switch is off the legacy pty:data copy still drives
* renderer parsers; this ingest keeps main's facts and records
* authoritative. */
ingestSyntheticTitleFrame(ptyId: string, data: string): void {
const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId)
entry.applyingChunk = true
entry.applyingSyntheticFrame = true
entry.chunkTouchedSessionTabs = false
try {
entry.tracker.applySyntheticTitleFrame(data)
} finally {
entry.applyingChunk = false
entry.applyingSyntheticFrame = false
this.flushPendingTerminalSideEffectFacts(ptyId, entry)
}
if (entry.chunkTouchedSessionTabs) {
this.touchMobileSessionSnapshotsForPty(ptyId)
}
}
/** Record one derived side-effect fact: batched per chunk while applying
* bytes, emitted immediately for between-chunk facts (stale-title timer). */
private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void {
if (!this.onTerminalSideEffects) {
return
}
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
if (entry?.applyingChunk) {
entry.pendingFacts.push(fact)
return
}
this.emitTerminalSideEffectBatch(ptyId, [fact])
}
private emitTerminalSideEffectBatch(
ptyId: string,
facts: TerminalSideEffectFact[],
options: { replay?: boolean } = {}
): void {
if (!this.onTerminalSideEffects || facts.length === 0) {
return
}
const batch: TerminalSideEffectBatch = {
ptyId,
seq: this.ptyOutputSequenceById.get(ptyId) ?? 0,
facts,
...(options.replay ? { replay: true } : {}),
...this.resolveTerminalSideEffectAttribution(ptyId)
}
try {
this.onTerminalSideEffects(batch)
} catch (err) {
console.error('[runtime] terminal side-effect listener threw', { ptyId, err })
}
}
/** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the
* first mounted leaf, fall back to the spawn-time PTY record binding. */
private 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
// Why: the cursor-agent literal drop applies to every title surface; a
// record-fallback snapshot must not replay the bare native title the
// tracker would have refused to emit live.
const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null
const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null
if (normalizedTitle === null && !rawTitle) {
return null
}
const input = `${previousTail ?? ''}${data}`
const scanTail = extractOscTitleScanTail(input)
if (scanTail.length > 0) {
this.oscTitleScanTailByPtyId.set(ptyId, scanTail)
} else {
this.oscTitleScanTailByPtyId.delete(ptyId)
return {
ptyId,
seq: this.ptyOutputSequenceById.get(ptyId) ?? 0,
replay: true,
facts: [
{
kind: 'title',
normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!),
rawTitle: rawTitle ?? normalizedTitle!
}
],
...this.resolveTerminalSideEffectAttribution(ptyId)
}
return extractLastOscTitle(input)
}
/** Raw last title from main's tracked PTY/leaf records — the title surface
* the tracker (live bytes + synthetic frames) keeps current. */
private 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
}
/** Why: synthetic agent title frames no longer ride pty:data, so neither
* renderer xterm nor the headless emulator observes them. Mobile-parity
* snapshot titles must prefer main's tracker over snapshot lastTitle, or
* hook-driven spinner/idle titles vanish from mobile tabs. */
private preferTrackedLastTitle<T extends { lastTitle?: string }>(ptyId: string, snapshot: T): T {
const tracked = this.getTrackedRawTitleForPty(ptyId)
if (!tracked) {
return snapshot
}
return { ...snapshot, lastTitle: tracked }
}
/** Decorative comparison key: spinner frame glyphs stripped, derived agent
* status kept so a working→idle flip with an otherwise-equal label still
* counts as a change. */
private makeMobileTitleGateKey(rawTitle: string, normalizedTitle: string): string {
return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs(
normalizedTitle
)}`
}
private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry {
const existing = this.ptyTitleTrackersByPtyId.get(ptyId)
if (existing) {
return existing
}
// Why: trackers are created lazily on the first observed chunk. After an
// app relaunch the PTY/leaf records can already hold a persisted title; a
// cold tracker would miss the parked working→idle completion and never
// arm the stale-title timer for a persisted 'working' title.
let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null
if (initialTitle === null) {
for (const leaf of this.getLeavesForPty(ptyId)) {
if (leaf.lastOscTitle) {
initialTitle = leaf.lastOscTitle
break
}
}
}
const tracker = createTerminalTitleTracker(
{
onTitle: (normalizedTitle, rawTitle, meta) => {
this.recordTerminalSideEffectFact(ptyId, {
kind: 'title',
normalizedTitle,
rawTitle,
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
})
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle)
if (!changed) {
return
}
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
const gateKey = this.makeMobileTitleGateKey(rawTitle, normalizedTitle)
const decorativeOnly = live?.lastMobileTitleGateKey === gateKey
if (live) {
live.lastMobileTitleGateKey = gateKey
}
if (live?.applyingChunk) {
// Why: synthetic spinner ticks change only the braille glyph
// ~12.5x/sec; fanning out full mobile session snapshots per frame
// is pure churn. Raw lastOscTitle updates above stay cheap.
if (!(live.applyingSyntheticFrame && decorativeOnly)) {
live.chunkTouchedSessionTabs = true
}
} else {
// Stale-working-title timer path — fires between chunks, so the
// per-chunk batching in onPtyData cannot pick it up.
this.touchMobileSessionSnapshotsForPty(ptyId)
}
},
// Why: agent transitions and bells become pty:sideEffect facts —
// main is the single byte parser for local/SSH PTYs; the renderer
// store handler decides what the facts mean (notification policy).
onAgentBecameWorking: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' })
},
onAgentBecameIdle: (title, meta) => {
this.recordTerminalSideEffectFact(ptyId, {
kind: 'agent-idle',
title,
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
})
},
onAgentExited: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' })
},
// Why: bell/command-finished/pr-link/2031 facts exist only for the
// pty:sideEffect channel. Headless serve has no consumer, so skip the
// per-chunk bell walk and 133/URL/2031 scans entirely.
...(this.onTerminalSideEffects
? {
onBell: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
},
onCommandFinished: (exitCode: number | null) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode })
},
onPrLink: (link: TerminalGitHubPRLink) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link })
},
// Why: hidden-delivery-gated views never see the bytes, so main
// surfaces DECSET 2031 subscribes as facts; the theme reply is
// still sent by the renderer (query authority stays with the view).
onMode2031Subscribe: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
}
}
: {})
},
initialTitle !== null ? { initialTitle } : {}
)
const entry: RuntimePtyTitleTrackerEntry = {
tracker,
applyingChunk: false,
applyingSyntheticFrame: false,
lastMobileTitleGateKey: null,
chunkTouchedSessionTabs: false,
pendingFacts: [],
// Why: command-code facts exist only for the pty:sideEffect channel —
// headless serve skips the per-chunk scrape entirely. The detector
// self-arms on the Command Code banner; the spawn command (when main
// saw one) mirrors the renderer detector's startupCommand fast-arm.
commandCodeDetector: this.onTerminalSideEffects
? 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 })
}
})
: null
}
this.ptyTitleTrackersByPtyId.set(ptyId, entry)
return entry
}
/** Apply one observed OSC title (raw form) to the PTY and leaf records.
* Returns true when the PTY record's title or status changed. */
private applyTrackedPtyTitle(ptyId: string, rawTitle: string): boolean {
const agentStatus = detectAgentStatusFromTitle(rawTitle)
let ptyRecordChanged = false
const pty = this.ptysById.get(ptyId)
if (pty) {
const prevStatus = pty.lastAgentStatus
const prevTitle = pty.lastOscTitle
const observedAt = this.nextTitleObservationSequence()
// Why: records keep the RAW title — worktree `ps` and mobile tab titles
// expect it; normalized titles ride along on the tracker for later
// emitted facts (terminal-side-effect-authority.md).
pty.lastOscTitle = rawTitle
pty.lastOscTitleAt = observedAt
pty.lastAgentStatus = agentStatus
this.setPtyManagementTitleFromObservedTitle(pty, rawTitle, observedAt)
ptyRecordChanged = prevTitle !== rawTitle || prevStatus !== agentStatus
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolvePtyTuiIdleWaiters(pty, ptyId)
}
// 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) {
this.refreshPtyForegroundAgent(ptyId)
}
}
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.
leaf.lastOscTitle = rawTitle
leaf.lastOscTitleAt = this.nextTitleObservationSequence()
const prevStatus = leaf.lastAgentStatus
// 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
// 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)
this.deliverPendingMessages(leaf)
}
}
return ptyRecordChanged
}
/** Cancel the per-PTY title tracker (stale-title timer included) on PTY
* teardown so it cannot fire into pruned records. */
private disposePtyTitleTracker(ptyId: string): void {
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose()
this.ptyTitleTrackersByPtyId.delete(ptyId)
}
private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void {
@@ -5448,6 +5871,37 @@ export class OrcaRuntimeService {
return addListenerToMap(this.dataListeners, ptyId, listener)
}
/** 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 {
this.remoteTerminalViewSubscriberCounts.set(
ptyId,
(this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1
)
let released = false
return () => {
if (released) {
return
}
released = true
const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1
if (next <= 0) {
this.remoteTerminalViewSubscriberCounts.delete(ptyId)
} else {
this.remoteTerminalViewSubscriberCounts.set(ptyId, next)
}
}
}
hasRemoteTerminalViewSubscriber(ptyId: string): boolean {
if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) {
return true
}
return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0
}
subscribeToFitOverrideChanges(
ptyId: string,
listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void
@@ -5587,15 +6041,20 @@ export class OrcaRuntimeService {
return
}
const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state: RuntimeHeadlessTerminal = {
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
outputSequence: 0,
writeChain: Promise.resolve()
}
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
this.headlessTerminals.set(ptyId, state)
state.writeChain = state.writeChain
.then(async () => {
// Why: seed writes never set forwardQueryReplies — the main-side
// replay guard. A snapshot containing old queries must answer no one.
await state.emulator.write(data)
// Why AFTER the seed write: the snapshot payload cannot carry kitty
// pushes (rehydrateSequences deliberately omits them), but ordering
// behind it keeps the parse deterministic. Unflagged like the seed —
// re-applying flags must answer no one.
if (typeof metadata.kittyKeyboardFlags === 'number') {
await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags)
}
if (metadata.cwd !== undefined) {
state.emulator.setCwd(metadata.cwd)
}
@@ -5636,11 +6095,9 @@ export class OrcaRuntimeService {
this.headlessHydrationState.set(ptyId, 'pending')
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state: RuntimeHeadlessTerminal = {
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
outputSequence: 0,
writeChain: Promise.resolve()
}
// Why: hydration writes below never set forwardQueryReplies (main-side
// replay guard) — renderer-buffer snapshots can embed stale queries.
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
this.headlessTerminals.set(ptyId, state)
// Why: append the seed work to writeChain so live writes queued by
@@ -5668,9 +6125,14 @@ export class OrcaRuntimeService {
if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) {
state.emulator.resize(ptyDims.cols, ptyDims.rows)
}
if (rendered.lastTitle) {
state.emulator.setLastTitle(rendered.lastTitle)
this.applySeededAgentStatus(ptyId, rendered.lastTitle)
// Why: the renderer xterm no longer sees synthetic hook title frames
// (they feed main's tracker only), so its serializer lastTitle can be
// stale here. Prefer main's tracked title; the renderer's is only the
// seed when main has observed none (fresh relaunch, cold tracker).
const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle
if (seedTitle) {
state.emulator.setLastTitle(seedTitle)
this.applySeededAgentStatus(ptyId, seedTitle)
}
} catch {
// Hydration is best-effort. Live writes continue via the same
@@ -5691,6 +6153,11 @@ export class OrcaRuntimeService {
if (!title) {
return
}
// Why: a relaunched main starts its per-PTY title tracker cold — without
// this seed it misses the parked working→idle completion and never arms
// the stale-title timer for a persisted 'working' title. Seeding no-ops
// once a live title was observed, so live state always wins.
this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title)
const status = detectAgentStatusFromTitle(title)
const pty = this.ptysById.get(ptyId)
if (pty) {
@@ -5711,11 +6178,28 @@ export class OrcaRuntimeService {
}
}
private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void {
/** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at
* ingestion only — never re-read at reply time. */
private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean {
return shouldModelAnswerHiddenPtyQueries({
ptyId,
settings: this.store?.getSettings(),
hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId)
})
}
private trackHeadlessTerminalData(
ptyId: string,
data: string,
outputSequence: number,
forwardQueryReplies = false
): void {
const state = this.getOrCreateHeadlessTerminal(ptyId)
state.writeChain = state.writeChain
.then(async () => {
await state.emulator.write(data)
// Why: the ingestion-time ownership decision is closed over this
// chain link; async scheduling cannot retroactively change it.
await state.emulator.write(data, { forwardQueryReplies })
state.outputSequence = outputSequence
})
.catch(() => {
@@ -5724,17 +6208,65 @@ export class OrcaRuntimeService {
})
}
/** 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. */
private createPtyHeadlessTerminalState(
ptyId: string,
dims: { cols: number; rows: number }
): RuntimeHeadlessTerminal {
let state: RuntimeHeadlessTerminal | null = null
const emulator = new HeadlessEmulator({
cols: dims.cols,
rows: dims.rows,
// 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) {
// 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)
}
state = { emulator, outputSequence: 0, writeChain: Promise.resolve() }
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. */
private ensureNativeWindowsConptyDa1Override(ptyId: string): void {
if (isNativeWindowsConptyPty(ptyId)) {
this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride()
}
}
private 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: RuntimeHeadlessTerminal = {
emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }),
outputSequence: 0,
writeChain: Promise.resolve()
}
const state = this.createPtyHeadlessTerminalState(ptyId, size)
this.headlessTerminals.set(ptyId, state)
return state
}
@@ -5810,7 +6342,9 @@ export class OrcaRuntimeService {
// If renderer serialization races reload/unmount, callers can still use
// their existing null fallback paths.
}
return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null
return rendererSnapshot
? this.preferTrackedLastTitle(ptyId, { ...rendererSnapshot, source: 'renderer' as const })
: null
}
private async withVisibleSnapshotFallback(
@@ -5898,20 +6432,20 @@ export class OrcaRuntimeService {
const snapshot = state.emulator.getSnapshot({ scrollbackRows })
const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi
return data.length > 0 || opts.includeEmpty === true
? {
? this.preferTrackedLastTitle(ptyId, {
data,
cols: snapshot.cols,
rows: snapshot.rows,
cwd: snapshot.cwd,
lastTitle: snapshot.lastTitle,
seq: state.outputSequence,
source: 'headless',
source: 'headless' as const,
oscLinks: snapshot.oscLinks,
// Why: lets the renderer skip the destructive scrollback clear when
// restoring an alt-screen snapshot — clearing wipes xterm's own
// history that the TUI relies on for scroll-up after a tab return.
alternateScreen: isAlternateScreen
}
})
: null
}
@@ -5922,6 +6456,10 @@ export class OrcaRuntimeService {
return
}
this.headlessTerminals.delete(ptyId)
// Why: queued chain links still parse below before the emulator disposes;
// sever the reply sink now so they cannot write to a respawned PTY that
// reused this id (belt to the sink's state-identity check).
state.emulator.disableQueryReplyForwarding()
state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose())
}
@@ -6726,6 +7264,7 @@ export class OrcaRuntimeService {
serveSimStateWatcher.unbindPty(ptyId)
// Clean up new mobile state for this PTY
this.mobileSubscribers.delete(ptyId)
this.remoteTerminalViewSubscriberCounts.delete(ptyId)
this.mobileDisplayModes.delete(ptyId)
this.resizeListeners.delete(ptyId)
this.lastRendererSizes.delete(ptyId)
@@ -6733,6 +7272,8 @@ export class OrcaRuntimeService {
this.clearWaitBlockedCheckState(ptyId)
this.ptyOutputSequenceById.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
this.oscTitleScanTailByPtyId.delete(ptyId)
this.clearAgentRowSnapshotsForPty(ptyId)
// Why: a Claude agent-team leader whose PTY exits naturally (agent finished,
@@ -17743,6 +18284,8 @@ export class OrcaRuntimeService {
this.clearWaitBlockedCheckState(ptyId)
this.ptyOutputSequenceById.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
this.oscTitleScanTailByPtyId.delete(ptyId)
this.clearAgentRowSnapshotsForPty(ptyId)
const handle = this.handleByPtyId.get(ptyId)
+329 -6
View File
@@ -40,6 +40,9 @@ const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024
const TERMINAL_OUTPUT_FLUSH_MS = 5
// Why: output batches become binary stream payloads; byte size is the transport cost.
const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024
// Why: remote clients can apply output pressure without pausing runtime PTY ingestion.
const TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES = 512 * 1024
const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024
// Why: pending output is held for later binary frames, so cap the encoded
// payload bytes rather than UTF-16 code units.
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
@@ -82,7 +85,13 @@ type TerminalMultiplexStream = {
ptyId: string
client: TerminalViewportClient | undefined
isMobile: boolean
ackOutput: boolean
ackInFlightBytes: number
buffering: boolean
ackPendingOutput: TerminalOutputFrameChunk[]
ackPendingOutputBytes: number
ackPendingOutputOverflowed: boolean
ackRecoverySnapshotInFlight: boolean
pendingOutput: TerminalOutputChunk[]
pendingOutputBytes: number
pendingOutputOverflowed: boolean
@@ -344,6 +353,26 @@ function getOutputAfterSnapshotSeq(
return chunk.data.slice(snapshotSeq - chunkStartSeq)
}
function appendAckPendingOutput(
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
): void {
stream.ackPendingOutput.push(chunk)
stream.ackPendingOutputBytes += chunk.bytes.byteLength
let omittedChunkCount = 0
while (
stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES &&
omittedChunkCount < stream.ackPendingOutput.length
) {
stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength
omittedChunkCount += 1
}
if (omittedChunkCount > 0) {
stream.ackPendingOutput.splice(0, omittedChunkCount)
stream.ackPendingOutputOverflowed = true
}
}
function trimPendingOutputToBudget(
pendingOutput: TerminalOutputChunk[],
pendingOutputBytes: number
@@ -370,6 +399,43 @@ function measureTerminalStreamByteLength(
return measureClipboardTextByteLength(data, options)
}
function trimPendingOutputCoveredBySnapshot(
pendingOutput: TerminalOutputChunk[],
snapshotSeq: number | undefined
): { chunks: TerminalOutputChunk[]; bytes: number } {
if (typeof snapshotSeq !== 'number') {
return {
chunks: pendingOutput,
bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0)
}
}
const chunks: TerminalOutputChunk[] = []
let bytes = 0
for (const chunk of pendingOutput) {
const chunkSeq = chunk.meta?.seq
const rawLength = chunk.meta?.rawLength ?? chunk.data.length
if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) {
chunks.push(chunk)
bytes += chunk.bytes
continue
}
const startSeq = chunkSeq - rawLength
if (snapshotSeq >= chunkSeq) {
continue
}
if (snapshotSeq <= startSeq) {
chunks.push(chunk)
bytes += chunk.bytes
continue
}
const data = chunk.data.slice(snapshotSeq - startSeq)
const slicedBytes = terminalStreamByteLength(data)
chunks.push({ data, bytes: slicedBytes, meta: undefined })
bytes += slicedBytes
}
return { chunks, bytes }
}
function terminalStreamByteLength(data: string): number {
return measureTerminalStreamByteLength(data).byteLength
}
@@ -728,7 +794,16 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
type: z.enum(['mobile', 'desktop']).default('desktop')
})
.optional(),
viewport: TerminalViewport.optional()
viewport: TerminalViewport.optional(),
capabilities: z
.object({
ackOutput: z.literal(1).optional()
})
.optional()
})
const TerminalMultiplexAckFrame = z.object({
bytes: z.number().int().nonnegative()
})
const TerminalMultiplexSnapshotRequestFrame = z.object({
@@ -1188,6 +1263,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let closed = false
let cursor = 0
const streams = new Map<number, TerminalMultiplexStream>()
let ackTotalInFlightBytes = 0
let resolveMultiplex = (): void => {}
const multiplexClosed = new Promise<void>((resolve) => {
resolveMultiplex = resolve
@@ -1231,6 +1307,136 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
})
)
}
const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => {
if (!stream.ackOutput) {
return true
}
return (
stream.ackInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES &&
ackTotalInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES
)
}
const sendAckGatedOutput = (
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
): void => {
sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
if (stream.ackOutput) {
stream.ackInFlightBytes += chunk.bytes.byteLength
ackTotalInFlightBytes += chunk.bytes.byteLength
}
}
const queueOrSendOutput = (
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
): void => {
if (closed || streams.get(stream.streamId) !== stream) {
return
}
if (
stream.ackPendingOutputOverflowed ||
stream.ackPendingOutput.length > 0 ||
!canSendAckGatedOutput(stream, chunk.bytes.byteLength)
) {
appendAckPendingOutput(stream, chunk)
return
}
sendAckGatedOutput(stream, chunk)
}
const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise<void> => {
if (
closed ||
streams.get(stream.streamId) !== stream ||
stream.ackRecoverySnapshotInFlight
) {
return
}
stream.ackRecoverySnapshotInFlight = true
try {
const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0)
if (closed || streams.get(stream.streamId) !== stream) {
return
}
const size = runtime.getTerminalSize(stream.ptyId)
const displayMode = runtime.getMobileDisplayMode(stream.ptyId)
// Why: dropped ACK-pending output means live frames are no longer a
// complete replay. Send a fresh model snapshot before resuming output.
// Why: truncated marks an unusable snapshot, and clients discard
// those. The recovery snapshot must be applied to cover dropped
// output, so it is only truncated when serialization failed.
sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), {
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
reason: 'ack-pending-overflow',
seq: serialized?.seq,
source: serialized?.source,
truncated: !serialized,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
data: serialized?.data ?? ''
})
if (serialized && typeof serialized.seq === 'number') {
// Why: retained chunks queued before the snapshot serialized are
// already contained in it; replaying them would duplicate output.
const snapshotSeq = serialized.seq
const retained = stream.ackPendingOutput.filter(
(chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq)
)
stream.ackPendingOutput = retained
stream.ackPendingOutputBytes = retained.reduce(
(total, chunk) => total + chunk.bytes.byteLength,
0
)
}
stream.ackPendingOutputOverflowed = false
} catch (error) {
sendStreamError(
stream.streamId,
error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.'
)
} finally {
if (streams.get(stream.streamId) === stream) {
stream.ackRecoverySnapshotInFlight = false
flushAckPendingOutput(stream)
}
}
}
const flushAckPendingOutput = (stream: TerminalMultiplexStream): void => {
if (stream.ackPendingOutputOverflowed) {
void sendAckRecoverySnapshot(stream)
return
}
let flushed = 0
while (
flushed < stream.ackPendingOutput.length &&
canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength)
) {
sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)
flushed += 1
}
if (flushed > 0) {
stream.ackPendingOutput.splice(0, flushed)
stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce(
(total, pending) => total + pending.bytes.byteLength,
0
)
}
}
const flushAllAckPendingOutput = (): void => {
for (const stream of streams.values()) {
flushAckPendingOutput(stream)
}
}
const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => {
if (!stream.ackOutput || bytes <= 0) {
return
}
const acknowledged = Math.min(stream.ackInFlightBytes, bytes)
stream.ackInFlightBytes -= acknowledged
ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged)
flushAllAckPendingOutput()
}
const detachStream = (streamId: number, emitEnd: boolean): void => {
const stream = streams.get(streamId)
if (!stream) {
@@ -1238,12 +1444,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
stream.outputBatcher.flush()
stream.outputBatcher.dispose()
ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes)
stream.ackInFlightBytes = 0
stream.ackPendingOutput = []
stream.ackPendingOutputBytes = 0
stream.ackPendingOutputOverflowed = false
stream.ackRecoverySnapshotInFlight = false
stream.unsubscribeData()
stream.unsubscribeResize()
stream.unsubscribeFit()
stream.unsubscribeDriver()
stream.unregisterBinaryHandler()
streams.delete(streamId)
flushAllAckPendingOutput()
if (stream.isMobile && stream.client?.id) {
runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id)
}
@@ -1273,6 +1486,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
detachStream(stream.streamId, false)
return
}
if (frame.opcode === TerminalStreamOpcode.Ack) {
const parsed = TerminalMultiplexAckFrame.safeParse(
decodeTerminalStreamJson<unknown>(frame.payload) ?? {}
)
if (parsed.success) {
acknowledgeOutput(stream, parsed.data.bytes)
}
return
}
if (frame.opcode === TerminalStreamOpcode.Input) {
const text = decodeTerminalStreamText(frame.payload)
if (!text) {
@@ -1428,6 +1650,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
emit({ type: 'end', streamId: request.streamId })
return
}
if (closed) {
return
}
// Why: a competing subscribe for the same streamId can fully register
// while this one awaited the PTY id above. Overwriting it in
// `streams` would orphan its data/view-subscriber registrations — a
// leaked view subscriber permanently silences the model query
// responder (terminal-query-authority.md). Detach it so every
// registration stays release-balanced.
detachStream(request.streamId, false)
const ptyId = leaf.ptyId
const stream: TerminalMultiplexStream = {
@@ -1436,7 +1668,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
ptyId,
client: request.client,
isMobile,
ackOutput: request.capabilities?.ackOutput === 1,
ackInFlightBytes: 0,
buffering: true,
ackPendingOutput: [],
ackPendingOutputBytes: 0,
ackPendingOutputOverflowed: false,
ackRecoverySnapshotInFlight: false,
pendingOutput: [],
pendingOutputBytes: 0,
pendingOutputOverflowed: false,
@@ -1444,7 +1682,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
resizeGeneration: 0,
outputBatcher: createTerminalOutputBatcher((data, meta) => {
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
queueOrSendOutput(stream, chunk)
}
}),
unsubscribeData: () => {},
@@ -1459,7 +1697,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
)
try {
stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed || streams.get(request.streamId) !== stream) {
return
}
@@ -1469,6 +1707,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
stream.outputBatcher.push(data, meta)
})
// Why: a multiplexed stream feeds a remote xterm view that answers
// terminal queries with view authority; the main model responder
// yields while it is attached (terminal-query-authority.md).
// Wrapped into unsubscribeData so every detach path releases it.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
stream.unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
if (isMobile && request.client?.id) {
await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport)
@@ -1626,6 +1873,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
})
} catch (error) {
// Why the ownership check: a newer subscribe may own this streamId
// now (it detached and released this stream on arrival). Detaching
// or erroring the slot here would tear down the successor's live
// registrations instead of this stream's.
if (streams.get(request.streamId) !== stream) {
return
}
detachStream(request.streamId, false)
sendStreamError(request.streamId, error instanceof Error ? error.message : String(error))
emit({ type: 'end', streamId: request.streamId })
@@ -1726,9 +1980,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const outputBatcher = createTerminalOutputBatcher((chunk) => {
emit({ type: 'data', chunk })
})
const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => {
outputBatcher.push(data)
})
// Why: this legacy JSON stream can feed a live xterm view too
// (older web/desktop subscribers), so it conservatively registers
// as a remote view subscriber. For read-only watchers the cost is
// a withheld model reply — the pre-Phase-5 status quo — which is
// strictly safer than a double reply under a view consumer.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
const unsubscribeData = (): void => {
releaseViewSubscriber()
unsubscribeStreamData()
}
const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
outputBatcher.flush()
emit({
@@ -1766,8 +2030,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// resize re-stream so it only fires on an actual width change.
let lastResizeCols: number | undefined
let resizeGeneration = 0
const pendingOutput: TerminalOutputChunk[] = []
let pendingOutput: TerminalOutputChunk[] = []
let pendingOutputBytes = 0
let pendingOutputOverflowed = false
let unsubscribeData = (): void => {}
let unsubscribeResize = (): void => {}
let unsubscribeFit = (): void => {}
@@ -1870,7 +2135,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed) {
return
}
@@ -1886,10 +2151,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
pendingOutputBytes = trimmed.bytes
pendingOutputOverflowed ||= trimmed.overflowed
return
}
outputBatcher?.push(data, meta)
})
// Why: binary subscribe streams feed remote xterm views (mobile and
// binary-capable desktop clients) that answer queries with view
// authority; the main model responder yields while attached.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
const read = await runtime.readTerminal(params.terminal)
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
@@ -1938,6 +2212,55 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// Why: baseline for resize re-stream gating; the client already
// rewrapped to these cols via the initial snapshot replay.
lastResizeCols = serialized?.cols ?? size?.cols
let recoveryAttempts = 0
// Why: if the bounded pre-subscribe tail overflowed, only a fresh
// model snapshot can cover the dropped middle without replay gaps.
while (pendingOutputOverflowed && recoveryAttempts < 2) {
pendingOutputOverflowed = false
recoveryAttempts += 1
const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (closed) {
return
}
if (!recovery) {
break
}
// Why: without an output seq (renderer-source fallback) covered
// chunks cannot be trimmed exactly, and the renderer view may lag
// the queued chunks under backpressure. Keep the bounded replay
// instead of applying an unverifiable snapshot.
if (typeof recovery.seq !== 'number') {
break
}
// Why: shipped mobile clients drop a second scrollback snapshot for
// an initialized handle but apply a resized snapshot inline by
// re-initializing xterm with fresh scrollback. Omit seq on the wire
// so the client's layout-seq staleness filter is not polluted with
// output-byte sequences.
const recoveryStats = sendSnapshotFrames(sendFrame, {
kind: 'resized',
cols: recovery.cols,
rows: recovery.rows,
displayMode,
reason: 'pending-output-overflow',
source: recovery.source,
truncated: false,
truncatedByByteBudget: recovery.truncatedByByteBudget,
data: recovery.data
})
console.log('[mobile-terminal-stream] recovery snapshot', {
terminal: params.terminal,
streamId,
reason: 'pending-output-overflow',
bytes: recoveryStats.bytes,
chunks: recoveryStats.chunks,
scrollbackRows: recovery.scrollbackRows,
truncatedByByteBudget: recovery.truncatedByByteBudget === true
})
const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq)
pendingOutput = trimmed.chunks
pendingOutputBytes = trimmed.bytes
}
buffering = false
for (const item of pendingOutput.splice(0)) {
const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
+3
View File
@@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
// Why: subscribe streams register as remote view subscribers for Phase-5
// query-authority suppression (terminal-query-authority.md).
registerRemoteTerminalViewSubscriber: () => () => {},
...overrides
} as OrcaRuntimeService
}
@@ -18,6 +18,9 @@ import {
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
// Why: every multiplex stream registers as a remote view subscriber for
// Phase-5 query-authority suppression (terminal-query-authority.md).
registerRemoteTerminalViewSubscriber: () => () => {},
...overrides
} as OrcaRuntimeService
}
@@ -468,6 +471,697 @@ describe('terminal multiplex RPC', () => {
}
})
it('holds ACK-capable multiplex output over budget until the client acknowledges bytes', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const dataListenerRef: {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({
data: 'snapshot',
cols: 120,
rows: 40
}),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn(
(
_: string,
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
) => {
dataListenerRef.current = listener
return vi.fn()
}
),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-ack-gated',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 16,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
viewport: { cols: 120, rows: 40 },
capabilities: { ackOutput: 1 }
})
})
)!
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
binaryFrames.splice(0)
const output = 'x'.repeat(700 * 1024)
dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length })
const initialOutputFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
const initialBytes = initialOutputFrames.reduce(
(total, frame) => total + (frame?.payload.byteLength ?? 0),
0
)
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
expect(initialOutputFrames.length).toBeGreaterThan(0)
const initialOutput = initialOutputFrames
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
expect(initialOutput.length).toBeLessThan(output.length)
handlers.get(16)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
streamId: 16,
seq: 2,
payload: encodeTerminalStreamText('still interactive\r')
})
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'still interactive\r',
enter: false,
interrupt: false
})
)
handlers.get(16)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Ack,
streamId: 16,
seq: 3,
payload: encodeTerminalStreamJson({ bytes: initialBytes })
})
)!
)
const flushedOutputFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
expect(flushedOutputFrames.length).toBeGreaterThan(initialOutputFrames.length)
runtime.cleanupSubscription('terminal-multiplex:conn-ack-gated')
await dispatchPromise
})
it('releases shared ACK budget to other stalled multiplex streams', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const dataListeners = new Map<
string,
(data: string, meta?: { seq?: number; rawLength?: number }) => void
>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn((terminal: string) => ({
ptyId: terminal.replace('terminal-', 'pty-')
})),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(async (ptyId: string) => ({
data: `snapshot-${ptyId}`,
cols: 120,
rows: 40
})),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn(
(
ptyId: string,
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
) => {
dataListeners.set(ptyId, listener)
return vi.fn()
}
),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-ack-shared-budget',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
const streamIds = [21, 22, 23, 24, 25, 26]
for (const streamId of streamIds) {
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: streamId,
payload: encodeTerminalStreamJson({
streamId,
terminal: `terminal-${streamId - 20}`,
client: { id: `desktop-${streamId}`, type: 'desktop' },
viewport: { cols: 120, rows: 40 },
capabilities: { ackOutput: 1 }
})
})
)!
)
}
await vi.waitFor(() =>
expect(
messages
.map((msg) => JSON.parse(msg).result)
.filter((result) => result?.type === 'subscribed')
).toHaveLength(streamIds.length)
)
await vi.waitFor(() => expect(dataListeners.size).toBe(streamIds.length))
binaryFrames.splice(0)
const fillerOutput = 'f'.repeat(480 * 1024)
for (let index = 1; index <= 4; index += 1) {
dataListeners.get(`pty-${index}`)?.(fillerOutput, {
seq: fillerOutput.length,
rawLength: fillerOutput.length
})
}
const stalledOutput = 's'.repeat(700 * 1024)
dataListeners.get('pty-5')?.(stalledOutput, {
seq: stalledOutput.length,
rawLength: stalledOutput.length
})
dataListeners.get('pty-6')?.(stalledOutput, {
seq: stalledOutput.length,
rawLength: stalledOutput.length
})
const initialOutputFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
const initialBytesByStream = new Map<number, number>()
for (const frame of initialOutputFrames) {
if (!frame) {
continue
}
initialBytesByStream.set(
frame.streamId,
(initialBytesByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength
)
}
const initialBytes = initialOutputFrames.reduce(
(total, frame) => total + (frame?.payload.byteLength ?? 0),
0
)
expect(initialBytes).toBeLessThanOrEqual(2 * 1024 * 1024)
expect(initialBytesByStream.get(21)).toBe(480 * 1024)
expect(initialBytesByStream.get(22)).toBe(480 * 1024)
expect(initialBytesByStream.get(23)).toBe(480 * 1024)
expect(initialBytesByStream.get(24)).toBe(480 * 1024)
expect(initialBytesByStream.get(25)).toBeGreaterThan(0)
expect(initialBytesByStream.get(26) ?? 0).toBe(0)
handlers.get(26)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
streamId: 26,
seq: 200,
payload: encodeTerminalStreamText('remote-still-interactive\r')
})
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-6', {
text: 'remote-still-interactive\r',
enter: false,
interrupt: false
})
)
const frameCountBeforeAck = binaryFrames.length
handlers.get(21)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Ack,
streamId: 21,
seq: 201,
payload: encodeTerminalStreamJson({ bytes: initialBytesByStream.get(21) ?? 0 })
})
)!
)
await vi.waitFor(() =>
expect(
binaryFrames
.slice(frameCountBeforeAck)
.map((frame) => decodeTerminalStreamFrame(frame))
.some((frame) => {
if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) {
return false
}
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
return payload?.reason === 'ack-pending-overflow'
})
).toBe(true)
)
const framesAfterAck = binaryFrames
.slice(frameCountBeforeAck)
.map((frame) => decodeTerminalStreamFrame(frame))
const snapshotStartIndex = framesAfterAck.findIndex((frame) => {
if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) {
return false
}
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
return payload?.reason === 'ack-pending-overflow'
})
const outputFramesAfterAck = framesAfterAck.filter(
(frame) => frame?.opcode === TerminalStreamOpcode.Output
)
const bytesAfterAckByStream = new Map<number, number>()
for (const frame of outputFramesAfterAck) {
if (!frame) {
continue
}
bytesAfterAckByStream.set(
frame.streamId,
(bytesAfterAckByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength
)
}
expect(snapshotStartIndex).toBeGreaterThanOrEqual(0)
expect(
framesAfterAck
.filter((frame) => frame?.streamId === 25 && frame.opcode === TerminalStreamOpcode.Output)
.every((frame) => framesAfterAck.indexOf(frame) > snapshotStartIndex)
).toBe(true)
expect(bytesAfterAckByStream.get(25) ?? 0).toBeGreaterThan(0)
expect(bytesAfterAckByStream.get(21) ?? 0).toBe(0)
expect(
outputFramesAfterAck.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
).toBeLessThanOrEqual(initialBytesByStream.get(21) ?? 0)
runtime.cleanupSubscription('terminal-multiplex:conn-ack-shared-budget')
await dispatchPromise
})
it('caps stalled ACK output and snapshots before resuming retained tail frames', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const dataListenerRef: {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
.mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 })
.mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: 99 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn(
(
_: string,
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
) => {
dataListenerRef.current = listener
return vi.fn()
}
),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-ack-overflow',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 17,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
viewport: { cols: 120, rows: 40 },
capabilities: { ackOutput: 1 }
})
})
)!
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
binaryFrames.splice(0)
const output = 'x'.repeat(3 * 1024 * 1024)
dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length })
const initialOutputFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
const initialBytes = initialOutputFrames.reduce(
(total, frame) => total + (frame?.payload.byteLength ?? 0),
0
)
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
handlers.get(17)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
streamId: 17,
seq: 2,
payload: encodeTerminalStreamText('still interactive\r')
})
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'still interactive\r',
enter: false,
interrupt: false
})
)
binaryFrames.splice(0)
handlers.get(17)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Ack,
streamId: 17,
seq: 3,
payload: encodeTerminalStreamJson({ bytes: initialBytes })
})
)!
)
await vi.waitFor(() =>
expect(
binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.some((frame) => {
if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) {
return false
}
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
return payload?.reason === 'ack-pending-overflow'
})
).toBe(true)
)
const drainFrames = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame))
const recoveryStartIndex = drainFrames.findIndex((frame) => {
if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) {
return false
}
const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload)
return payload?.reason === 'ack-pending-overflow'
})
const firstOutputAfterAckIndex = drainFrames.findIndex(
(frame) => frame?.opcode === TerminalStreamOpcode.Output
)
expect(recoveryStartIndex).toBeGreaterThanOrEqual(0)
// Why: clients discard truncated snapshots; a usable recovery snapshot
// must not be marked truncated or the dropped output gap is permanent.
expect(
decodeTerminalStreamJson<{ truncated?: boolean }>(drainFrames[recoveryStartIndex]!.payload)
?.truncated
).toBe(false)
expect(firstOutputAfterAckIndex).toBeGreaterThan(recoveryStartIndex)
expect(
drainFrames
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
).toBe('recovered snapshot')
const outputBytesAfterRecovery = drainFrames
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
expect(outputBytesAfterRecovery).toBeLessThanOrEqual(256 * 1024)
runtime.cleanupSubscription('terminal-multiplex:conn-ack-overflow')
await dispatchPromise
})
it('trims recovery-covered ACK pending output instead of replaying it', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const dataListenerRef: {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
const floodedChars = 3 * 1024 * 1024
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
.mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 })
// Why: the recovery snapshot seq covers the entire flood, so every
// retained pending chunk is already contained in the snapshot.
.mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: floodedChars }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn(
(
_: string,
listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void
) => {
dataListenerRef.current = listener
return vi.fn()
}
),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-ack-trim',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 31,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
viewport: { cols: 120, rows: 40 },
capabilities: { ackOutput: 1 }
})
})
)!
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
binaryFrames.splice(0)
const output = 'x'.repeat(floodedChars)
dataListenerRef.current?.(output, { seq: floodedChars, rawLength: floodedChars })
const initialBytes = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
expect(initialBytes).toBeLessThanOrEqual(512 * 1024)
binaryFrames.splice(0)
handlers.get(31)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Ack,
streamId: 31,
seq: 2,
payload: encodeTerminalStreamJson({ bytes: initialBytes })
})
)!
)
await vi.waitFor(() =>
expect(
binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd)
).toBe(true)
)
const framesAfterRecovery = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame))
expect(
framesAfterRecovery
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
).toBe('recovered snapshot')
// Why: every retained chunk is covered by the recovery snapshot seq;
// replaying any of them would duplicate snapshot content.
expect(
framesAfterRecovery.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
).toEqual([])
binaryFrames.splice(0)
const fresh = 'fresh-after-recovery\r\n'
dataListenerRef.current?.(fresh, {
seq: floodedChars + fresh.length,
rawLength: fresh.length
})
await vi.waitFor(() => {
const freshOutput = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
expect(freshOutput).toBe(fresh)
})
runtime.cleanupSubscription('terminal-multiplex:conn-ack-trim')
await dispatchPromise
})
it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
@@ -1372,6 +2066,226 @@ describe('terminal multiplex RPC', () => {
await dispatchPromise
})
it('keeps view-subscriber releases balanced when a same-streamId subscribe overwrites a blocked one', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
// Why: a leaked registration permanently suppresses the model query
// responder (terminal-query-authority.md) — the count must return to 0.
let viewSubscriberCount = 0
let leafResolved = false
let resolveFirstWait: (ptyId: string) => void = () => {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn(() => (leafResolved ? { ptyId: 'pty-1' } : { ptyId: null })),
waitForLeafPtyId: vi.fn(
() =>
new Promise<string>((resolve) => {
resolveFirstWait = resolve
})
),
registerRemoteTerminalViewSubscriber: vi.fn(() => {
viewSubscriberCount += 1
let released = false
return () => {
if (!released) {
released = true
viewSubscriberCount -= 1
}
}
}),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
handleMobileSubscribe: vi.fn().mockResolvedValue(undefined),
handleMobileUnsubscribe: vi.fn(),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-overwrite',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
const sendSubscribe = (): void => {
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 7,
terminal: 'terminal-1',
client: { id: 'phone-1', type: 'mobile' }
})
})
)!
)
}
// Subscribe A blocks in waitForLeafPtyId; subscribe B (same streamId)
// then resolves the leaf directly and fully registers.
sendSubscribe()
await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled())
leafResolved = true
sendSubscribe()
await vi.waitFor(() =>
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
1
)
)
// A resumes and takes the slot; B's registration must be released, not
// orphaned by the overwrite.
resolveFirstWait('pty-1')
await vi.waitFor(() =>
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
2
)
)
handlers.get(7)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Unsubscribe,
streamId: 7,
seq: 2,
payload: new Uint8Array()
})
)!
)
expect(viewSubscriberCount).toBe(0)
cleanups.get('terminal-multiplex:conn-overwrite')?.()
await dispatchPromise
})
it('keeps an evicted subscribe error from detaching the successor stream', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
let viewSubscriberCount = 0
const mobileSubscribeWaiters: {
resolve: () => void
reject: (error: Error) => void
}[] = []
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
registerRemoteTerminalViewSubscriber: vi.fn(() => {
viewSubscriberCount += 1
let released = false
return () => {
if (!released) {
released = true
viewSubscriberCount -= 1
}
}
}),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
handleMobileSubscribe: vi.fn(
() =>
new Promise<boolean>((resolve, reject) => {
mobileSubscribeWaiters.push({ resolve: () => resolve(true), reject })
})
),
handleMobileUnsubscribe: vi.fn(),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-evicted-error',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
const sendSubscribe = (): void => {
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 9,
terminal: 'terminal-1',
client: { id: 'phone-1', type: 'mobile' }
})
})
)!
)
}
// A registers, then blocks in handleMobileSubscribe. B (same streamId)
// evicts A on arrival and completes its own registration.
sendSubscribe()
await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(1))
sendSubscribe()
await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(2))
mobileSubscribeWaiters[1]!.resolve()
await vi.waitFor(() =>
expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength(
1
)
)
expect(viewSubscriberCount).toBe(1)
// A's pending await now rejects. The evicted stream must not detach the
// successor that owns the slot.
mobileSubscribeWaiters[0]!.reject(new Error('mobile_subscribe_failed'))
await Promise.resolve()
await Promise.resolve()
expect(viewSubscriberCount).toBe(1)
cleanups.get('terminal-multiplex:conn-evicted-error')?.()
await dispatchPromise
expect(viewSubscriberCount).toBe(0)
})
it('bounds live output queued while a multiplex snapshot is loading', async () => {
vi.useFakeTimers()
try {
@@ -15,6 +15,9 @@ import {
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
// Why: subscribe streams register as remote view subscribers for Phase-5
// query-authority suppression (terminal-query-authority.md).
registerRemoteTerminalViewSubscriber: () => () => {},
...overrides
} as OrcaRuntimeService
}
@@ -15,6 +15,9 @@ import {
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
// Why: subscribe streams register as remote view subscribers for Phase-5
// query-authority suppression (terminal-query-authority.md).
registerRemoteTerminalViewSubscriber: () => () => {},
...overrides
} as OrcaRuntimeService
}
@@ -331,21 +334,35 @@ describe('terminal subscribe buffering', () => {
await dispatchPromise
})
it('bounds legacy binary output queued while the initial snapshot is serializing', async () => {
it('recovers binary output overflow queued while the initial snapshot is serializing', async () => {
vi.useFakeTimers()
try {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
const dataListenerRef: { current?: (data: string) => void } = {}
let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {}
const dataListenerRef: {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
const snapshotResolvers: ((value: {
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
}) => void)[] = []
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
resolveSnapshot = resolve
new Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
}>((resolve) => {
snapshotResolvers.push(resolve)
})
),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
@@ -386,26 +403,63 @@ describe('terminal subscribe buffering', () => {
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
const shiftSpy = vi.spyOn(Array.prototype, 'shift')
let seq = 0
for (let index = 0; index < 400; index += 1) {
dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`)
const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}`
seq += data.length
dataListenerRef.current?.(data, { seq, rawLength: data.length })
}
const shiftCallCount = shiftSpy.mock.calls.length
shiftSpy.mockRestore()
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled())
resolveSnapshot({ data: '', cols: 120, rows: 40 })
snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' })
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2))
snapshotResolvers[1]?.({
data: 'recovered after overflow\r\n',
cols: 120,
rows: 40,
seq,
source: 'headless'
})
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
await vi.runOnlyPendingTimersAsync()
const output = binaryFrames
const decodedFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.filter((frame): frame is NonNullable<typeof frame> => frame !== null)
const snapshotStarts = decodedFrames.filter(
(frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart
)
const decodedStarts = snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))
// Why: shipped mobile clients only apply a mid-session snapshot when it
// arrives as a resized frame; a second scrollback frame is dropped.
// Why seq 0: the merged stream prefers the snapshot's own output seq
// (post-restore live-chunk reconciliation) over the layout seq.
expect(decodedStarts).toEqual([
expect.objectContaining({ kind: 'scrollback', seq: 0 }),
expect.objectContaining({
kind: 'resized',
reason: 'pending-output-overflow',
source: 'headless'
})
])
// Why: output-byte sequences must not pollute the client layout-seq
// staleness filter.
expect(decodedStarts[1]).not.toHaveProperty('seq')
const snapshotText = decodedFrames
.filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => decodeTerminalStreamText(frame.payload))
.join('')
const output = decodedFrames
.filter((frame) => frame.opcode === TerminalStreamOpcode.Output)
.map((frame) => decodeTerminalStreamText(frame.payload))
.join('')
expect(output.length).toBeLessThanOrEqual(256 * 1024)
expect(output).not.toContain('000')
expect(output).toContain('399')
expect(output).not.toContain('399')
expect(snapshotText).toContain('recovered after overflow')
expect(shiftCallCount).toBe(0)
runtime.cleanupSubscription('terminal-1:desktop-1')
@@ -531,4 +585,120 @@ describe('terminal subscribe buffering', () => {
runtime.cleanupSubscription('terminal-1:phone-1')
await dispatchPromise
})
it('keeps bounded replay when overflow recovery has no output seq', async () => {
vi.useFakeTimers()
try {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
const dataListenerRef: {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
const snapshotResolvers: ((value: {
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
}) => void)[] = []
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
new Promise<{
data: string
cols: number
rows: number
seq?: number
source?: 'headless' | 'renderer'
}>((resolve) => {
snapshotResolvers.push(resolve)
})
),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => {
dataListenerRef.current = listener
return vi.fn()
}),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateMobileViewport: vi.fn().mockResolvedValue(false)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
capabilities: { terminalBinaryStream: 1 }
}),
(msg) => messages.push(msg),
{
connectionId: 'conn-buffered-no-seq',
sendBinary: (bytes) => binaryFrames.push(bytes)
}
)
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
let seq = 0
for (let index = 0; index < 400; index += 1) {
const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}`
seq += data.length
dataListenerRef.current?.(data, { seq, rawLength: data.length })
}
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled())
snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' })
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2))
// Why: renderer-source snapshots carry no output seq, so covered chunks
// cannot be trimmed and the recovery snapshot must not be applied.
snapshotResolvers[1]?.({
data: 'renderer fallback snapshot\r\n',
cols: 120,
rows: 40,
source: 'renderer'
})
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
await vi.runOnlyPendingTimersAsync()
const decodedFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame): frame is NonNullable<typeof frame> => frame !== null)
const snapshotStarts = decodedFrames.filter(
(frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart
)
// Why seq 0: the merged stream prefers the snapshot's own output seq
// (post-restore live-chunk reconciliation) over the layout seq.
expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([
expect.objectContaining({ kind: 'scrollback', seq: 0 })
])
const output = decodedFrames
.filter((frame) => frame.opcode === TerminalStreamOpcode.Output)
.map((frame) => decodeTerminalStreamText(frame.payload))
.join('')
expect(output.length).toBeLessThanOrEqual(256 * 1024)
expect(output).not.toContain('000')
expect(output).toContain('399')
runtime.cleanupSubscription('terminal-1:desktop-1')
await dispatchPromise
} finally {
vi.useRealTimers()
}
})
})
+209
View File
@@ -13,6 +13,15 @@ import * as runtimeMetadataModule from './runtime-metadata'
import { readRuntimeMetadata } from './runtime-metadata'
import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc'
import { parsePairingCode } from '../../shared/pairing'
import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamText,
encodeTerminalStreamFrame,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../shared/terminal-stream-protocol'
import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto'
import { DeviceRegistry } from './device-registry'
@@ -2760,6 +2769,206 @@ describe('OrcaRuntimeRpcServer', () => {
}
})
it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const writes: { terminal: string; text: string }[] = []
const runtime = new OrcaRuntimeService(makeStore() as never)
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'multiplex-background-pty' })
.mockResolvedValueOnce({ id: 'multiplex-active-pty' })
runtime.setPtyController({
spawn,
write: (ptyId, data) => {
writes.push({ terminal: ptyId, text: data })
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
const phoneOffer = server.createPairingOffer({
address: '127.0.0.1',
name: 'phone',
scope: 'mobile'
})
expect(phoneOffer.available).toBe(true)
if (!phoneOffer.available) {
throw new Error('WebSocket pairing unavailable')
}
const pairing = parsePairingCode(phoneOffer.pairingUrl)
expect(pairing).toBeTruthy()
if (!pairing) {
throw new Error('Pairing URL did not parse')
}
const metadata = readRuntimeMetadata(userDataPath)
const laptopEndpoint = metadata!.transports[0]!.endpoint
const laptopAuthToken = metadata!.authToken
const worktree = 'id:repo-1::/tmp/worktree-a'
const backgroundLeafId = '11111111-1111-4111-8111-111111111111'
const activeLeafId = '22222222-2222-4222-8222-222222222222'
const backgroundCreateResponse = await sendRequest(laptopEndpoint, {
id: 'laptop_create_background',
authToken: laptopAuthToken,
method: 'terminal.create',
params: {
worktree,
command: 'background',
tabId: 'multiplex-background-tab',
leafId: backgroundLeafId
}
})
const activeCreateResponse = await sendRequest(laptopEndpoint, {
id: 'laptop_create_active',
authToken: laptopAuthToken,
method: 'terminal.create',
params: {
worktree,
command: 'active',
tabId: 'multiplex-active-tab',
leafId: activeLeafId,
activate: true
}
})
const backgroundTerminal = (backgroundCreateResponse.result as { terminal: { handle: string } })
.terminal
const activeTerminal = (activeCreateResponse.result as { terminal: { handle: string } })
.terminal
const responses: Record<string, unknown>[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const onError = vi.fn()
const subscription = await subscribeRemoteRuntimeRequest(
pairing,
'terminal.multiplex',
{},
15_000,
{
onResponse: (response) => responses.push(response as Record<string, unknown>),
onBinary: (bytes) => binaryFrames.push(bytes),
onError
}
)
try {
await vi.waitFor(() =>
expect(
responses.some(
(response) => (response.result as { type?: string } | undefined)?.type === 'ready'
)
).toBe(true)
)
subscription.sendBinary(
encodeTerminalStreamFrame({
seq: 1,
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
payload: encodeTerminalStreamJson({
streamId: 21,
terminal: backgroundTerminal.handle,
client: { id: 'desktop-background', type: 'desktop' },
capabilities: { ackOutput: 1 }
})
})
)
subscription.sendBinary(
encodeTerminalStreamFrame({
seq: 2,
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
payload: encodeTerminalStreamJson({
streamId: 22,
terminal: activeTerminal.handle,
client: { id: 'desktop-active', type: 'desktop' },
capabilities: { ackOutput: 1 }
})
})
)
await vi.waitFor(() => {
const subscribedStreamIds = responses
.map((response) => response.result as { type?: string; streamId?: number } | undefined)
.filter((result) => result?.type === 'subscribed')
.map((result) => result?.streamId)
expect(subscribedStreamIds).toEqual(expect.arrayContaining([21, 22]))
})
binaryFrames.splice(0)
const backgroundOutput = 'B'.repeat(700 * 1024)
runtime.onPtyData('multiplex-background-pty', backgroundOutput, 1)
await vi.waitFor(() => {
const backgroundFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
const backgroundBytes = backgroundFrames.reduce(
(total, frame) => total + (frame?.payload.byteLength ?? 0),
0
)
expect(backgroundBytes).toBeGreaterThan(0)
expect(backgroundBytes).toBeLessThan(backgroundOutput.length)
})
const frameCountBeforeActive = binaryFrames.length
runtime.onPtyData('multiplex-active-pty', 'ACTIVE_MULTIPLEX_READY\r\n', 2)
await vi.waitFor(() => {
const activeOutput = binaryFrames
.slice(frameCountBeforeActive)
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 22)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
expect(activeOutput).toContain('ACTIVE_MULTIPLEX_READY')
})
subscription.sendBinary(
encodeTerminalStreamFrame({
seq: 3,
opcode: TerminalStreamOpcode.Input,
streamId: 22,
payload: encodeTerminalStreamText('still interactive\r')
})
)
await vi.waitFor(() =>
expect(writes).toContainEqual({
terminal: 'multiplex-active-pty',
text: 'still interactive\r'
})
)
const backgroundBytesBeforeAck = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
subscription.sendBinary(
encodeTerminalStreamFrame({
seq: 4,
opcode: TerminalStreamOpcode.Ack,
streamId: 21,
payload: encodeTerminalStreamJson({ bytes: backgroundBytesBeforeAck })
})
)
await vi.waitFor(() => {
const backgroundBytesAfterAck = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
expect(backgroundBytesAfterAck).toBeGreaterThan(backgroundBytesBeforeAck)
})
expect(onError).not.toHaveBeenCalled()
} finally {
subscription.close()
await server.stop()
}
})
it('serves worktree.ps from the runtime summary builder', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
@@ -0,0 +1,140 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
_resetTerminalModelQueryAuthorityForTest,
clearNativeWindowsConptyPty,
isNativeWindowsConptyPty,
isNativeWindowsLocalPtySpawn,
isTerminalModelQueryAuthorityEnabled,
markNativeWindowsConptyPty,
shouldModelAnswerHiddenPtyQueries
} from './terminal-model-query-authority'
import {
_resetHiddenRendererPtyDeliveryGateForTest,
markHiddenRendererPty,
setRendererPtyDeliveryInterest
} from '../ipc/pty-hidden-delivery-gate'
const ALL_ON = {
terminalMainSideEffectAuthority: true,
terminalHiddenDeliveryGate: true,
terminalModelQueryAuthority: true
}
afterEach(() => {
_resetTerminalModelQueryAuthorityForTest()
_resetHiddenRendererPtyDeliveryGateForTest()
})
describe('isTerminalModelQueryAuthorityEnabled', () => {
it('defaults on, including for absent settings', () => {
expect(isTerminalModelQueryAuthorityEnabled(ALL_ON)).toBe(true)
expect(isTerminalModelQueryAuthorityEnabled({})).toBe(true)
expect(isTerminalModelQueryAuthorityEnabled(null)).toBe(true)
expect(isTerminalModelQueryAuthorityEnabled(undefined)).toBe(true)
})
it('is an independent off switch for the responder alone', () => {
expect(
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalModelQueryAuthority: false })
).toBe(false)
})
it('requires both Phase-4 gate switches — no marks exist without them', () => {
expect(
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalHiddenDeliveryGate: false })
).toBe(false)
expect(
isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalMainSideEffectAuthority: false })
).toBe(false)
})
})
describe('shouldModelAnswerHiddenPtyQueries', () => {
const answer = (ptyId: string, overrides: Record<string, boolean> = {}): boolean =>
shouldModelAnswerHiddenPtyQueries({
ptyId,
settings: { ...ALL_ON, ...overrides },
hasRemoteViewSubscriber: false
})
it('answers only for hidden-marked PTYs (the delivery decision is the reply decision)', () => {
expect(answer('pty-1')).toBe(false)
markHiddenRendererPty('pty-1')
expect(answer('pty-1')).toBe(true)
expect(answer('pty-other')).toBe(false)
})
it('yields to registered renderer delivery interest (chunk is delivered to a sidecar)', () => {
markHiddenRendererPty('pty-1')
setRendererPtyDeliveryInterest('pty-1', true)
expect(answer('pty-1')).toBe(false)
setRendererPtyDeliveryInterest('pty-1', false)
expect(answer('pty-1')).toBe(true)
})
it('yields while a remote view subscriber is attached', () => {
markHiddenRendererPty('pty-1')
expect(
shouldModelAnswerHiddenPtyQueries({
ptyId: 'pty-1',
settings: ALL_ON,
hasRemoteViewSubscriber: true
})
).toBe(false)
})
it('stays silent under any kill switch', () => {
markHiddenRendererPty('pty-1')
expect(answer('pty-1', { terminalModelQueryAuthority: false })).toBe(false)
expect(answer('pty-1', { terminalHiddenDeliveryGate: false })).toBe(false)
expect(answer('pty-1', { terminalMainSideEffectAuthority: false })).toBe(false)
})
})
describe('isNativeWindowsLocalPtySpawn (main-side mirror of isLocalNativeWindowsPty)', () => {
const base = {
connectionId: null,
cwd: 'C:\\repo',
shellOverride: undefined,
platform: 'win32' as NodeJS.Platform
}
it('matches local native Windows spawns', () => {
expect(isNativeWindowsLocalPtySpawn(base)).toBe(true)
expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: undefined })).toBe(true)
expect(
isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Tools\\powershell.exe' })
).toBe(true)
})
it('rejects non-Windows hosts', () => {
expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'darwin' })).toBe(false)
expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'linux' })).toBe(false)
})
it('rejects SSH-backed spawns', () => {
expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: 'ssh-1' })).toBe(false)
})
it('rejects WSL cwds and WSL shell overrides', () => {
expect(
isNativeWindowsLocalPtySpawn({ ...base, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me' })
).toBe(false)
expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl.exe' })).toBe(false)
expect(
isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Windows\\System32\\wsl.exe' })
).toBe(false)
expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl' })).toBe(false)
})
})
describe('native-Windows ConPTY spawn record', () => {
it('marks, reads, and clears per PTY', () => {
expect(isNativeWindowsConptyPty('pty-1')).toBe(false)
markNativeWindowsConptyPty('pty-1')
expect(isNativeWindowsConptyPty('pty-1')).toBe(true)
expect(isNativeWindowsConptyPty('pty-2')).toBe(false)
clearNativeWindowsConptyPty('pty-1')
expect(isNativeWindowsConptyPty('pty-1')).toBe(false)
})
})
@@ -0,0 +1,111 @@
/**
* Phase 5 of the terminal model/view architecture: main-side terminal query
* authority (docs/reference/terminal-query-authority.md).
*
* The delivery decision is the reply decision: main answers a query iff the
* hidden-delivery gate dropped the chunk that carried it. This module owns
* the responder kill-switch predicate and the main-side mirror of the
* renderer's native-Windows-ConPTY determination, recorded per PTY at spawn
* so the runtime emulator can register the DA1 override before byte zero.
*/
import type { GlobalSettings } from '../../shared/types'
import { isWslUncPath } from '../../shared/wsl-paths'
import {
isHiddenPtyDeliveryGateEnabled,
shouldDropHiddenRendererPtyData
} from '../ipc/pty-hidden-delivery-gate'
export type TerminalModelQueryAuthoritySettings = Pick<
GlobalSettings,
'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' | 'terminalModelQueryAuthority'
>
/** Responder kill switch: requires BOTH Phase-4 gate switches (no marks/drops
* exist without them) plus the Phase-5-specific independent off switch. */
export function isTerminalModelQueryAuthorityEnabled(
settings: TerminalModelQueryAuthoritySettings | null | undefined
): boolean {
return isHiddenPtyDeliveryGateEnabled(settings) && settings?.terminalModelQueryAuthority !== false
}
/** Per-chunk reply-ownership predicate, evaluated once at ingestion in
* OrcaRuntimeService.onPtyData — the same module state and tick as the
* hidden-gate drop sites, so "chunk dropped" and "main answers" cannot
* diverge for live chunks. Remote view subscribers (mobile/web/remote
* desktop xterms on the multiplexed stream) keep view authority, so main
* yields while one is attached. */
export function shouldModelAnswerHiddenPtyQueries(opts: {
ptyId: string
settings: TerminalModelQueryAuthoritySettings | null | undefined
hasRemoteViewSubscriber: boolean
}): boolean {
return (
isTerminalModelQueryAuthorityEnabled(opts.settings) &&
!opts.hasRemoteViewSubscriber &&
shouldDropHiddenRendererPtyData(opts.ptyId, opts.settings)
)
}
/** Main-side mirror of the renderer's isLocalNativeWindowsPty
* (windows-pty-compatibility.ts), computed from spawn-time facts: local or
* daemon provider (no SSH connection), win32 host, and not a WSL shell. */
export function isNativeWindowsLocalPtySpawn(opts: {
connectionId: string | null | undefined
cwd: string | null | undefined
shellOverride: string | null | undefined
platform?: NodeJS.Platform
}): boolean {
if ((opts.platform ?? process.platform) !== 'win32') {
return false
}
if (opts.connectionId) {
return false
}
if (isWslUncPath(opts.cwd ?? '')) {
return false
}
if (/(?:^|[/\\])wsl(?:\.exe)?$/i.test(opts.shellOverride ?? '')) {
return false
}
return true
}
// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts records
// the determination at spawn, the runtime consults it at emulator creation.
// Daemon-adopted PTYs from a previous app run carry no mark — acceptable:
// ConPTY's blocking DA1 only fires at spawn, which happened in a prior life.
const nativeWindowsConptyPtys = new Set<string>()
// Why installers: the mark lands after the awaited spawn response, but daemon
// stream data (warm-reattach flush) can lazy-create the runtime emulator
// first. The runtime registers an installer so marking retrofits the DA1
// override onto an existing emulator; installation is idempotent emulator-side.
type ConptyDa1OverrideInstaller = (ptyId: string) => void
const conptyDa1OverrideInstallers = new Set<ConptyDa1OverrideInstaller>()
export function registerConptyDa1OverrideInstaller(installer: ConptyDa1OverrideInstaller): void {
conptyDa1OverrideInstallers.add(installer)
}
export function markNativeWindowsConptyPty(id: string): void {
nativeWindowsConptyPtys.add(id)
for (const installer of conptyDa1OverrideInstallers) {
installer(id)
}
}
export function isNativeWindowsConptyPty(id: string): boolean {
return nativeWindowsConptyPtys.has(id)
}
/** Wired into clearProviderPtyState so every PTY teardown path releases the
* spawn record. */
export function clearNativeWindowsConptyPty(id: string): void {
nativeWindowsConptyPtys.delete(id)
}
/** Test seam: reset module state between tests. */
export function _resetTerminalModelQueryAuthorityForTest(): void {
nativeWindowsConptyPtys.clear()
conptyDa1OverrideInstallers.clear()
}
@@ -0,0 +1,827 @@
/**
* Phase 5 model query responder (docs/reference/terminal-query-authority.md):
* reply parity through the runtime emulator, the per-chunk ownership matrix,
* the main-side replay guard, and the ingestion-time capture race.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import {
_resetHiddenRendererPtyDeliveryGateForTest,
markHiddenRendererPty,
setRendererPtyDeliveryInterest,
unmarkHiddenRendererPty
} from '../ipc/pty-hidden-delivery-gate'
import {
_resetTerminalModelQueryAuthorityForTest,
markNativeWindowsConptyPty
} from './terminal-model-query-authority'
import {
_resetTerminalViewAttributesForTest,
setTerminalViewAttributes
} from './terminal-view-attribute-store'
import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes'
const settingsState = {
terminalMainSideEffectAuthority: true as boolean,
terminalHiddenDeliveryGate: true as boolean,
terminalModelQueryAuthority: true as boolean
}
const store = {
getRepo: () => undefined,
getRepos: () => [],
addRepo: () => {},
updateRepo: () => undefined as never,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: () => undefined as never,
removeWorktreeMeta: () => {},
getGitHubCache: () => ({ pr: {}, issue: {} }) as never,
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
terminalMainSideEffectAuthority: settingsState.terminalMainSideEffectAuthority,
terminalHiddenDeliveryGate: settingsState.terminalHiddenDeliveryGate,
terminalModelQueryAuthority: settingsState.terminalModelQueryAuthority
})
}
type RendererBufferStub = { data: string; cols: number; rows: number }
function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) {
const runtime = new OrcaRuntimeService(store)
const replies: { ptyId: string; data: string }[] = []
runtime.setPtyController({
write: (ptyId, data) => {
replies.push({ ptyId, data })
return true
},
kill: () => true,
getForegroundProcess: async () => null,
getSize: () => ({ cols: 80, rows: 24 }),
resize: () => true,
...(opts.rendererBuffer
? {
hasRendererSerializer: () => true,
serializeBuffer: async () => opts.rendererBuffer ?? null
}
: {})
})
return { runtime, replies }
}
/** Awaits the per-PTY emulator writeChain so queued chunk links (and the
* replies they forward) have settled. */
async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise<void> {
await runtime.serializeMainTerminalBuffer(ptyId)
}
/** Renderer-pushed attribute snapshot with distinct, pinned slot values so
* reply fixtures cannot pass by coincidence. */
function viewAttributes(overrides: Partial<TerminalViewAttributes> = {}): TerminalViewAttributes {
const ansi = Array.from(
{ length: 256 },
(_, i) => [i, (i * 2) % 256, (i * 3) % 256] as TerminalViewRgb
)
ansi[1] = [0xcc, 0x00, 0x00]
return {
foreground: [0xd0, 0xd0, 0xd0],
background: [0x1e, 0x1e, 0x2e],
cursor: [0xff, 0x99, 0x00],
ansi,
colorSchemeMode: 'dark',
cursorStyle: 'bar',
cursorBlink: true,
...overrides
}
}
afterEach(() => {
_resetHiddenRendererPtyDeliveryGateForTest()
_resetTerminalModelQueryAuthorityForTest()
_resetTerminalViewAttributesForTest()
settingsState.terminalMainSideEffectAuthority = true
settingsState.terminalHiddenDeliveryGate = true
settingsState.terminalModelQueryAuthority = true
})
describe('reply parity for hidden-dropped chunks', () => {
// Expected replies pinned from the design doc and verified against the
// bundled @xterm/headless build — the same core the renderer runs, so
// parity is structural for static and model-state classes.
it.each([
['DA1 CSI c', '\x1b[c', ['\x1b[?1;2c']],
['DA1 CSI 0 c variant', '\x1b[0c', ['\x1b[?1;2c']],
['DA2', '\x1b[>c', ['\x1b[>0;276;0c']],
['DSR 5n operating status', '\x1b[5n', ['\x1b[0n']],
['CPR 6n at origin', '\x1b[6n', ['\x1b[1;1R']],
['CPR 6n reports the model cursor position', 'hello\r\nworld\x1b[6n', ['\x1b[2;6R']],
['DECXCPR ?6n', '\x1b[?6n', ['\x1b[?1;1R']],
['DECRPM ?1 DECCKM default', '\x1b[?1$p', ['\x1b[?1;2$y']],
['DECRPM ?6 DECOM default', '\x1b[?6$p', ['\x1b[?6;2$y']],
['DECRPM ?7 DECAWM default', '\x1b[?7$p', ['\x1b[?7;1$y']],
['DECRPM ?25 DECTCEM default', '\x1b[?25$p', ['\x1b[?25;1$y']],
['DECRPM ?1004 focus events default', '\x1b[?1004$p', ['\x1b[?1004;2$y']],
['DECRPM ?1006 SGR mouse default', '\x1b[?1006$p', ['\x1b[?1006;2$y']],
['DECRPM ?1016 SGR pixels default', '\x1b[?1016$p', ['\x1b[?1016;2$y']],
['DECRPM ?1049 alt screen default', '\x1b[?1049$p', ['\x1b[?1049;2$y']],
['DECRPM ?2004 bracketed paste default', '\x1b[?2004$p', ['\x1b[?2004;2$y']],
['DECRPM ?2026 synchronized output default', '\x1b[?2026$p', ['\x1b[?2026;2$y']],
['DECRPM reports a set mode as enabled', '\x1b[?2004h\x1b[?2004$p', ['\x1b[?2004;1$y']],
['DECRPM unknown mode reports 0', '\x1b[?12345$p', ['\x1b[?12345;0$y']],
['DECRQM ANSI insert mode', '\x1b[4$p', ['\x1b[4;2$y']],
['DECRQSS DECSTBM default margins', '\x1bP$qr\x1b\\', ['\x1bP1$r1;24r\x1b\\']],
['DECRQSS DECSTBM after margin set', '\x1b[5;20r\x1bP$qr\x1b\\', ['\x1bP1$r5;20r\x1b\\']],
['DECRQSS DECSCUSR default cursor', '\x1bP$q q\x1b\\', ['\x1bP1$r2 q\x1b\\']],
['DECRQSS DECSCA', '\x1bP$q"q\x1b\\', ['\x1bP1$r0"q\x1b\\']],
['DECRQSS SGR', '\x1bP$qm\x1b\\', ['\x1bP1$r0m\x1b\\']],
['XTVERSION', '\x1b[>0q', ['\x1bP>|xterm.js(6.0.0)\x1b\\']],
['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']],
['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']]
])('%s', async (_label, chunk, expectedReplies) => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-q')
runtime.onPtyData('pty-q', chunk, Date.now())
await settle(runtime, 'pty-q')
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true)
})
it.each([
['XTWINOPS', '\x1b[14t'],
['XTGETTCAP', '\x1bP+q544e\x1b\\'],
['DSR ?15n printer status', '\x1b[?15n'],
['DSR ?25n UDK status', '\x1b[?25n'],
['DSR ?26n keyboard status', '\x1b[?26n'],
['DSR ?53n locator status', '\x1b[?53n'],
// View-attribute class: silent until the slice-2 renderer attribute push
// — a fabricated default would resurrect the default-black OSC-11 bug.
['OSC 10 foreground query', '\x1b]10;?\x07'],
['OSC 11 background query', '\x1b]11;?\x07'],
['OSC 12 cursor-color query', '\x1b]12;?\x1b\\'],
['OSC 4 palette query', '\x1b]4;1;?\x07'],
['DSR ?996n color-scheme query', '\x1b[?996n']
])('stays silent for %s', async (_label, chunk) => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-q')
runtime.onPtyData('pty-q', chunk, Date.now())
await settle(runtime, 'pty-q')
expect(replies).toEqual([])
})
})
describe('reply ownership matrix', () => {
const DA1 = '\x1b[c'
it('never answers delivered (unmarked) chunks — the visible xterm owns them', async () => {
const { runtime, replies } = createResponderRuntime()
runtime.onPtyData('pty-v', DA1, Date.now())
await settle(runtime, 'pty-v')
expect(replies).toEqual([])
})
it('never answers while renderer delivery interest holds the chunk delivered', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-i')
setRendererPtyDeliveryInterest('pty-i', true)
runtime.onPtyData('pty-i', DA1, Date.now())
await settle(runtime, 'pty-i')
expect(replies).toEqual([])
})
it.each([
['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)],
['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)],
[
'terminalMainSideEffectAuthority',
() => (settingsState.terminalMainSideEffectAuthority = false)
]
])('never answers with kill switch %s off', async (_label, flip) => {
flip()
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-k')
runtime.onPtyData('pty-k', DA1, Date.now())
await settle(runtime, 'pty-k')
expect(replies).toEqual([])
})
it('yields while a remote view subscriber is attached and resumes on release', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-r')
const release = runtime.registerRemoteTerminalViewSubscriber('pty-r')
runtime.onPtyData('pty-r', DA1, Date.now())
await settle(runtime, 'pty-r')
expect(replies).toEqual([])
release()
// Releases are idempotent: a double release must not unbalance the count.
release()
runtime.onPtyData('pty-r', DA1, Date.now())
await settle(runtime, 'pty-r')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
})
it('counts overlapping remote view subscribers', () => {
const { runtime } = createResponderRuntime()
const releaseA = runtime.registerRemoteTerminalViewSubscriber('pty-m')
const releaseB = runtime.registerRemoteTerminalViewSubscriber('pty-m')
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true)
releaseA()
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true)
releaseB()
expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false)
})
it('treats mobile subscriber records as remote view subscribers', async () => {
const { runtime } = createResponderRuntime()
await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 })
expect(runtime.hasRemoteTerminalViewSubscriber('pty-mob')).toBe(true)
})
it('answers a dropped-chunk query exactly once', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-once')
runtime.onPtyData('pty-once', DA1, Date.now())
await settle(runtime, 'pty-once')
expect(replies).toHaveLength(1)
})
})
describe('main-side replay guard', () => {
const DA1 = '\x1b[c'
it('never answers queries embedded in a seeded snapshot, then answers live bytes', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-seed')
runtime.seedHeadlessTerminal('pty-seed', `restored prompt${DA1}`)
await settle(runtime, 'pty-seed')
expect(replies).toEqual([])
runtime.onPtyData('pty-seed', DA1, Date.now())
await settle(runtime, 'pty-seed')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
})
it('never answers queries replayed by renderer-buffer hydration', async () => {
const { runtime, replies } = createResponderRuntime({
rendererBuffer: { data: `restored screen${DA1}`, cols: 80, rows: 24 }
})
markHiddenRendererPty('pty-hyd')
// First live byte triggers maybeHydrateHeadlessFromRenderer; the hydration
// seed parses the embedded DA1 but must not forward its reply.
runtime.onPtyData('pty-hyd', 'live output', Date.now())
await settle(runtime, 'pty-hyd')
expect(replies).toEqual([])
})
})
describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => {
it('answers ?u with the persisted snapshot flags after a re-seed, silently applied', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-kitty')
// Daemon warm-reattach threads modes.kittyKeyboardFlags through the
// spawn result into the seed; applying them is a seed-side write and
// must answer no one (main-side replay guard).
runtime.seedHeadlessTerminal('pty-kitty', 'restored prompt', undefined, {
kittyKeyboardFlags: 5
})
await settle(runtime, 'pty-kitty')
expect(replies).toEqual([])
runtime.onPtyData('pty-kitty', '\x1b[?u', Date.now())
await settle(runtime, 'pty-kitty')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u'])
})
it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-kitty0')
runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt')
runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now())
await settle(runtime, 'pty-kitty0')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u'])
})
})
describe('ingestion-time ownership capture', () => {
const DA1 = '\x1b[c'
it('still answers when the hidden mark flips off between ingestion and the async write', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-race')
runtime.onPtyData('pty-race', DA1, Date.now())
// Flip before the queued writeChain link runs: the captured decision wins.
unmarkHiddenRendererPty('pty-race')
await settle(runtime, 'pty-race')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
})
it('stays silent when the hidden mark lands after ingestion', async () => {
const { runtime, replies } = createResponderRuntime()
runtime.onPtyData('pty-race2', DA1, Date.now())
markHiddenRendererPty('pty-race2')
await settle(runtime, 'pty-race2')
expect(replies).toEqual([])
})
})
describe('stale writeChain links after dispose', () => {
const DA1 = '\x1b[c'
it('never forwards a queued reply once the PTY state is disposed', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-stale')
// Queue a forward-flagged chain link, then dispose before it runs.
runtime.onPtyData('pty-stale', DA1, Date.now())
runtime.onPtyExit('pty-stale', 0)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(replies).toEqual([])
})
it('never injects a stale reply into a successor PTY reusing the session id', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-reuse')
// Daemon respawns reuse session ids: dispose with the flagged link still
// queued, then re-create the same id before the link runs.
runtime.onPtyData('pty-reuse', DA1, Date.now())
runtime.onPtyExit('pty-reuse', 0)
runtime.onPtyData('pty-reuse', 'fresh shell banner', Date.now())
await settle(runtime, 'pty-reuse')
expect(replies).toEqual([])
})
})
describe('ConPTY DA1 override', () => {
it('retrofits the override when the spawn mark lands after data created the emulator', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-win-late')
// Daemon warm-reattach flush: stream data creates the emulator before
// the awaited spawn response marks the PTY native-Windows.
runtime.onPtyData('pty-win-late', 'warm reattach flush', Date.now())
markNativeWindowsConptyPty('pty-win-late')
runtime.onPtyData('pty-win-late', '\x1b[c', Date.now())
await settle(runtime, 'pty-win-late')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
})
it('keeps the override single-reply when installed at creation and marked again', async () => {
const { runtime, replies } = createResponderRuntime()
markNativeWindowsConptyPty('pty-win-idem')
markHiddenRendererPty('pty-win-idem')
runtime.onPtyData('pty-win-idem', 'boot output', Date.now())
// A duplicate mark (e.g. respawn against a live emulator) must not stack
// a second handler that double-replies.
markNativeWindowsConptyPty('pty-win-idem')
runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now())
await settle(runtime, 'pty-win-idem')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
})
it('answers CSI ?61;4c for marked native-Windows PTYs, suppressing the core ?1;2c', async () => {
const { runtime, replies } = createResponderRuntime()
markNativeWindowsConptyPty('pty-win')
markHiddenRendererPty('pty-win')
runtime.onPtyData('pty-win', '\x1b[c', Date.now())
await settle(runtime, 'pty-win')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
})
it('lets non-primary device-attribute queries fall through to the core', async () => {
const { runtime, replies } = createResponderRuntime()
markNativeWindowsConptyPty('pty-win2')
markHiddenRendererPty('pty-win2')
runtime.onPtyData('pty-win2', '\x1b[>c', Date.now())
await settle(runtime, 'pty-win2')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[>0;276;0c'])
})
it('keeps the override silent for delivered chunks', async () => {
const { runtime, replies } = createResponderRuntime()
markNativeWindowsConptyPty('pty-win3')
runtime.onPtyData('pty-win3', '\x1b[c', Date.now())
await settle(runtime, 'pty-win3')
expect(replies).toEqual([])
})
})
describe('HeadlessEmulator forwarding window', () => {
it('forwards replies only for writes flagged forwardQueryReplies', async () => {
const onQueryReply = vi.fn()
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
try {
await emulator.write('\x1b[c')
expect(onQueryReply).not.toHaveBeenCalled()
await emulator.write('\x1b[c', { forwardQueryReplies: true })
expect(onQueryReply).toHaveBeenCalledTimes(1)
expect(onQueryReply).toHaveBeenCalledWith('\x1b[?1;2c')
} finally {
emulator.dispose()
}
})
it('scopes the async-fallback forwarding window to the flagged chunk parse', async () => {
const onQueryReply = vi.fn()
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
// Force the async write path (xterm deprecates writeSync; the fallback
// must stay structurally safe without writeChain serialization).
const internals = emulator as unknown as { terminal: { _core: { writeSync?: unknown } } }
internals.terminal._core.writeSync = undefined
try {
// Enqueue an unflagged seed carrying a query, then a flagged live
// chunk, WITHOUT awaiting between them: both sit in xterm's write
// queue together. The seed parse must not see an open window.
const seed = emulator.write('seeded\x1b[c')
const live = emulator.write('\x1b[5n', { forwardQueryReplies: true })
await Promise.all([seed, live])
expect(onQueryReply.mock.calls.map((call) => call[0])).toEqual(['\x1b[0n'])
} finally {
emulator.dispose()
}
})
it('keeps the ConPTY override inside the forwarding window', async () => {
const onQueryReply = vi.fn()
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
emulator.installConptyPrimaryDeviceAttributesOverride()
try {
// Unflagged (replayed/seeded) DA1 must answer no one even with the
// override installed.
await emulator.write('\x1b[c')
expect(onQueryReply).not.toHaveBeenCalled()
await emulator.write('\x1b[c', { forwardQueryReplies: true })
expect(onQueryReply).toHaveBeenCalledTimes(1)
expect(onQueryReply).toHaveBeenCalledWith('\x1b[?61;4c')
} finally {
emulator.dispose()
}
})
})
describe('view-attribute bridge replies (after renderer push)', () => {
// Reply bytes pinned to the renderer xterm's format: OSC replies use the
// queried ident, 16-bit doubled-byte channels, and ST termination
// (CoreBrowserTerminal._handleColorEvent + toRgbString); ?996n answers with
// the contour 997 report, same bytes as mode2031SequenceFor.
it.each([
['OSC 10 foreground', '\x1b]10;?\x07', ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\']],
['OSC 11 background', '\x1b]11;?\x07', ['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']],
['OSC 12 cursor color', '\x1b]12;?\x1b\\', ['\x1b]12;rgb:ffff/9999/0000\x1b\\']],
['OSC 4 named palette slot', '\x1b]4;1;?\x07', ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']],
['OSC 4 extended palette slot', '\x1b]4;196;?\x07', ['\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']],
[
'OSC 4 multiple slots in one sequence',
'\x1b]4;1;?;196;?\x07',
['\x1b]4;1;rgb:cccc/0000/0000\x1b\\', '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']
],
[
'OSC 10 stacked params report foreground then background',
'\x1b]10;?;?\x07',
['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']
],
['DSR ?996n dark', '\x1b[?996n', ['\x1b[?997;1n']],
['DECRQSS DECSCUSR from pushed cursor options', '\x1bP$q q\x1b\\', ['\x1bP1$r5 q\x1b\\']],
['DECRQM ?12 from pushed cursorBlink', '\x1b[?12$p', ['\x1b[?12;1$y']]
])('%s', async (_label, chunk, expectedReplies) => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-view')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-view', chunk, Date.now())
await settle(runtime, 'pty-view')
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
})
it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-lum-dark')
// Supported divergence: light app mode with terminalUseSeparateLightTheme
// off renders a dark terminal theme. A visible xterm answers ?996n from
// bg/fg relative luminance (CoreBrowserTerminal._reportColorScheme), so
// the hidden reply must say dark here too.
setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' }))
runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now())
await settle(runtime, 'pty-lum-dark')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n'])
})
it('answers ?996n light for a light palette regardless of the pushed app mode', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-lum-light')
setTerminalViewAttributes(
viewAttributes({
foreground: [0x33, 0x33, 0x33],
background: [0xfa, 0xfa, 0xfa],
colorSchemeMode: 'dark'
})
)
runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now())
await settle(runtime, 'pty-lum-light')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n'])
})
it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => {
// _reportColorScheme reads the CURRENT theme-service colors, which include
// OSC 10/11 SET mutations — the per-PTY overlays layer the same way.
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-lum-set')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now())
await settle(runtime, 'pty-lum-set')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n'])
})
it('stays silent before the first push, then answers the same query after it', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-first')
runtime.onPtyData('pty-first', '\x1b]11;?\x07\x1b[?996n', Date.now())
await settle(runtime, 'pty-first')
// No fabricated defaults: silence is the documented hidden status quo.
expect(replies).toEqual([])
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-first', '\x1b]11;?\x07', Date.now())
await settle(runtime, 'pty-first')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
})
it('retrofits cursor options onto already-live emulators when the push lands late', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-late')
// Emulator exists before any push: core default DECSCUSR is steady block.
runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now())
await settle(runtime, 'pty-late')
expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\'])
setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false }))
runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now())
await settle(runtime, 'pty-late')
expect(replies.map((reply) => reply.data).at(-1)).toBe('\x1bP1$r4 q\x1b\\')
})
})
describe('per-PTY OSC color SET layering', () => {
it('layers an OSC 4 SET over the pushed base, isolated per PTY', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-a')
markHiddenRendererPty('pty-b')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-a', '\x1b]4;1;rgb:00/ff/00\x07\x1b]4;1;?\x07', Date.now())
runtime.onPtyData('pty-b', '\x1b]4;1;?\x07', Date.now())
await settle(runtime, 'pty-a')
await settle(runtime, 'pty-b')
expect(replies).toEqual([
{ ptyId: 'pty-a', data: '\x1b]4;1;rgb:0000/ffff/0000\x1b\\' },
{ ptyId: 'pty-b', data: '\x1b]4;1;rgb:cccc/0000/0000\x1b\\' }
])
})
it('restores a single indexed color via OSC 104;<idx>', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-104')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now())
await settle(runtime, 'pty-104')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\'])
})
it('restores the whole indexed table via bare OSC 104', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-104all')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData(
'pty-104all',
'\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07',
Date.now()
)
await settle(runtime, 'pty-104all')
expect(replies.map((reply) => reply.data)).toEqual([
'\x1b]4;1;rgb:cccc/0000/0000\x1b\\',
'\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'
])
})
it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-special')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData(
'pty-special',
'\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' +
'\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' +
'\x1b]110\x07\x1b]111\x07\x1b]112\x07' +
'\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07',
Date.now()
)
await settle(runtime, 'pty-special')
expect(replies.map((reply) => reply.data)).toEqual([
'\x1b]10;rgb:0101/0202/0303\x1b\\',
'\x1b]11;rgb:ffff/ffff/ffff\x1b\\',
'\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\',
'\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\',
'\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\',
'\x1b]12;rgb:ffff/9999/0000\x1b\\'
])
})
it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => {
// Cold-restore scrollback replayed into a visible renderer xterm re-applies
// OSC SETs to its theme service; the model mirrors that state — but the
// replay guard still keeps the seed from ANSWERING anything.
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-seedset')
setTerminalViewAttributes(viewAttributes())
runtime.seedHeadlessTerminal('pty-seedset', 'restored\x1b]4;1;#00ff00\x07\x1b]4;1;?\x07')
await settle(runtime, 'pty-seedset')
expect(replies).toEqual([])
runtime.onPtyData('pty-seedset', '\x1b]4;1;?\x07', Date.now())
await settle(runtime, 'pty-seedset')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:0000/ffff/0000\x1b\\'])
})
it('preserves per-PTY overrides on an identical re-push (fresh renderer process)', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-idem')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-idem', '\x1b]11;#ffffff\x07', Date.now())
await settle(runtime, 'pty-idem')
// A second window / renderer reload / macOS re-activation re-pushes
// byte-identical attributes (its publisher dedupe is per-process). That is
// not a theme apply, so the OSC SET overlay must survive.
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-idem', '\x1b]11;?\x07', Date.now())
await settle(runtime, 'pty-idem')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:ffff/ffff/ffff\x1b\\'])
})
it('clears per-PTY overrides when a new push lands (theme apply parity)', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-clear')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-clear', '\x1b]11;#ffffff\x07', Date.now())
await settle(runtime, 'pty-clear')
// A theme apply overwrites OSC-SET-mutated colors on visible panes too
// (ThemeService._setTheme), so the model mirrors that on every CHANGED
// push (identical re-pushes are filtered — see the test above).
setTerminalViewAttributes(viewAttributes({ background: [0x10, 0x20, 0x30] }))
runtime.onPtyData('pty-clear', '\x1b]11;?\x07', Date.now())
await settle(runtime, 'pty-clear')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1010/2020/3030\x1b\\'])
})
})
describe('view-attribute replay guard and suppression', () => {
it('never answers view-attribute queries embedded in a seeded snapshot', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-vseed')
setTerminalViewAttributes(viewAttributes())
runtime.seedHeadlessTerminal('pty-vseed', 'prompt\x1b]11;?\x07\x1b[?996n')
await settle(runtime, 'pty-vseed')
expect(replies).toEqual([])
runtime.onPtyData('pty-vseed', '\x1b]11;?\x07', Date.now())
await settle(runtime, 'pty-vseed')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
})
it('never answers view-attribute queries replayed by renderer-buffer hydration', async () => {
const { runtime, replies } = createResponderRuntime({
rendererBuffer: { data: 'restored\x1b]11;?\x07\x1b[?996n', cols: 80, rows: 24 }
})
markHiddenRendererPty('pty-vhyd')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-vhyd', 'live output', Date.now())
await settle(runtime, 'pty-vhyd')
expect(replies).toEqual([])
})
it('never answers a delivered (unmarked) view-attribute query — the visible xterm owns it', async () => {
const { runtime, replies } = createResponderRuntime()
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-vvis', '\x1b]11;?\x07\x1b[?996n', Date.now())
await settle(runtime, 'pty-vvis')
expect(replies).toEqual([])
})
it('never answers while renderer delivery interest holds the chunk delivered', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-vint')
setRendererPtyDeliveryInterest('pty-vint', true)
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-vint', '\x1b]11;?\x07', Date.now())
await settle(runtime, 'pty-vint')
expect(replies).toEqual([])
})
it('yields view-attribute replies while a remote view subscriber is attached', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-vrem')
setTerminalViewAttributes(viewAttributes())
const release = runtime.registerRemoteTerminalViewSubscriber('pty-vrem')
runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now())
await settle(runtime, 'pty-vrem')
expect(replies).toEqual([])
release()
runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now())
await settle(runtime, 'pty-vrem')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
})
it.each([
['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)],
['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)],
[
'terminalMainSideEffectAuthority',
() => (settingsState.terminalMainSideEffectAuthority = false)
]
])('never answers view-attribute queries with kill switch %s off', async (_label, flip) => {
flip()
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-vkill')
setTerminalViewAttributes(viewAttributes())
runtime.onPtyData('pty-vkill', '\x1b]11;?\x07\x1b[?996n', Date.now())
await settle(runtime, 'pty-vkill')
expect(replies).toEqual([])
})
})
@@ -0,0 +1,57 @@
/**
* Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute
* bridge): main-side cache of the renderer's `pty:terminalViewAttributes`
* push. One app-global snapshot, not per-PTY — per-pane font zoom never
* affects these attributes and the color/cursor settings are global.
*
* Null until the first push, and the responder answers NO view-attribute
* query while null (silent-until-first-push): a fabricated default would
* resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC
* hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip.
*/
import {
terminalViewAttributesEqual,
type TerminalViewAttributes
} from '../../shared/terminal-view-attributes'
// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives
// the push, the runtime emulators consult it at reply time via the getter.
let currentAttributes: TerminalViewAttributes | null = null
// Why appliers (pattern of registerConptyDa1OverrideInstaller): each push
// must also reach already-live emulators — cursor options under the replay
// guard, plus the per-PTY override reset a theme apply implies.
type TerminalViewAttributesApplier = (attributes: TerminalViewAttributes) => void
const pushAppliers = new Set<TerminalViewAttributesApplier>()
export function registerTerminalViewAttributesApplier(
applier: TerminalViewAttributesApplier
): void {
pushAppliers.add(applier)
}
/** Called from the pty:terminalViewAttributes IPC handler with a validated
* payload. Last push wins (replies always use the freshest snapshot). */
export function setTerminalViewAttributes(attributes: TerminalViewAttributes): void {
// Why idempotent: the renderer publisher's dedupe is per-process, so a
// fresh renderer (second window, reload, macOS re-activation) re-pushes
// identical attributes. That is not a theme apply — fanning out would wipe
// every PTY's OSC SET overlay while visible panes keep theirs.
if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) {
return
}
currentAttributes = attributes
for (const applier of pushAppliers) {
applier(attributes)
}
}
export function getTerminalViewAttributes(): TerminalViewAttributes | null {
return currentAttributes
}
/** Test seam: reset module state between tests. */
export function _resetTerminalViewAttributesForTest(): void {
currentAttributes = null
pushAppliers.clear()
}
+95
View File
@@ -91,6 +91,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
}))
const { deployAndLaunchRelay } = await import('./ssh-relay-deploy')
// Why: the hidden-delivery gate module is intentionally real (pure state, no
// electron deps) so the SSH parity tests exercise the same gate main uses.
const { markHiddenRendererPty, setRendererPtyDeliveryInterest } =
await import('../ipc/pty-hidden-delivery-gate')
const { _resetHiddenRendererPtyDeliveryGateForTest } =
await import('../ipc/pty-hidden-delivery-gate')
const { execCommand } = await import('./ssh-relay-deploy-helpers')
const { getRemoteHostPlatform } = await import('./ssh-remote-platform')
const {
@@ -147,6 +153,95 @@ describe('SshRelaySession', () => {
installRemoteManagedAgentHooksMock.mockResolvedValue([])
mockDeploySuccess()
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
_resetHiddenRendererPtyDeliveryGateForTest()
})
it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
const runtime = {
onPtyData: vi.fn(() => 99),
onPtyExit: vi.fn()
}
const session = new SshRelaySession(
'target-1',
getMainWindow,
mockStore,
mockPortForward,
runtime as never
)
await session.establish(mockConn)
const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as {
onData: ReturnType<typeof vi.fn>
}
const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: {
id: string
data: string
}) => void
markHiddenRendererPty('ssh-pty-1')
onData({ id: 'ssh-pty-1', data: 'hidden ssh output' })
// Runtime ingestion still ran; renderer delivery shrank to one marker.
expect(runtime.onPtyData).toHaveBeenCalledWith(
'ssh-pty-1',
'hidden ssh output',
expect.any(Number)
)
expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1)
// Why out-of-band: an in-band empty pty:data sentinel is ambiguous with
// chunks fully consumed by renderer OSC-9999 stripping.
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: 'ssh-pty-1',
reason: 'hidden-drop',
markerSeq: 99
})
onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' })
expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1)
// Delivery interest (renderer sidecars) suppresses the gate — parity with
// the local path in ipc/pty.ts.
setRendererPtyDeliveryInterest('ssh-pty-1', true)
onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' })
expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
id: 'ssh-pty-1',
data: 'sidecar ssh bytes',
seq: 99,
rawLength: 'sidecar ssh bytes'.length
})
// Non-hidden PTYs are unaffected.
onData({ id: 'ssh-pty-2', data: 'visible ssh output' })
expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', {
id: 'ssh-pty-2',
data: 'visible ssh output',
seq: 99,
rawLength: 'visible ssh output'.length
})
})
it('keeps hidden SSH delivery when the gate kill switch is off', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({
terminalHiddenDeliveryGate: false
}))
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as {
onData: ReturnType<typeof vi.fn>
}
const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: {
id: string
data: string
}) => void
markHiddenRendererPty('ssh-pty-1')
onData({ id: 'ssh-pty-1', data: 'still delivered' })
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: 'ssh-pty-1',
data: 'still delivered'
})
})
it('starts in idle state', () => {
+29 -1
View File
@@ -40,6 +40,11 @@ import {
setPtyOwnership,
answerStartupTerminalColorQueriesForPty
} from '../ipc/pty'
import {
recordHiddenRendererPtyDataDrop,
shouldDropHiddenRendererPtyData
} from '../ipc/pty-hidden-delivery-gate'
import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider,
@@ -944,7 +949,30 @@ export class SshRelaySession {
const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now())
const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data)
const win = this.getMainWindow()
if (win && !win.isDestroyed() && rendererData.length > 0) {
if (!win || win.isDestroyed()) {
return
}
// Why: hidden-delivery gate parity with ipc/pty.ts — runtime ingestion
// above already consumed the chunk; gated renderer delivery is dropped
// and one out-of-band pty:modelRestoreNeeded signal latches
// model-restore-needed for reveal. Never an in-band pty:data sentinel:
// OSC-9999-only chunks legitimately strip to empty in the renderer.
const store = this.store as { getSettings?: Store['getSettings'] }
if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) {
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
if (drop.shouldEmitRestoreMarker) {
win.webContents.send('pty:modelRestoreNeeded', {
id: payload.id,
reason: 'hidden-drop',
...(typeof seq === 'number' ? { markerSeq: seq } : {})
} satisfies PtyModelRestoreNeededEvent)
}
return
}
// Why: startup color-query answering can strip query-only chunks to
// empty; skip empty sends and only attach seq metadata when the chunk
// reaches the renderer unmodified (seq tracks raw stream offsets).
if (rendererData.length > 0) {
win.webContents.send('pty:data', {
...payload,
data: rendererData,
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
describe('shouldCopySyntheticTitleFrameToPtyData', () => {
it('keeps the legacy pty:data copy only while the kill switch is off', () => {
// Authority off: renderer byte parsers are the sole synthetic-frame
// consumer, so the legacy copy must keep flowing.
expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: false })).toBe(
true
)
})
it('skips the copy under main authority — tracker ingest is the only consumer', () => {
// Why: under authority the copy would only mint phantom renderer ACKs
// for fabricated bytes main never metered.
expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: true })).toBe(
false
)
// Default-on: an unset switch means main authority.
expect(shouldCopySyntheticTitleFrameToPtyData({})).toBe(false)
expect(shouldCopySyntheticTitleFrameToPtyData(null)).toBe(false)
expect(shouldCopySyntheticTitleFrameToPtyData(undefined)).toBe(false)
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { GlobalSettings } from '../shared/types'
/**
* Why: with the side-effect kill switch off, renderer byte parsers are the
* ONLY consumer of main-fabricated OSC title frames, so they must still ride
* `pty:data`. With main authority on (the default), the tracker ingest is the
* sole consumer and the legacy copy would only mint phantom renderer ACKs for
* bytes main never metered. See terminal-side-effect-authority.md (slice 3).
*/
export function shouldCopySyntheticTitleFrameToPtyData(
settings: Pick<GlobalSettings, 'terminalMainSideEffectAuthority'> | null | undefined
): boolean {
return settings?.terminalMainSideEffectAuthority === false
}
+51 -12
View File
@@ -176,22 +176,13 @@ import type {
WorkspaceSessionPatch,
WorkspaceSessionState
} from '../shared/types'
type GitLabRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
type GitHubRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
import type { TerminalViewAttributes } from '../shared/terminal-view-attributes'
import type {
WarpThemeImportPreview,
WarpThemeImportSource
} from '../shared/terminal-custom-themes'
import type { SetupScriptImportCandidate } from '../shared/setup-script-imports'
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
@@ -262,6 +253,7 @@ import type {
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
@@ -409,6 +401,18 @@ import type {
} from '../shared/workspace-cleanup'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
type GitLabRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
type GitHubRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
export type BrowserApi = {
registerGuest: (args: {
browserPageId: string
@@ -1123,6 +1127,10 @@ export type PreloadApi = {
shellOverride?: string
projectRuntime?: ProjectExecutionRuntimeResolution
terminalColorQueryReplies?: { foreground?: string; background?: string }
// Why: hidden-at-spawn declaration — main marks the PTY hidden before
// its first byte so the delivery gate + model responder own spawn-time
// queries (terminal-query-authority.md §races).
initiallyHidden?: boolean
// Why: closes the SIGKILL race documented in INVESTIGATION.md — main
// sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before
// pty:spawn returns. Only the renderer's daemon-host path threads these.
@@ -1155,6 +1163,15 @@ export type PreloadApi = {
ackData: (id: string, charCount: number) => void
setActiveRendererPty: (id: string, active: boolean) => void
setRendererPtyVisible: (id: string, visible: boolean) => void
/** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer
* byte delivery after model ingestion; reveal restores from snapshots. */
setHiddenRendererPty: (id: string, hidden: boolean) => void
/** Ref-counted-on-the-renderer delivery-interest signal that suppresses
* the hidden-delivery gate while any raw-byte consumer is registered. */
setPtyDeliveryInterest: (id: string, interested: boolean) => void
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
* appearance push backing main's hidden-PTY OSC/DSR color replies. */
publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void
hasChildProcesses: (id: string) => Promise<boolean>
getForegroundProcess: (id: string) => Promise<string | null>
getCwd: (id: string) => Promise<string>
@@ -1170,6 +1187,10 @@ export type PreloadApi = {
rows: number
cwd?: string | null
seq?: number
/** Start of main's pending renderer-delivery queue at snapshot time
* (equals `seq` when empty) — bounds the renderer's post-restore
* duplicate window. */
pendingDeliveryStartSeq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null>
@@ -1187,6 +1208,11 @@ export type PreloadApi = {
peakRendererInFlightChars: number
peakMaxRendererInFlightCharsByPty: number
ackGatedFlushSkipCount: number
hiddenDeliveryGatedPtyCount: number
deliveryInterestPtyCount: number
hiddenDeliveryDroppedChars: number
hiddenDeliveryDroppedChunks: number
pendingDroppedChars: number
}>
resetRendererDeliveryDebug: () => Promise<void>
onData: (
@@ -1200,6 +1226,15 @@ export type PreloadApi = {
}) => void
) => () => void
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
/** Out-of-band main→renderer signal that renderer-bound bytes were
* dropped (hidden-delivery gate / pending cap); the pane restores from
* the model snapshot. Never delivered in-band on pty:data. */
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void
/** Batched derived side-effect facts for PTYs whose bytes transit local
* main; see docs/reference/terminal-side-effect-authority.md. */
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void
/** Title-only replay snapshot for (re)attach; attention facts never replay. */
getSideEffectSnapshot: (id: string) => Promise<TerminalSideEffectBatch | null>
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
onSerializeBufferRequest: (
callback: (data: {
@@ -1894,6 +1929,10 @@ export type PreloadApi = {
telemetryAcknowledgeBanner: () => Promise<void>
settings: {
get: () => Promise<GlobalSettings>
/** Synchronous persisted-settings read for startup decisions that cannot
* wait for async hydration (terminal side-effect authority). Blocking
* IPC — call sparingly. */
getSync: () => GlobalSettings | null
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
listFonts: () => Promise<string[]>
previewGhosttyImport: () => Promise<GhosttyImportPreview>
+4 -1
View File
@@ -20,5 +20,8 @@ const exposeStore = preloadEnv?.MODE === 'e2e' || isEnvFlagEnabled(preloadEnv?.V
export const preloadE2EConfig = createE2EConfig({
headless: process.env.ORCA_E2E_HEADLESS === '1',
exposeStore,
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null,
// Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so
// only a real positive override reaches the renderer parking policy.
terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null
})
+61
View File
@@ -52,6 +52,8 @@ import type {
WorktreeDefaultTabsLaunch,
WorktreeRemoteBranchConflictEvent
} from '../shared/types'
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
import type { TerminalViewAttributes } from '../shared/terminal-view-attributes'
import type {
WarpThemeImportPreview,
WarpThemeImportSource
@@ -128,6 +130,7 @@ import type {
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
import type {
SpeechErrorEvent,
SpeechLifecycleEvent,
@@ -764,6 +767,10 @@ const api = {
shellOverride?: string
projectRuntime?: ProjectExecutionRuntimeResolution
terminalColorQueryReplies?: { foreground?: string; background?: string }
// Why: hidden-at-spawn declaration — main marks the PTY hidden before
// its first byte so the delivery gate + model responder own spawn-time
// queries (terminal-query-authority.md §races).
initiallyHidden?: boolean
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
// ptyId) binding before pty:spawn returns. Only the renderer's
@@ -824,6 +831,24 @@ const api = {
setRendererPtyVisible: (id: string, visible: boolean): void => {
ipcRenderer.send('pty:setRendererPtyVisible', { id, visible })
},
/** Hidden-delivery gate (Phase 4): hidden=true lets main DROP renderer
* byte delivery after model ingestion; reveal restores from the model
* snapshot. Fire-and-forget like setActiveRendererPty. */
setHiddenRendererPty: (id: string, hidden: boolean): void => {
ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden })
},
/** Delivery-interest signal: any renderer party that needs raw bytes
* (dispatcher sidecars, eager pre-mount buffers) suppresses the
* hidden-delivery gate for that PTY while registered. */
setPtyDeliveryInterest: (id: string, interested: boolean): void => {
ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested })
},
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
* appearance push that lets main's model responder answer OSC 4/10/11/12
* and DSR ?996n for hidden-gated PTYs with renderer-true values. */
publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => {
ipcRenderer.send('pty:terminalViewAttributes', attributes)
},
kill: (id: string, opts?: { keepHistory?: boolean }): Promise<void> =>
ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }),
@@ -841,6 +866,7 @@ const api = {
rows: number
cwd?: string | null
seq?: number
pendingDeliveryStartSeq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
@@ -859,6 +885,11 @@ const api = {
peakRendererInFlightChars: number
peakMaxRendererInFlightCharsByPty: number
ackGatedFlushSkipCount: number
hiddenDeliveryGatedPtyCount: number
deliveryInterestPtyCount: number
hiddenDeliveryDroppedChars: number
hiddenDeliveryDroppedChunks: number
pendingDroppedChars: number
}> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'),
resetRendererDeliveryDebug: (): Promise<void> =>
@@ -915,6 +946,32 @@ const api = {
return () => ipcRenderer.removeListener('pty:replay', listener)
},
/** Out-of-band signal that main dropped renderer-bound bytes for a PTY
* (hidden-delivery gate / pending cap) — the pane must restore from the
* model snapshot. Deliberately NOT on pty:data: an in-band marker is
* ambiguous with chunks fully stripped by OSC-9999 cleaning. */
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) =>
callback(event)
ipcRenderer.on('pty:modelRestoreNeeded', listener)
return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener)
},
/** Batched derived side-effect facts (title/bell/agent transitions) for
* PTYs whose bytes transit local main. Per-PTY in-order; deliberately not
* synchronized with pty:data (terminal-side-effect-authority.md). */
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) =>
callback(batch)
ipcRenderer.on('pty:sideEffect', listener)
return () => ipcRenderer.removeListener('pty:sideEffect', listener)
},
/** Title-only replay snapshot applied on (re)attach — attention facts
* (bells/completions) never replay. */
getSideEffectSnapshot: (id: string): Promise<TerminalSideEffectBatch | null> =>
ipcRenderer.invoke('pty:sideEffectSnapshot', { id }),
onExit: (callback: (data: { id: string; code: number }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) =>
callback(data)
@@ -1689,6 +1746,10 @@ const api = {
settings: {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
// Why: blocking read for the few startup decisions (terminal side-effect
// authority) that cannot wait for async hydration. Call sparingly.
getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'),
set: (args: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('settings:set', args),
+10
View File
@@ -127,6 +127,8 @@ import {
} from './startup/startup-diagnostics'
import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility'
import { applyDocumentTheme } from './lib/document-theme'
import { getSystemPrefersDark } from './lib/terminal-theme'
import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance'
import { isEditableTarget } from './lib/editable-target'
import { getSelectedTextForFileSearch } from './lib/file-search-selection'
import { useShortcutLabel } from './hooks/useShortcutLabel'
@@ -855,6 +857,14 @@ function App(): React.JSX.Element {
// Load settings first so a persisted remote runtime does not boot against
// the local filesystem and then hydrate stale local workspace state.
await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings())
// Why here: hidden-at-launch PTYs (background terminal reconnects,
// agent sessions) can query OSC 10/11 before any terminal pane mounts
// and main's responder is silent-until-first-push. Publish composed
// view attributes as soon as settings exist, before any spawn below.
publishTerminalViewAttributesAtAppStart(
useAppStore.getState().settings,
getSystemPrefersDark()
)
// Why: load local + every configured runtime environment (not just the
// active one) so a cold start that restored a remote workspace doesn't
// hide local repos. The sidebar "All hosts" scope then shows them all.
+227 -1
View File
@@ -65,6 +65,18 @@ import {
} from './terminal/split-group-mount'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
import {
getTerminalWorktreeColdParkRecheckDelayMs,
selectColdParkedTerminalWorktrees,
type TerminalWorktreeColdParkCandidate
} from './terminal-pane/terminal-hidden-view-parking'
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
import {
canWatcherCoverParkedTerminalTab,
pruneParkedTerminalWatchers,
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers
} from './terminal-pane/terminal-parked-tab-watchers'
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
import CodexRestartChip from './CodexRestartChip'
@@ -124,6 +136,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
function haveSameWorktreeIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const id of left) {
if (!right.has(id)) {
return false
}
}
return true
}
function findUnifiedTabByVisibleId(
state: TerminalStoreSnapshot,
worktreeId: string,
@@ -206,6 +230,8 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
function Terminal(): React.JSX.Element | null {
const mountedWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
const terminalWorktreeHiddenSinceRef = useRef(new Map<string, number>())
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
const allWorktrees = useAllWorktrees()
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
const workspaceSurfaces = useMemo(
@@ -222,6 +248,8 @@ function Terminal(): React.JSX.Element | null {
const renderedActiveWorktreeId = activeWorktreeId
const activeView = useAppStore((s) => s.activeView)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
const activeTabId = useAppStore((s) => s.activeTabId)
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
@@ -709,7 +737,11 @@ function Terminal(): React.JSX.Element | null {
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
// spawning when restoring a session with many saved worktree tabs.
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
const [, setBackgroundMountRevision] = useState(0)
const [backgroundMountRevision, setBackgroundMountRevision] = useState(0)
const [terminalParkingRevision, setTerminalParkingRevision] = useState(0)
const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState<ReadonlySet<string>>(
() => new Set()
)
useEffect(() => {
const timers = measurableBackgroundWorktreeTimersRef.current
const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current
@@ -747,6 +779,122 @@ function Terminal(): React.JSX.Element | null {
closeDialogDebounceTimers.clear()
}
}, [])
useEffect(() => {
const timers = terminalWorktreeParkingTimersRef.current
return () => {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
}, [])
// Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set
// selection, and one recheck timer per still-pending deadline so React
// re-renders exactly when the hysteresis elapses instead of polling.
useEffect(() => {
const parkingTimers = terminalWorktreeParkingTimersRef.current
for (const timer of parkingTimers.values()) {
window.clearTimeout(timer)
}
parkingTimers.clear()
const nowMs = Date.now()
const overrides = getTerminalParkingPolicyOverrides()
const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId))
const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) {
if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
}
}
const retentionCandidates: TerminalWorktreeColdParkCandidate[] = []
for (const workspace of workspaceSurfaces) {
const worktreeId = workspace.id
if (!mountedWorktreeIdsRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
continue
}
const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId)
const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId)
if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
} else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
}
retentionCandidates.push({
worktreeId,
terminalTabs: tabsByWorktree[worktreeId] ?? [],
isVisible,
shouldMeasureHiddenWorktree,
hasActivityTerminalPortal,
hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null
})
}
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
worktrees: retentionCandidates,
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
...overrides
})
// Why: a worktree with any tab the byte watchers cannot cover (no
// capture, no layout snapshot, legacy leaf ids) must never park — it
// would go silent for bells/titles/completions, the failure that sank
// the first parking attempt.
for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) {
const tabs = tabsByWorktree[worktreeId] ?? []
if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) {
nextParkedTerminalWorktreeIds.delete(worktreeId)
}
}
setParkedTerminalWorktreeIds((current) =>
haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds)
? current
: nextParkedTerminalWorktreeIds
)
for (const candidate of retentionCandidates) {
if (
candidate.isVisible ||
candidate.shouldMeasureHiddenWorktree ||
candidate.hasActivityTerminalPortal ||
nextParkedTerminalWorktreeIds.has(candidate.worktreeId)
) {
continue
}
const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: terminalParkingEnabled,
hiddenSinceMs: candidate.hiddenSinceMs,
nowMs,
...overrides
})
if (delayMs !== null && delayMs > 0) {
const worktreeId = candidate.worktreeId
const timer = window.setTimeout(() => {
parkingTimers.delete(worktreeId)
setTerminalParkingRevision((revision) => revision + 1)
}, delayMs)
parkingTimers.set(worktreeId, timer)
}
}
}, [
activeView,
activityTerminalPortals,
backgroundMountRevision,
pendingStartupByTabId,
renderedActiveWorktreeId,
tabsByWorktree,
terminalParkingEnabled,
terminalParkingRevision,
workspaceSurfaces
])
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
@@ -769,6 +917,59 @@ function Terminal(): React.JSX.Element | null {
groupsByWorktree,
activeGroupIdByWorktree
)
// Why: parked byte-watcher reconciliation for the legacy (non-split)
// terminal host, which renders TerminalPanes directly. In split mode each
// TerminalPaneOverlayLayer owns its worktree's watchers, so here we only
// dispose worktrees that render no overlay layer (no layout / unmounted)
// and prune watchers for deleted worktrees.
useEffect(() => {
pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id)))
for (const workspace of workspaceSurfaces) {
if (
anyMountedWorktreeHasLayout &&
mountedWorktreeIdsRef.current.has(workspace.id) &&
getEffectiveLayoutForWorktree(workspace.id)
) {
continue
}
const tabs = tabsByWorktree[workspace.id] ?? []
const parkedTabIds = new Set<string>()
if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) {
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const parked =
!isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id)
if (parked) {
for (const tab of tabs) {
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
worktreeId: workspace.id,
tabId: tab.id
})
if (!activityTerminalPortal) {
parkedTabIds.add(tab.id)
}
}
}
}
syncParkedTerminalTabWatchers({ worktreeId: workspace.id, tabs, parkedTabIds })
}
}, [
activeView,
activityTerminalPortals,
anyMountedWorktreeHasLayout,
backgroundMountRevision,
getEffectiveLayoutForWorktree,
parkedTerminalWorktreeIds,
renderedActiveWorktreeId,
tabsByWorktree,
workspaceSurfaces
])
// Why: symmetric with useTerminalTabColdParking's unmount cleanup — when
// the terminal host unmounts, no reconciliation effect will run again, so
// dispose every remaining parked watcher here (overlay-layer children have
// already disposed theirs by the time this parent cleanup runs).
useEffect(() => () => pruneParkedTerminalWatchers(new Set()), [])
// Auto-create first tab when worktree activates
useEffect(() => {
if (!workspaceSessionReady) {
@@ -1068,6 +1269,12 @@ function Terminal(): React.JSX.Element | null {
if (consumeSuppressedPtyExit(ptyId)) {
return
}
// Why: a parked multi-leaf tab has no PaneManager to promote split
// siblings, so closing the tab here would kill them; the reveal
// remount handles dead PTYs per leaf instead.
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
return
}
handleCloseTab(tabId)
},
[consumeSuppressedPtyExit, handleCloseTab]
@@ -1800,6 +2007,10 @@ function Terminal(): React.JSX.Element | null {
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIds.has(workspace.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${workspace.id}`}
@@ -1809,6 +2020,7 @@ function Terminal(): React.JSX.Element | null {
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
activityTerminalPortals={activityTerminalPortals}
/>
)
@@ -1860,6 +2072,10 @@ function Terminal(): React.JSX.Element | null {
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIds.has(workspace.id)
return (
<div
key={workspace.id}
@@ -1881,6 +2097,12 @@ function Terminal(): React.JSX.Element | null {
const isActivityPortalTab = activityTerminalPortal !== null
const isActiveTerminalTab =
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
// Why: parking is exactly the unmount path tab-group
// moves use — transports detach, the PTY survives, and
// the parked byte watcher owns side effects until reveal.
if (shouldColdParkTerminalPanes && !isActivityPortalTab) {
return null
}
const terminalPane = (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
@@ -2087,6 +2309,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
focusedGroupId,
isVisible,
shouldMeasureHiddenWorktree,
shouldColdParkTerminalPanes,
activityTerminalPortals
}: {
worktreeId: string
@@ -2095,6 +2318,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
focusedGroupId?: string
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
shouldColdParkTerminalPanes: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
}): React.JSX.Element {
const browserPageIds = useAppStore(
@@ -2134,6 +2358,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreeId={worktreeId}
worktreePath={worktreePath}
isWorktreeActive={isVisible}
coldParkTerminalPanes={shouldColdParkTerminalPanes}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
/>
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
@@ -12,6 +12,8 @@ import {
import TerminalPane from './TerminalPane'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
type TerminalOverlayAssignment = {
unifiedTabId: string
@@ -237,6 +239,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
if (consumeSuppressedPtyExit(ptyId)) {
return
}
// Why: a parked multi-leaf tab has no PaneManager to promote split
// siblings, so closing the tab here would kill them; the reveal
// remount handles dead PTYs per leaf instead.
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
return
}
closeTab(terminalTabId)
leaveWorktreeIfEmpty()
}}
@@ -278,11 +286,15 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
worktreePath,
isWorktreeActive,
coldParkTerminalPanes = false,
shouldMeasureHiddenWorktree = false,
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS
}: {
worktreeId: string
worktreePath: string
isWorktreeActive: boolean
coldParkTerminalPanes?: boolean
shouldMeasureHiddenWorktree?: boolean
activityTerminalPortals?: ActivityTerminalPortalTarget[]
}): React.JSX.Element | null {
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
@@ -346,6 +358,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
return entries
}, [groupActiveTabById, unifiedTabs])
const parkedTerminalTabIds = useTerminalTabColdParking({
worktreeId,
terminalTabs,
assignments,
isWorktreeActive,
coldParkTerminalPanes,
shouldMeasureHiddenWorktree,
activityTerminalPortals
})
if (!worktreePath) {
return null
}
@@ -360,6 +382,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
tabId: terminalTab.id
})
// Why: parking is exactly the unmount path tab-group moves use —
// transports detach, the PTY and tab model survive, and the parked
// byte watcher takes over side effects until reveal remounts here.
if (parkedTerminalTabIds.has(terminalTab.id)) {
return null
}
return (
<TerminalOverlaySlot
key={terminalTab.id}
@@ -0,0 +1,66 @@
/**
* Agent-task-complete notification policy predicates and timing constants.
*
* Why extracted from pty-connection.ts: the parked byte watcher and the
* pty:sideEffect facts handler apply the exact live-path semantics without a
* pane, and policy must not drift between the three consumers
* (docs/reference/terminal-side-effect-authority.md). This module is
* deliberately dependency-light — no pane/xterm imports — so pane-less
* consumers can use it.
*/
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { GlobalSettings } from '../../../../shared/types'
/** Delay before BEL/completion OS notifications so the richer
* agent-task-complete notification can win a same-burst BEL race. */
export const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
/** Hard cap on waiting for hook detail before dispatching a completion. */
export const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
export const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
type NotificationSettingsState = {
settings: Pick<GlobalSettings, 'notifications' | 'experimentalTerminalAttention'> | null
}
export function isAgentTaskCompleteOsNotificationEnabledFromState(
state: NotificationSettingsState
): boolean {
const notifications = state.settings?.notifications
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
}
export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean {
return state.settings?.experimentalTerminalAttention === true
}
/** Completion tracking runs when either consumer (OS notification or the
* experimental terminal-attention marker) is enabled. */
export function isAgentTaskCompleteTrackingEnabledFromState(
state: NotificationSettingsState
): boolean {
return (
isAgentTaskCompleteOsNotificationEnabledFromState(state) ||
isTerminalAttentionEnabledFromState(state)
)
}
export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
return Boolean(
entry &&
Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS &&
(entry.lastAssistantMessage || entry.toolName || entry.toolInput)
)
}
export function canDispatchAgentNotificationAfterGrace(
entry: AgentStatusEntry | undefined,
options: { allowDoneDetailAfterGrace?: boolean } = {}
): boolean {
// Why: hook-backed goal/mission loops can report `done` between milestones.
// User-input states may notify as soon as detail arrives, but `done` waits
// for the max quiet window so resumed work can cancel the pending banner.
return (
hasAgentNotificationDetail(entry) &&
(entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true)
)
}
@@ -0,0 +1,813 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts'
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
const PTY_ID = 'pty-parked-1'
const TAB_ID = 'tab-1'
const WORKTREE_ID = 'repo-1::/tmp/wt-1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
const PANE_ID = 1
// Mirrors PARKED_NOTIFICATION_GRACE_MS / AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS.
const NOTIFICATION_GRACE_MS = 250
// Real agent-detection titles: braille spinner classifies as working,
// the "✳ " Claude prefix as idle, and both as Claude agents.
const WORKING_TITLE_OSC = '\x1b]0;⠋ Build feature\x07'
const IDLE_TITLE = '✳ Build feature'
const IDLE_TITLE_OSC = `\x1b]0;${IDLE_TITLE}\x07`
type MockStoreState = {
settings: {
theme?: 'system' | 'dark' | 'light'
promptCacheTimerEnabled?: boolean
experimentalTerminalAttention?: boolean
terminalMainSideEffectAuthority?: boolean
terminalHiddenDeliveryGate?: boolean
notifications?: { enabled?: boolean; agentTaskComplete?: boolean }
} | null
setRuntimePaneTitle: ReturnType<typeof vi.fn>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
updateTabTitle: ReturnType<typeof vi.fn>
markWorktreeUnread: ReturnType<typeof vi.fn>
markTerminalTabUnread: ReturnType<typeof vi.fn>
markTerminalPaneUnread: ReturnType<typeof vi.fn>
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
}
const dispatchTerminalNotification = vi.fn()
let mockStoreState: MockStoreState
vi.mock('./use-notification-dispatch', () => ({
dispatchTerminalNotification
}))
vi.mock('@/lib/terminal-theme', () => ({
getSystemPrefersDark: () => true
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mockStoreState
}
}))
function createMockStoreState(): MockStoreState {
return {
// Why: terminalMainSideEffectAuthority false pins the legacy byte-parser
// mode this suite was written for; the authority-on fact-consumer mode is
// covered by the dedicated describe block below.
settings: {
theme: 'system',
promptCacheTimerEnabled: true,
experimentalTerminalAttention: false,
terminalMainSideEffectAuthority: false,
notifications: { enabled: true, agentTaskComplete: true }
},
setRuntimePaneTitle: vi.fn(),
clearRuntimePaneTitle: vi.fn(),
updateTabTitle: vi.fn(),
markWorktreeUnread: vi.fn(),
markTerminalTabUnread: vi.fn(),
markTerminalPaneUnread: vi.fn(),
setCacheTimerStartedAt: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn()
}
}
describe('startParkedTerminalByteWatcher', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
let onData: ((payload: { id: string; data: string }) => void) | null = null
function emit(data: string): void {
onData?.({ id: PTY_ID, data })
}
// The output processor defers title/bell side effects onto a 0ms drain timer.
function flushSideEffects(): void {
vi.advanceTimersByTime(0)
}
async function startWatcher(
overrides: Partial<ParkedTerminalByteWatcherOptions> = {}
): Promise<{ dispose: () => void; sendInput: ReturnType<typeof vi.fn> }> {
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
const sendInput = vi.fn()
const dispose = startParkedTerminalByteWatcher({
ptyId: PTY_ID,
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneId: PANE_ID,
sendInput,
...overrides
})
return { dispose, sendInput }
}
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
dispatchTerminalNotification.mockClear()
onData = null
mockStoreState = createMockStoreState()
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
pty: {
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
onData = callback
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
ackData: vi.fn()
}
}
} as unknown as typeof window
})
afterEach(() => {
vi.useRealTimers()
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('forwards every OSC title in order to the pane and tab title store actions', async () => {
const { dispose } = await startWatcher()
emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`)
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([
[TAB_ID, PANE_ID, '⠋ Build feature'],
[TAB_ID, PANE_ID, IDLE_TITLE]
])
expect(mockStoreState.updateTabTitle.mock.calls).toEqual([
[TAB_ID, '⠋ Build feature'],
[TAB_ID, IDLE_TITLE]
])
dispose()
})
it('drops the bare cursor-agent native title before it reaches the store', async () => {
const { dispose } = await startWatcher()
emit('\x1b]0;Cursor Agent\x07')
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled()
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
dispose()
})
it('does not drive the tab title when drivesTabTitle is false', async () => {
const { dispose } = await startWatcher({ drivesTabTitle: false })
emit(IDLE_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
dispose()
})
it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => {
const { dispose } = await startWatcher()
emit('build finished\x07')
flushSideEffects()
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'terminal-bell',
paneKey: PANE_KEY
})
dispose()
})
it('marks the exact pane unread when experimental terminal attention is enabled', async () => {
mockStoreState.settings = {
...mockStoreState.settings,
experimentalTerminalAttention: true
}
const { dispose } = await startWatcher()
emit('\x07')
flushSideEffects()
expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY)
dispose()
})
it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => {
const { dispose } = await startWatcher()
emit('\x1b]0;par')
emit('tial title\x07')
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
dispose()
})
it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => {
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
emit(IDLE_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
PANE_KEY,
expect.any(Number)
)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'agent-task-complete',
terminalTitle: IDLE_TITLE,
paneKey: PANE_KEY
})
dispose()
})
it('suppresses the completion OS notification when only terminal attention is on', async () => {
mockStoreState.settings = {
...mockStoreState.settings,
experimentalTerminalAttention: true,
notifications: { enabled: true, agentTaskComplete: false }
}
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
emit(IDLE_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'agent-task-complete',
terminalTitle: IDLE_TITLE,
paneKey: PANE_KEY,
suppressOsNotification: true
})
dispose()
})
it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => {
mockStoreState.settings = {
...mockStoreState.settings,
experimentalTerminalAttention: false,
notifications: { enabled: false }
}
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
emit(IDLE_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
PANE_KEY,
expect.any(Number)
)
dispose()
})
it('lets a same-burst completion supersede the pending bell OS notification', async () => {
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
flushSideEffects()
emit(`${IDLE_TITLE_OSC}\x07`)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8)
// The bell still marks unread immediately; only the OS notification yields.
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(
WORKTREE_ID,
expect.objectContaining({ source: 'agent-task-complete' })
)
dispose()
})
it('cancels the pending completion and clears the cache timer when working resumes', async () => {
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
emit(IDLE_TITLE_OSC)
flushSideEffects()
emit(WORKING_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
dispose()
})
it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => {
const { dispose, sendInput } = await startWatcher()
emit('\x1b[?20')
expect(sendInput).not.toHaveBeenCalled()
emit('31h')
expect(sendInput).toHaveBeenCalledTimes(1)
// theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol.
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
emit('\x1b[?2031l')
expect(sendInput).toHaveBeenCalledTimes(1)
dispose()
})
it('stops answering DECSET 2031 after dispose', async () => {
const { dispose, sendInput } = await startWatcher()
dispose()
emit('\x1b[?2031h')
expect(sendInput).not.toHaveBeenCalled()
})
it('observes GitHub PR links across chunk boundaries', async () => {
const { dispose } = await startWatcher()
emit('PR: https://github.com/orca-dev/orca/pull/42')
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
emit('1\r\ndone')
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
WORKTREE_ID,
expect.objectContaining({
url: 'https://github.com/orca-dev/orca/pull/421',
number: 421,
slug: { owner: 'orca-dev', repo: 'orca' }
})
)
dispose()
})
it('fires completion when seeded with a working title and the agent goes idle while parked', async () => {
// Why: the pane was working at park time; the watcher's fresh tracker
// must be seeded or this working→idle transition can never fire.
const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' })
emit(IDLE_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'agent-task-complete',
terminalTitle: IDLE_TITLE,
paneKey: PANE_KEY
})
dispose()
})
it('does not fire completion for an idle title without a seed or observed transition', async () => {
const { dispose } = await startWatcher()
emit(IDLE_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
dispose()
})
it('clears the watcher-written runtime title slot on dispose', async () => {
const { dispose } = await startWatcher()
emit(IDLE_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
dispose()
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
})
it('leaves the runtime title slot alone on dispose when it never wrote one', async () => {
const { dispose } = await startWatcher()
emit('plain output with no titles\r\n')
flushSideEffects()
dispose()
expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled()
})
it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => {
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
emit(IDLE_TITLE_OSC)
flushSideEffects()
// Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds.
dispose()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
// The teardown flush that main emits after pty.kill must be a no-op.
emit('final teardown flush\x07')
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
it('dispose unregisters the sidecar and cancels the pending bell notification', async () => {
const { dispose } = await startWatcher()
emit('\x07')
flushSideEffects()
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
dispose()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
emit('\x07')
flushSideEffects()
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
// Idempotent: a second dispose must not throw or clobber another watcher.
dispose()
})
it('disposes the previous watcher when a new one starts for the same PTY', async () => {
await startWatcher({ paneId: 1 })
const second = await startWatcher({ paneId: 2 })
emit(IDLE_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1)
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE)
second.dispose()
})
// ─── Main side-effect authority (terminal-side-effect-authority.md) ────
//
// With the kill switch on, the watcher must not register byte parsers —
// main is the single byte parser and the watcher's policy block consumes
// pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031
// reply (query authority never moves to main); PR links arrive as facts.
describe('with main side-effect authority on', () => {
function enableMainAuthority(): void {
mockStoreState.settings = {
...mockStoreState.settings,
terminalMainSideEffectAuthority: true
}
}
async function dispatchFacts(
facts: TerminalSideEffectFact[],
options: { seq?: number; replay?: boolean } = {}
): Promise<void> {
const handler = await import('./terminal-side-effect-facts-handler')
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: PTY_ID,
seq: options.seq ?? 0,
...(options.replay ? { replay: true } : {}),
facts
})
}
/** Feed chunks the way OrcaRuntimeService.onPtyData does: OSC 9999 strip,
* shared title tracker, one fact batch per chunk — the main half of the
* migration-safety parity check. */
async function emitViaMainTrackerFacts(chunks: string[]): Promise<void> {
const { createAgentStatusOscProcessor } = await import('../../../../shared/agent-status-osc')
const { createTerminalTitleTracker } =
await import('../../../../shared/terminal-output-side-effects')
const handler = await import('./terminal-side-effect-facts-handler')
const processStatusChunk = createAgentStatusOscProcessor()
let pending: TerminalSideEffectFact[] = []
const tracker = createTerminalTitleTracker({
onTitle: (normalizedTitle, rawTitle) =>
pending.push({ kind: 'title', normalizedTitle, rawTitle }),
onAgentBecameWorking: () => pending.push({ kind: 'agent-working' }),
onAgentBecameIdle: (title) => pending.push({ kind: 'agent-idle', title }),
onAgentExited: () => pending.push({ kind: 'agent-exited' }),
onBell: () => pending.push({ kind: 'bell' })
})
let seq = 0
for (const chunk of chunks) {
seq += chunk.length
tracker.handleChunk(processStatusChunk(chunk).cleanData)
if (pending.length > 0) {
handler._dispatchTerminalSideEffectBatchForTest({ ptyId: PTY_ID, seq, facts: pending })
pending = []
}
}
tracker.dispose()
}
type RecordedCall = [string, ...unknown[]]
/** Wrap the policy-visible store actions so byte mode and fact mode can be
* compared as one ordered outcome sequence. Timestamps are masked. */
function recordPolicyOutcomes(): RecordedCall[] {
const calls: RecordedCall[] = []
mockStoreState.setRuntimePaneTitle.mockImplementation((...args: unknown[]) => {
calls.push(['setRuntimePaneTitle', ...args])
})
mockStoreState.updateTabTitle.mockImplementation((...args: unknown[]) => {
calls.push(['updateTabTitle', ...args])
})
mockStoreState.markWorktreeUnread.mockImplementation((...args: unknown[]) => {
calls.push(['markWorktreeUnread', ...args])
})
mockStoreState.markTerminalTabUnread.mockImplementation((...args: unknown[]) => {
calls.push(['markTerminalTabUnread', ...args])
})
mockStoreState.markTerminalPaneUnread.mockImplementation((...args: unknown[]) => {
calls.push(['markTerminalPaneUnread', ...args])
})
mockStoreState.setCacheTimerStartedAt.mockImplementation((key: unknown, at: unknown) => {
calls.push(['setCacheTimerStartedAt', key, typeof at === 'number' ? '<ts>' : at])
})
dispatchTerminalNotification.mockImplementation((...args: unknown[]) => {
calls.push(['dispatchTerminalNotification', ...args])
})
return calls
}
it('does not consume bytes: a byte BEL produces no unread or notification', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
emit('build finished\x07')
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
dispose()
})
it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([{ kind: 'bell' }])
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'terminal-bell',
paneKey: PANE_KEY
})
dispose()
})
it('fires the cache timer and completion from working→idle facts', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
{ kind: 'agent-working' }
])
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
await dispatchFacts([
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
{ kind: 'agent-idle', title: IDLE_TITLE }
])
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
PANE_KEY,
expect.any(Number)
)
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'agent-task-complete',
terminalTitle: IDLE_TITLE,
paneKey: PANE_KEY
})
dispose()
})
it('clears state without completion attention for stale-derived idle facts', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
{ kind: 'agent-working' }
])
// Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a
// merely-paused agent must not earn a task-complete notification.
await dispatchFacts([
{
kind: 'title',
normalizedTitle: 'Build feature',
rawTitle: 'Build feature',
staleWorkingTitleClear: true
},
{ kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true }
])
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith(
TAB_ID,
PANE_ID,
'Build feature'
)
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
dispose()
})
it('replay batches restore the title only — attention facts never replay', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts(
[
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
{ kind: 'bell' },
{ kind: 'agent-idle', title: IDLE_TITLE }
],
{ replay: true }
)
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
dispose()
})
it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => {
// Why: with the hidden-delivery gate on (default), parked PTY bytes are
// dropped in main — the fact is the only 2031 signal, and the byte
// sidecar must NOT exist (its registration would re-enable delivery).
enableMainAuthority()
const { dispose, sendInput } = await startWatcher()
emit('\x1b[?2031h')
expect(sendInput).not.toHaveBeenCalled()
await dispatchFacts([{ kind: '2031-subscribe' }])
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
// Why: pr-link facts arrive on the channel; byte-scanning here too
// would observe every link twice.
emit('PR: https://github.com/orca-dev/orca/pull/42\r\n')
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
dispose()
})
it('marks the PTY hidden for delivery on start and clears it on dispose', async () => {
enableMainAuthority()
const setHiddenRendererPty = vi.fn()
;(
window as unknown as { api: { pty: Record<string, unknown> } }
).api.pty.setHiddenRendererPty = setHiddenRendererPty
const { dispose } = await startWatcher()
expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true)
dispose()
// Why: the unhide must land before reveal re-registers pane handlers —
// the watcher registry disposes watchers before the remount effect runs.
expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false)
})
it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => {
enableMainAuthority()
mockStoreState.settings = {
...mockStoreState.settings,
terminalHiddenDeliveryGate: false
} as MockStoreState['settings']
const setHiddenRendererPty = vi.fn()
;(
window as unknown as { api: { pty: Record<string, unknown> } }
).api.pty.setHiddenRendererPty = setHiddenRendererPty
const { dispose, sendInput } = await startWatcher()
// Gate off — bytes keep flowing, so the split-chunk byte scan answers.
emit('\x1b[?20')
expect(sendInput).not.toHaveBeenCalled()
emit('31h')
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
// Why: a 2031-subscribe fact must not double-fire the reply in byte
// mode — exactly one responder owns the answer at any time.
await dispatchFacts([{ kind: '2031-subscribe' }])
expect(sendInput).toHaveBeenCalledTimes(1)
expect(setHiddenRendererPty).not.toHaveBeenCalled()
dispose()
})
it('observes PR links from pr-link facts with worktree attribution', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
const link = {
url: 'https://github.com/orca-dev/orca/pull/421',
slug: { owner: 'orca-dev', repo: 'orca' },
number: 421
}
await dispatchFacts([{ kind: 'pr-link', link }])
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
WORKTREE_ID,
link
)
dispose()
})
it('dispose unregisters the fact consumer and clears a written title slot', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }])
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
dispose()
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
await dispatchFacts([{ kind: 'bell' }])
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
// The key migration-safety check: the same bytes produce the identical
// ordered store outcome whether the watcher parses them directly (kill
// switch off) or consumes main-derived facts over the channel.
it('produces identical store outcomes via the channel as the byte parser did', async () => {
const fixtureChunks = [WORKING_TITLE_OSC, 'agent response body\r\n', `${IDLE_TITLE_OSC}\x07`]
// Pass 1: legacy byte-parser mode.
const byteModeCalls = recordPolicyOutcomes()
{
const { dispose } = await startWatcher()
for (const chunk of fixtureChunks) {
emit(chunk)
flushSideEffects()
}
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
dispose()
}
// Pass 2: fresh modules/store, authority on, facts derived by the
// shared main tracker from the same bytes.
vi.resetModules()
mockStoreState = createMockStoreState()
dispatchTerminalNotification.mockReset()
const factModeCalls = recordPolicyOutcomes()
{
enableMainAuthority()
const { dispose } = await startWatcher()
await emitViaMainTrackerFacts(fixtureChunks)
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
dispose()
}
expect(byteModeCalls.length).toBeGreaterThan(0)
expect(factModeCalls).toEqual(byteModeCalls)
})
})
})
@@ -0,0 +1,348 @@
/**
* Parked terminal side-effect watcher.
*
* Why: parking unmounts the TerminalPane subtree, which tears down the pane's
* side-effect consumers — the parked tab's only source of bell, title,
* agent-completion, and PR-link policy. (Losing them is the gap that sank the
* first parking attempt.) Under main side-effect authority the watcher is
* purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the
* kill switch off it registers the legacy byte parsers on the dispatcher
* sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate:
* gate ON answers from main's '2031-subscribe' fact (no parked bytes exist),
* gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts).
* Either way the reply is sent from the renderer — query authority never
* moves to main. See docs/reference/terminal-hidden-view-parking.md and
* docs/reference/terminal-side-effect-authority.md.
*/
import { isClaudeAgent } from '../../../../shared/agent-detection'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { useAppStore } from '@/store'
import {
mode2031SequenceFor,
resolveTerminalColorSchemeMode
} from '../../../../shared/terminal-color-scheme-protocol'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import {
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
isAgentTaskCompleteOsNotificationEnabledFromState,
isAgentTaskCompleteTrackingEnabledFromState
} from './agent-task-complete-policy'
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
import { createPtyOutputProcessor } from './pty-transport'
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
import {
isMainTerminalSideEffectAuthorityForPty,
registerTerminalSideEffectFactConsumer
} from './terminal-side-effect-facts-handler'
import { dispatchTerminalNotification } from './use-notification-dispatch'
// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts.
// The parked path must keep the live path's BEL-vs-completion race window so
// notification behavior is identical whether a tab is parked or mounted.
const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS
type StoreState = ReturnType<typeof useAppStore.getState>
function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean {
return isAgentTaskCompleteOsNotificationEnabledFromState(state)
}
function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean {
return isAgentTaskCompleteTrackingEnabledFromState(state)
}
export type ParkedTerminalByteWatcherOptions = {
ptyId: string
tabId: string
worktreeId: string
/** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey
* used for cache-timer, unread, and notification attribution. */
leafId: string
/** PaneManager pane id the unmounted pane used. Runtime pane titles are
* keyed by it, so the watcher must write the slot the live path wrote —
* a different id would leave a stale (possibly "working") title behind. */
paneId: number
/** Whether this PTY's pane was the tab's active split pane. Mirrors the
* live path, where only the focused split drives the tab title. */
drivesTabTitle?: boolean
/** The pane's last known runtime title at park time. Seeds the agent
* tracker so an agent that was working when the pane unmounted still
* fires its completion when it goes idle while parked. */
initialTitle?: string
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
sendInput: (data: string) => void
}
const parkedWatcherDisposersByPtyId = new Map<string, () => void>()
export function startParkedTerminalByteWatcher(
options: ParkedTerminalByteWatcherOptions
): () => void {
const { ptyId, tabId, worktreeId, paneId, sendInput } = options
const drivesTabTitle = options.drivesTabTitle ?? true
const paneKey = makePaneKey(tabId, options.leafId)
// Why: one watcher per PTY. A stale watcher from a previous park cycle would
// double-fire bell/completion side effects for the same bytes.
parkedWatcherDisposersByPtyId.get(ptyId)?.()
let disposed = false
let pendingBellNotification = false
// Why: a watcher-written runtime title (especially into a negative fallback
// slot) has no live pane to overwrite it after reveal; a stale 'working'
// entry would pin worktree status forever. Track writes so dispose can
// clear exactly the slot this watcher touched.
let wroteRuntimeTitleSlot = false
let bellNotificationTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteTimer: ReturnType<typeof setTimeout> | null = null
const clearBellNotificationTimer = (): void => {
if (bellNotificationTimer !== null) {
clearTimeout(bellNotificationTimer)
bellNotificationTimer = null
}
}
const clearAgentTaskCompleteTimer = (): void => {
if (agentTaskCompleteTimer !== null) {
clearTimeout(agentTaskCompleteTimer)
agentTaskCompleteTimer = null
}
}
// Why: like the live path, a BEL OS notification only yields when the
// pending completion would itself produce an OS notification.
const hasPendingAgentTaskCompleteNotification = (): boolean =>
agentTaskCompleteTimer !== null &&
isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
const scheduleTerminalBellNotification = (): void => {
if (bellNotificationTimer !== null) {
return
}
bellNotificationTimer = setTimeout(() => {
bellNotificationTimer = null
if (disposed) {
pendingBellNotification = false
return
}
if (hasPendingAgentTaskCompleteNotification()) {
return
}
pendingBellNotification = false
dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey })
}, PARKED_NOTIFICATION_GRACE_MS)
}
// Why: one policy block for both consumption modes — byte parsing (kill
// switch off) and pty:sideEffect facts (main authority on). The semantics
// must be identical or flipping the switch changes notification behavior.
const sideEffectCallbacks = {
onTitleChange: (title: string): void => {
const state = useAppStore.getState()
wroteRuntimeTitleSlot = true
state.setRuntimePaneTitle(tabId, paneId, title)
if (drivesTabTitle) {
state.updateTabTitle(tabId, title)
}
},
onBell: (): void => {
const state = useAppStore.getState()
state.markWorktreeUnread(worktreeId)
state.markTerminalTabUnread(tabId)
if (state.settings?.experimentalTerminalAttention === true) {
state.markTerminalPaneUnread(paneKey)
}
// Why: agent CLIs often emit BEL in the same completion burst as their
// working→idle title change. Delay only the OS notification so the
// richer agent-task-complete notification can win (live-path parity).
pendingBellNotification = true
if (!hasPendingAgentTaskCompleteNotification()) {
scheduleTerminalBellNotification()
}
},
onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => {
// Why: stale-derived idles come from main's unthrottled 3s timer, not
// observed bytes — clear session state, never schedule the completion
// notification a merely-paused agent did not earn (live-path parity).
if (meta?.staleWorkingTitleClear) {
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
return
}
const state = useAppStore.getState()
// Why: mirrors pty-connection — null settings means "not hydrated yet";
// a spurious timestamp is harmless while a dropped one loses the timer.
if (
isClaudeAgent(title) &&
(state.settings === null || state.settings.promptCacheTimerEnabled)
) {
state.setCacheTimerStartedAt(paneKey, Date.now())
}
if (!isAgentTaskCompleteTrackingEnabled(state)) {
return
}
clearAgentTaskCompleteTimer()
agentTaskCompleteTimer = setTimeout(() => {
agentTaskCompleteTimer = null
if (disposed) {
return
}
// Why: the completion supersedes a concurrent BEL so each completion
// burst yields exactly one OS notification, same as the live path.
pendingBellNotification = false
clearBellNotificationTimer()
dispatchTerminalNotification(worktreeId, {
source: 'agent-task-complete',
terminalTitle: title,
paneKey,
...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
? {}
: { suppressOsNotification: true })
})
}, PARKED_NOTIFICATION_GRACE_MS)
},
onAgentBecameWorking: (): void => {
// Why: a new API call refreshes the prompt-cache TTL, so clear any
// running countdown; it restarts when the agent next becomes idle.
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
clearAgentTaskCompleteTimer()
if (pendingBellNotification) {
scheduleTerminalBellNotification()
}
},
onAgentExited: (): void => {
// Why: title reverting to a plain shell means the agent session ended;
// a stale countdown must not survive in the sidebar while parked.
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
}
}
// Why: parking eligibility excludes remote-runtime and SSH PTYs, so every
// watched PTY's bytes transit local main — when the authority switch is on,
// the watcher must NOT register byte parsers (the fact consumer below is
// the single policy consumer; double registration would double-fire bells).
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
settings: useAppStore.getState().settings,
runtimeEnvironmentId: null
})
// Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all —
// facts carry side effects and the reveal remount restores from the model
// snapshot. Decided once at watcher start: it picks which 2031 responder
// (byte sidecar vs fact reply) exists, so it must never flip per chunk.
const hiddenDeliveryGateActive =
mainSideEffectAuthority &&
isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings)
const sendMode2031Reply = (): void => {
const settings = useAppStore.getState().settings
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
}
// Why (byte-parser mode only): reuse the transport's output processor so
// the parked path keeps the exact live-path parsing semantics — all-titles
// ordering, normalization, the cursor-agent native-title drop, the
// OSC-aware stateful bell detector, and the working/idle agent tracker.
// initialAgentTitle: an agent already working at park time must still
// produce a working→idle transition; main's continuous tracker covers this
// in fact-consumer mode.
const processor = mainSideEffectAuthority
? null
: createPtyOutputProcessor({
...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}),
...sideEffectCallbacks
})
// Why (byte-parser mode only): with main authority, pr-link facts arrive on
// the channel below; byte-scanning too would observe every link twice.
const observeTerminalGitHubPRLink = mainSideEffectAuthority
? null
: createTerminalGitHubPRLinkDetector()
const unregisterFactConsumer = mainSideEffectAuthority
? registerTerminalSideEffectFactConsumer({
ptyId,
// Why: no title snapshot on park — the pane's runtime title slot is
// already current at park time, exactly like the byte-parser mode.
callbacks: {
...sideEffectCallbacks,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
// Why (gate mode only): bytes never arrive while gated, so the 2031
// subscribe arrives as a main-tracker fact instead of a byte scan.
// The reply is still sent from here — query authority stays with
// the view/watcher (model/view contract invariant 6).
...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {})
}
})
: null
// Why: no xterm exists while parked, so nothing answers a DECSET 2031
// subscription. With the hidden-delivery gate OFF the byte responder is the
// parked path's only byte consumer under main authority. With the gate ON it
// must NOT register: its subscribeToPtyData sidecar doubles as a
// delivery-interest signal that would force-feed bytes to the gated PTY —
// the fact callback above replaces the byte scan.
const stopMode2031Responder = hiddenDeliveryGateActive
? null
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
// Why: parked tabs are the canonical hidden view — mark the PTY gated so
// main stops renderer byte delivery; dispose clears the bit before the
// reveal remount re-registers pane handlers (existing dispose ordering).
if (hiddenDeliveryGateActive) {
;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, true)
}
// Why (byte-parser mode only): with main authority the watcher consumes
// pty:sideEffect facts exclusively and registers NO byte parsers here —
// title/bell/agent parsing and the PR-link scan would double-fire policy.
const unsubscribeByteParsers =
processor === null
? null
: subscribeToPtyData(ptyId, (data) => {
// Why: empty pane callbacks — the watcher wants only the parser
// side effects; there is no xterm to deliver bytes to.
processor.processData(data, {})
if (observeTerminalGitHubPRLink) {
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
}
}
})
const dispose = (): void => {
if (disposed) {
return
}
disposed = true
// Why: unhide BEFORE the reveal remount registers pane handlers — main
// resumes delivery and (if bytes were dropped) emits the restore marker
// the remounted pane's restore machinery consumes.
if (hiddenDeliveryGateActive) {
;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, false)
}
stopMode2031Responder?.()
unsubscribeByteParsers?.()
unregisterFactConsumer?.()
// Why: cancels the deferred side-effect drain, stale-title timer, and
// tracker/bell-detector state so the watcher cannot fire after the
// revealed pane's live parsers take over.
processor?.clearAccumulatedState()
clearBellNotificationTimer()
clearAgentTaskCompleteTimer()
pendingBellNotification = false
// Why: the store merge never deletes title slots, so a watcher-written
// entry would strand after reveal (the revealing pane re-registers under
// its own pane id) and could pin worktree status 'working'. The revealed
// pane repopulates its slot via its own title flow.
if (wroteRuntimeTitleSlot) {
wroteRuntimeTitleSlot = false
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
}
if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) {
parkedWatcherDisposersByPtyId.delete(ptyId)
}
}
parkedWatcherDisposersByPtyId.set(ptyId, dispose)
return dispose
}
@@ -0,0 +1,50 @@
/**
* DECSET 2031 color-scheme responder for parked terminals (byte-scan mode).
*
* Why a dedicated byte sidecar: no xterm exists while a tab is parked, so
* nothing answers a TUI's mode-2031 theme subscription. Query authority stays
* with the view/watcher (model/view contract invariant 6), so this reply can
* never move to main. Phase 4: this subscribeToPtyData registration doubles
* as a delivery-interest signal, so it is only used while the hidden-delivery
* gate is OFF — gated parked PTYs answer from the main tracker's
* '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts).
*
* Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes
* answer 2031 from xterm once the background queue drains, but a PARKED tab
* has no xterm in any switch-off mode, and the '2031-subscribe' fact is only
* consumed while the gate is ON — this sidecar stays the only answerer here.
*/
import {
mode2031SequenceFor,
resolveTerminalColorSchemeMode,
scanMode2031Sequences
} from '../../../../shared/terminal-color-scheme-protocol'
import { useAppStore } from '@/store'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
export type ParkedTerminalMode2031ResponderOptions = {
ptyId: string
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
sendInput: (data: string) => void
}
export function startParkedTerminalMode2031Responder(
options: ParkedTerminalMode2031ResponderOptions
): () => void {
const { ptyId, sendInput } = options
// Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan
// carries a bounded tail between chunks so split sequences still match.
let scanTail = ''
return subscribeToPtyData(ptyId, (data) => {
const scan = scanMode2031Sequences(scanTail, data)
scanTail = scan.tail
if (!scan.subscribe) {
return
}
// Why: reply with the resolved theme so TUIs that subscribe while parked
// still learn it before the pane is ever revealed.
const settings = useAppStore.getState().settings
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
})
}
@@ -69,4 +69,10 @@ export type PtyConnectionDeps = {
setCacheTimerStartedAt: (key: string, ts: number | null) => void
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void
/** Records a DECSET 2031 subscription answered from main's
* '2031-subscribe' fact, mirroring the xterm CSI handler's registry write
* (paneMode2031 + last replied theme) so later theme flips push CSI 997.
* The reply itself is sent by the fact handler — query authority stays
* with the view (model/view contract invariant 6). */
recordPaneMode2031Subscription?: (paneId: number, repliedMode: 'dark' | 'light') => void
}
File diff suppressed because it is too large Load Diff
@@ -78,7 +78,10 @@ import {
writeTerminalOutput
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
import {
isLocalNativeWindowsConpty,
resolveWindowsShellOverride
} from '@/lib/pane-manager/windows-pty-compatibility'
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import {
captureTerminalWriteScrollIntent,
@@ -119,10 +122,10 @@ import {
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier'
import { createCommandCodeOutputStatusDetector } from './command-code-output-status'
import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status'
import type { PtyDataMeta } from './pty-dispatcher'
import { getEagerPtyBufferHandle } from './pty-dispatcher'
import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
import {
CONPTY_DA1_RESPONSE,
@@ -130,6 +133,7 @@ import {
installTerminalCapabilityReplyHandlers,
sendTerminalOscColorQueryReplies
} from './terminal-capability-replies'
import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel'
import {
cancelScheduledHiddenOutputRestore,
scheduleHiddenOutputRestore
@@ -166,14 +170,23 @@ import {
releaseAgentStartupDeliveryAttempt
} from '@/lib/agent-startup-delayed-delivery'
import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition'
import {
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS,
canDispatchAgentNotificationAfterGrace,
isAgentTaskCompleteOsNotificationEnabledFromState,
isAgentTaskCompleteTrackingEnabledFromState
} from './agent-task-complete-policy'
import {
isMainTerminalSideEffectAuthorityForPty,
registerTerminalSideEffectFactConsumer
} from './terminal-side-effect-facts-handler'
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
const REMOTE_PTY_ID_PREFIX = 'remote:'
const PTY_CONNECT_DIAG_LIMIT = 200
const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500
const STARTUP_DRAFT_PASTE_QUIET_MS = 1500
@@ -333,6 +346,9 @@ type E2eTerminalHiddenSnapshotOverride = {
const e2eTerminalHiddenSnapshotOverrides = new Map<string, E2eTerminalHiddenSnapshotOverride>()
// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes
// either never reach the renderer (delivery gate) or ride the background
// scheduler queue. Only the mode-2031 fact-reply counter still has a producer.
type E2eTerminalPtyOutputDebugSnapshot = {
hiddenRendererSkipCount: number
hiddenRendererSkippedChars: number
@@ -652,40 +668,19 @@ type PanePtyBinding = IDisposable & {
noteVisibilityResume: () => void
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
/** True when the hidden-delivery gate structurally manages the pane's
* current PTY. The lifecycle's xterm CSI ?2031h observer consults this to
* stay silent — main's '2031-subscribe' fact is the sole responder for
* gate-managed PTYs. */
isHiddenDeliveryGateManagedPty: () => boolean
}
function isAgentTaskCompleteNotificationEnabled(): boolean {
return isAgentTaskCompleteNotificationEnabledFromState(useAppStore.getState())
}
function isAgentTaskCompleteNotificationEnabledFromState(
state: ReturnType<typeof useAppStore.getState>
): boolean {
const notifications = state.settings?.notifications
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
}
function isTerminalAttentionEnabledFromState(
state: ReturnType<typeof useAppStore.getState>
): boolean {
return state.settings?.experimentalTerminalAttention === true
return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState())
}
function isAgentTaskCompleteTrackingEnabled(): boolean {
const state = useAppStore.getState()
return (
isAgentTaskCompleteNotificationEnabledFromState(state) ||
isTerminalAttentionEnabledFromState(state)
)
}
function isAgentTaskCompleteTrackingEnabledFromState(
state: ReturnType<typeof useAppStore.getState>
): boolean {
return (
isAgentTaskCompleteNotificationEnabledFromState(state) ||
isTerminalAttentionEnabledFromState(state)
)
return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState())
}
const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>()
@@ -695,7 +690,7 @@ let agentTaskCompleteTrackingSettingsSnapshot: string | null = null
function getAgentTaskCompleteTrackingSettingsSnapshot(
state: ReturnType<typeof useAppStore.getState>
): string {
return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteNotificationEnabledFromState(state)}`
return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}`
}
function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void {
@@ -729,27 +724,6 @@ function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () =>
}
}
function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
return Boolean(
entry &&
Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS &&
(entry.lastAssistantMessage || entry.toolName || entry.toolInput)
)
}
function canDispatchAgentNotificationAfterGrace(
entry: AgentStatusEntry | undefined,
options: { allowDoneDetailAfterGrace?: boolean } = {}
): boolean {
// Why: hook-backed goal/mission loops can report `done` between milestones.
// User-input states may notify as soon as detail arrives, but `done` waits
// for the max quiet window so resumed work can cancel the pending banner.
return (
hasAgentNotificationDetail(entry) &&
(entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true)
)
}
function recordPtyConnectDiagnostic(message: string): void {
if (!e2eConfig.exposeStore) {
return
@@ -1056,6 +1030,11 @@ export function connectPanePty(
// window still drains on the fast path instead of the 1s coalesce fallback.
let synchronizedForegroundFrameInteractive = false
let suppressSnapshotReplayPtyResize = false
// Why: hidden-delivery gate sync is wired up alongside the deferred PTY
// output plumbing inside the connect frame; lifecycle hooks (visibility
// flips, exit, dispose) run before/after it exists, so start with no-ops.
let syncHiddenRendererPtyDelivery: () => void = () => {}
let releaseHiddenRendererPtyDelivery: () => void = () => {}
// Why: idle callbacks are registered before the deferred PTY output plumbing
// exists. Start with the shared scheduler, then switch to the PTY writer
// below so hidden-tab resets keep backlog-recovery callbacks and byte order.
@@ -1519,36 +1498,43 @@ export function connectPanePty(
}
return pendingWrite.then(() => interruptInference.flushPending())
}
const commandLifecycle = createTerminalCommandLifecycle({
onCommandFinished: () => {
// Why: the finished command may have moved HEAD or the index (e.g.
// `git checkout`); nudge git UI now instead of waiting for a poll.
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
const inferenceResult = flushPendingInterruptInference()
if (inferenceResult === true) {
// Why: OSC 133 D means the foreground shell command exited. If an
// interrupt was inferred first, drop only when the current interrupted
// row is still the same turn; otherwise a killed OpenCode CLI leaves a
// stale "interrupted" row even though the process is gone.
dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true })
return
}
if (inferenceResult instanceof Promise) {
void inferenceResult.then((applied) => {
dropCommandFinishedStatusIfSameTurn(entry, {
allowInferredInterrupt: applied === true
})
})
return
}
// Why: OSC 133 D marks the foreground shell command exiting. Remove the
// row without retaining a done snapshot; this section represents a live
// agent process, and the shell prompt means that process is gone.
dropCommandFinishedStatusIfSameTurn(entry)
// Why: one command-finished policy whether the signal arrives as bytes
// (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact —
// routing both through this handler keeps the drop/interrupt semantics
// identical across authority modes.
const handleCommandFinished = (_bestEffortExitCode: number | null): void => {
// Why: the finished command may have moved HEAD or the index (e.g.
// `git checkout`); nudge git UI now instead of waiting for a poll.
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
const inferenceResult = flushPendingInterruptInference()
if (inferenceResult === true) {
// Why: OSC 133 D means the foreground shell command exited. If an
// interrupt was inferred first, drop only when the current interrupted
// row is still the same turn; otherwise a killed OpenCode CLI leaves a
// stale "interrupted" row even though the process is gone.
dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true })
return
}
if (inferenceResult instanceof Promise) {
void inferenceResult.then((applied) => {
dropCommandFinishedStatusIfSameTurn(entry, {
allowInferredInterrupt: applied === true
})
})
return
}
// Why: OSC 133 D marks the foreground shell command exiting. Remove the
// row without retaining a done snapshot; this section represents a live
// agent process, and the shell prompt means that process is gone.
dropCommandFinishedStatusIfSameTurn(entry)
}
const commandLifecycle = createTerminalCommandLifecycle({
onCommandFinished: handleCommandFinished
})
// Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect —
// it stays attached in every authority mode.
commandLifecycle.attachXtermConsumer(pane.terminal)
const onTerminalKeyDown = (event: KeyboardEvent): void => {
if (isPlainEscapeKeyEvent(event)) {
@@ -1611,6 +1597,47 @@ export function connectPanePty(
// Why: bind time lets async liveness reconcile ignore a request started
// before this PTY bound (newborn race). Null disables the guard (fail-safe).
let activePanePtyBindingBoundAt: number | null = null
// Why: with main side-effect authority on, the pane's title/bell/agent
// policy callbacks consume pty:sideEffect facts instead of transport byte
// parsers (which stay unregistered) — same policy code, single consumer.
// restoreTitleOnRegister replaces the eager-replay title restore: main's
// title-only snapshot carries the no-attention-replay rule.
let unregisterSideEffectFactConsumer: (() => void) | null = null
const registerSideEffectFactConsumerForPty = (ptyId: string): void => {
if (!mainSideEffectAuthority || disposed) {
return
}
unregisterSideEffectFactConsumer?.()
unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({
ptyId,
callbacks: {
onTitleChange,
onBell,
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited,
onCommandFinished: handleCommandFinished,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link),
// Why: the Command Code settle policy stays here — the done settle
// timer must consult the live store row (which hook events and
// renderer seeds also write), so main only emits scrape facts.
onCommandCodeWorking: seedCommandCodeOutputWorkingStatus,
onCommandCodeDone: scheduleCommandCodeOutputDoneStatus,
// Why: gated hidden panes never see the subscribe bytes; the fact
// replaces the byte scan (and the old post-latch subscribe drop).
...(hiddenDeliveryGateActive
? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact }
: {})
},
restoreTitleOnRegister: true
})
}
const dropSideEffectFactConsumer = (): void => {
unregisterSideEffectFactConsumer?.()
unregisterSideEffectFactConsumer = null
}
const clearPanePtyFitBinding = (): void => {
// Why: fit bindings live in a module-level map, so pane teardown must
// clear them explicitly instead of relying on DOM removal.
@@ -1712,6 +1739,10 @@ export function connectPanePty(
}
handledExitPtyId = ptyId
agentCompletionCoordinator.dispose()
dropSideEffectFactConsumer()
// Why: main clears gate state on PTY exit too; this only resets the
// pane-local marker so a reused pane cannot skip re-marking a new PTY.
releaseHiddenRendererPtyDelivery()
clearPanePtyFitBinding()
const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId)
if (!isSuppressedExit) {
@@ -1782,7 +1813,11 @@ export function connectPanePty(
let hasConsideredInitialCacheTimerSeed = false
let allowInitialIdleCacheSeed = false
const onTitleChange = (title: string, rawTitle: string): void => {
const onTitleChange = (
title: string,
rawTitle: string,
meta?: { staleWorkingTitleClear?: boolean }
): void => {
const paneTitle = normalizeCompatibleAgentTitleForOwner(title, getAuthoritativePaneAgent())
if (
shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, {
@@ -1795,7 +1830,11 @@ export function connectPanePty(
}
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle)
if (syncAgentTaskCompleteTrackingEnabled()) {
// Why: a stale-derived cleared title comes from main's unthrottled 3s
// timer, not agent output. It must update the visible title but never
// feed completion tracking — observeTitle would classify the cleared
// title as idle and mint a task-complete for a merely-paused agent.
if (!meta?.staleWorkingTitleClear && syncAgentTaskCompleteTrackingEnabled()) {
agentCompletionCoordinator.observeTitle(rawTitle)
}
// Why: only the focused pane should drive the tab title — otherwise two
@@ -1914,11 +1953,6 @@ export function connectPanePty(
}, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
}
const commandCodeOutputStatusDetector = createCommandCodeOutputStatusDetector({
startupCommand: paneStartup?.command,
onWorking: seedCommandCodeOutputWorkingStatus,
onDone: scheduleCommandCodeOutputDoneStatus
})
const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector()
const reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => {
if (!ptyId || isRemoteRuntimePtyId(ptyId)) {
@@ -1941,6 +1975,8 @@ export function connectPanePty(
// Why: record bind time on the spawn/attach chokepoint so the reconcile
// guard knows this binding is newer than any pre-bind snapshot.
activePanePtyBindingBoundAt = performance.now()
registerSideEffectFactConsumerForPty(ptyId)
syncHiddenRendererPtyDelivery()
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? []
if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) {
@@ -2155,7 +2191,16 @@ export function connectPanePty(
// findable after the OS banner is gone. Double-firing with a concurrent BEL
// is handled by delaying the BEL OS notification below; main still keeps a
// 5 s per-worktree dedupe as the final guard.
const onAgentBecameIdle = (title: string): void => {
const onAgentBecameIdle = (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => {
// Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not
// observed bytes — a merely-paused agent (>3s silent mid-task, window
// minimized) would otherwise mint a false task-complete OS notification
// that renderer timer throttling previously damped. Clear session-tied
// state only; never schedule completion attention from it.
if (meta?.staleWorkingTitleClear) {
deps.setCacheTimerStartedAt(cacheKey, null)
return
}
// Why: only start the prompt-cache countdown for Claude agents — other
// agents have different (or no) prompt-caching semantics and showing a
// timer for them would be misleading.
@@ -2243,7 +2288,10 @@ export function connectPanePty(
userAgent: navigator.userAgent,
connectionId,
cwd: deps.cwd,
shellOverride,
// Why: main folds the global Windows shell into its spawn classification
// (pty.ts effectiveShellOverride); fold it here too so both sides treat
// a global-WSL default identically (terminal-query-authority.md ConPTY).
shellOverride: resolveWindowsShellOverride(shellOverride, state.settings?.terminalWindowsShell),
executionHostId
})
if (isNativeWindowsConpty) {
@@ -2287,6 +2335,37 @@ export function connectPanePty(
})
: undefined
const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null
// Why: when main holds side-effect authority for this PTY's bytes, the
// transport must NOT register title/bell/agent byte parsers — the
// pty:sideEffect fact consumer below is the single policy consumer.
// Decided once at transport creation so a fact never has two consumers.
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
settings: state.settings,
runtimeEnvironmentId
})
// Why: Phase-4 hidden-delivery gate — only meaningful under main authority
// (renderer byte parsers need bytes otherwise). Decided once at pane
// creation: it picks the mode-2031 answer path (fact reply vs byte scan),
// which must have exactly one owner.
const hiddenDeliveryGateActive =
mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(state.settings)
// Why: structural per-PTY gate predicate (authority on + gate on + bytes
// transit local main, which implies snapshot-backed). Shared by the hidden
// mark sync and mode-2031 reply ownership so reply ownership can never
// disagree with what main may drop — and never depends on the racy hidden
// mark (a fact can outrun the pty:data task that sets it).
const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string =>
hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId)
// Why (byte-parser mode only): with main authority the Command Code scrape
// runs in main's per-PTY tracker and arrives as command-code facts; running
// the byte detector too would double-drive the seed/settle policy above.
const commandCodeOutputStatusDetector = mainSideEffectAuthority
? null
: createCommandCodeOutputStatusDetector({
startupCommand: paneStartup?.command,
onWorking: seedCommandCodeOutputWorkingStatus,
onDone: scheduleCommandCodeOutputDoneStatus
})
const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste'
const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0
let lastTerminalInputAt = Number.NEGATIVE_INFINITY
@@ -2329,12 +2408,16 @@ export function connectPanePty(
...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}),
...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}),
onPtyExit: onExit,
onTitleChange,
onPtySpawn,
onBell,
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited,
...(mainSideEffectAuthority
? {}
: {
onTitleChange,
onBell,
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited
}),
// Why: local IPC terminals are now model-owned in main: OrcaRuntimeService
// parses OSC 9999 before renderer delivery and forwards through the hook
// server with local/SSH identity. Remote-runtime streams do not pass through
@@ -2404,6 +2487,28 @@ export function connectPanePty(
const transport = runtimeEnvironmentId
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
: createIpcPtyTransport(transportOptions)
// Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031
// responder — visible, hidden, marked or not. Conditioning the reply on the
// hidden mark double-fired (mark set + bytes delivered live via interest →
// fact AND xterm both replied) or dropped the reply entirely (fact outran
// the pty:data task that set the mark). The xterm-side CSI reply and the
// skipped-byte scan are disabled for these panes (same structural
// predicate), so exactly one reply goes out.
const handleHiddenMode2031SubscribeFact = (): void => {
if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
return
}
const mode = resolveTerminalColorSchemeMode(
useAppStore.getState().settings,
getSystemPrefersDark()
)
transport.sendInput(mode2031SequenceFor(mode))
// Why: register the subscription exactly like the xterm CSI handler
// would — without the registry entry, later theme flips never push the
// CSI 997 update and the TUI keeps a stale theme after reveal.
deps.recordPaneMode2031Subscription?.(pane.id, mode)
recordHiddenMode2031Reply()
}
deps.paneTransportsRef.current.set(pane.id, transport)
const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({
terminal: pane.terminal,
@@ -3277,6 +3382,7 @@ export function connectPanePty(
...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}),
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -3633,6 +3739,54 @@ export function connectPanePty(
// can reuse the pane object for a different session before visibility.
let hiddenOutputRestorePtyId: string | null = null
let hiddenOutputRestoreGeneration = 0
// Why: after a snapshot restore, main can still drain ACK-backlog chunks
// whose bytes the snapshot already covers — writing them unguarded
// duplicates visible output. Track the restored baseline seq (per PTY)
// and the expected next chunk start so dataCallback can drop/slice
// overlaps and detect seq gaps from main-side pending-cap trims whose
// one-shot marker was already consumed.
let restoredSnapshotBaselineSeq: number | null = null
let restoredSnapshotBaselinePtyId: string | null = null
let restoredSnapshotExpectedStartSeq: number | null = null
// Why: main samples its pending renderer-delivery queue with the snapshot.
// Chunks at or below this seq can never be backlog duplicates (delivery is
// once-and-in-order), so the dedupe window is (windowStart, baseline].
let restoredSnapshotDeliveryWindowStartSeq: number | null = null
function setRestoredSnapshotBaseline(
ptyId: string,
snapshot: { seq?: number; pendingDeliveryStartSeq?: number }
): void {
if (typeof snapshot.seq !== 'number') {
clearRestoredSnapshotBaseline()
return
}
const windowStartSeq =
typeof snapshot.pendingDeliveryStartSeq === 'number'
? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq)
: null
if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) {
// Why: main reported an empty undelivered backlog — no chunk at or
// below the snapshot seq can ever arrive again (delivery is once and
// in order) and a future pending-cap trim re-arms the out-of-band
// marker. Arming a baseline anyway would misread live chunks from a
// foreign seq domain (restarted counter / synthetic injection) as
// duplicates or trim gaps and silently drop genuinely-new output.
clearRestoredSnapshotBaseline()
return
}
restoredSnapshotBaselineSeq = snapshot.seq
restoredSnapshotBaselinePtyId = ptyId
restoredSnapshotExpectedStartSeq = snapshot.seq
restoredSnapshotDeliveryWindowStartSeq = windowStartSeq
}
function clearRestoredSnapshotBaseline(): void {
restoredSnapshotBaselineSeq = null
restoredSnapshotBaselinePtyId = null
restoredSnapshotExpectedStartSeq = null
restoredSnapshotDeliveryWindowStartSeq = null
}
let foregroundImmediateBudgetChars = 0
let foregroundImmediateBudgetWindowStart = 0
let foregroundRewriteChunkEndedWithCarriageReturn = false
@@ -3678,26 +3832,115 @@ export function connectPanePty(
return transport.serializeBuffer(opts)
}
function respondToSkippedMode2031Subscribe(data: string): void {
const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data)
hiddenMode2031ScanTail = scan.tail
if (scan.finalState === 'unsubscribed') {
deps.paneMode2031Ref.current.delete(pane.id)
deps.paneLastThemeModeRef.current.delete(pane.id)
}
if (scan.finalState !== 'subscribed') {
// Why: hidden/parked panes used to mark hidden only at the first
// dataCallback sync, leaving a spawn-time window where neither side
// answered queries (the spawn-time DA1 loss). Declaring hidden on the
// spawn IPC lets main mark the PTY before its first byte — including
// codex spawns: the model responder answers their startup probes from
// byte zero now that the 10s renderer query window is gone.
// Remote-runtime PTYs are never gate-markable (no local main transit).
function shouldDeclareHiddenAtSpawn(): boolean {
return (
hiddenDeliveryGateActive &&
!runtimeEnvironmentId &&
!disposed &&
!shouldWritePtyOutputForeground(deps.isVisibleRef.current)
)
}
// ── Hidden-delivery gate sync (Phase 4) ─────────────────────────────
// Why: marks this pane's PTY hidden in main while no visible view needs
// its bytes; main then drops delivery after model ingestion and reveal
// restores from the snapshot. The marked id is tracked locally so PTY
// changes (reattach/restart) can never leave a stale id gated.
let hiddenDeliverySyncedPtyId: string | null = null
let hiddenDeliveryMarkedHidden = false
let modelRestoreSubscribedPtyId: string | null = null
let unregisterModelRestoreNeeded: (() => void) | null = null
function sendHiddenRendererPtyDelivery(ptyId: string, hidden: boolean): void {
window.api.pty.setHiddenRendererPty?.(ptyId, hidden)
}
// Why: main reports dropped renderer-bound bytes (hidden gate / pending
// cap) out-of-band — routed per PTY by pty-model-restore-channel.ts.
function handleModelRestoreNeededMarker(): void {
if (disposed) {
return
}
const settings = useAppStore.getState().settings
const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer
// mode 2031 out-of-band so TUIs still render the snapshot with the same
// theme-dependent styling they would have used in a visible pane.
deps.paneMode2031Ref.current.set(pane.id, true)
transport.sendInput(mode2031SequenceFor(mode))
deps.paneLastThemeModeRef.current.set(pane.id, mode)
recordHiddenMode2031Reply()
// Why: dropped bytes invalidate every cross-chunk carry — a partial
// OSC-9999 prefix spanning the gap would corrupt the next live chunk.
transport.resetCrossChunkParserState?.()
// Why: parity with the hidden skip path — a marker landing while a
// restore is in flight means the in-flight snapshot may predate the
// drop, so a fresh snapshot must follow. Captured BEFORE the mark: on a
// visible pane the mark starts a restore synchronously, which must not
// count as "already in flight".
const restoreWasInFlight = hiddenOutputRestoreInFlight !== null
markHiddenOutputRestoreNeeded()
if (restoreWasInFlight) {
hiddenOutputRestoreFreshSnapshotNeeded = true
}
}
function syncModelRestoreNeededSubscription(ptyId: string | null): void {
if (modelRestoreSubscribedPtyId === ptyId) {
return
}
unregisterModelRestoreNeeded?.()
unregisterModelRestoreNeeded = null
modelRestoreSubscribedPtyId = ptyId
// Why: markers exist only for PTYs whose bytes transit local main;
// remote-runtime transports are structurally unaffected.
if (!ptyId || isRemoteRuntimePtyId(ptyId)) {
return
}
unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler(
ptyId,
handleModelRestoreNeededMarker
)
}
syncHiddenRendererPtyDelivery = (): void => {
const ptyId = transport.getPtyId()
syncModelRestoreNeededSubscription(ptyId)
if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) {
if (hiddenDeliveryMarkedHidden) {
sendHiddenRendererPtyDelivery(hiddenDeliverySyncedPtyId, false)
}
hiddenDeliverySyncedPtyId = null
hiddenDeliveryMarkedHidden = false
}
if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) {
return
}
const shouldHide = !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current)
const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId
hiddenDeliverySyncedPtyId = ptyId
if (shouldHide) {
if (!hiddenDeliveryMarkedHidden) {
hiddenDeliveryMarkedHidden = true
sendHiddenRendererPtyDelivery(ptyId, true)
}
} else if (hiddenDeliveryMarkedHidden || isFirstSyncForPty) {
// Why: clear unconditionally on the first sync for a PTY — a stale
// main-side hidden bit can survive a renderer reload for
// daemon-backed PTYs that keep their session id.
hiddenDeliveryMarkedHidden = false
sendHiddenRendererPtyDelivery(ptyId, false)
}
}
releaseHiddenRendererPtyDelivery = (): void => {
if (hiddenDeliverySyncedPtyId !== null && hiddenDeliveryMarkedHidden) {
sendHiddenRendererPtyDelivery(hiddenDeliverySyncedPtyId, false)
}
hiddenDeliverySyncedPtyId = null
hiddenDeliveryMarkedHidden = false
unregisterModelRestoreNeeded?.()
unregisterModelRestoreNeeded = null
modelRestoreSubscribedPtyId = null
}
function beforeTerminalOutputWrite(): void {
recordTerminalOutput(pane.terminal)
}
@@ -3813,6 +4056,27 @@ export function connectPanePty(
}
}
function respondToSkippedMode2031Subscribe(data: string): void {
const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data)
hiddenMode2031ScanTail = scan.tail
if (scan.finalState === 'unsubscribed') {
deps.paneMode2031Ref.current.delete(pane.id)
deps.paneLastThemeModeRef.current.delete(pane.id)
}
if (scan.finalState !== 'subscribed') {
return
}
const settings = useAppStore.getState().settings
const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer
// mode 2031 out-of-band so TUIs still render the snapshot with the same
// theme-dependent styling they would have used in a visible pane.
deps.paneMode2031Ref.current.set(pane.id, true)
transport.sendInput(mode2031SequenceFor(mode))
deps.paneLastThemeModeRef.current.set(pane.id, mode)
recordHiddenMode2031Reply()
}
function writePtyOutputToXterm(
data: string,
foreground: boolean,
@@ -4120,6 +4384,75 @@ export function connectPanePty(
return chunk.data.slice(offset)
}
type RestoredSnapshotReconciliation =
| { action: 'write'; data: string; meta: PtyDataMeta | undefined }
| { action: 'drop-duplicate' }
| { action: 'force-fresh-restore' }
// Why: same slicing rules as getChunkDataAfterSnapshot, applied to LIVE
// chunks after a restore completed — main's ACK backlog keeps draining
// chunks at or before the snapshot seq, and pending-cap trims can drop
// seq ranges silently once the one-shot overflow marker was consumed.
function reconcileChunkAgainstRestoredSnapshot(
data: string,
meta: PtyDataMeta | undefined
): RestoredSnapshotReconciliation {
if (restoredSnapshotBaselineSeq === null) {
return { action: 'write', data, meta }
}
if (transport.getPtyId() !== restoredSnapshotBaselinePtyId) {
clearRestoredSnapshotBaseline()
return { action: 'write', data, meta }
}
if (typeof meta?.seq !== 'number') {
// Why: seq-less chunks (no runtime metering) cannot be reconciled;
// mirror getChunkDataAfterSnapshot and pass them through.
return { action: 'write', data, meta }
}
if (
restoredSnapshotDeliveryWindowStartSeq !== null &&
meta.seq <= restoredSnapshotDeliveryWindowStartSeq
) {
// Why: every byte main could still deliver at snapshot time started
// AFTER this seq, and delivery is once-and-in-order — so this chunk
// cannot be a backlog duplicate. It is a new seq domain (restarted
// counter / synthetic source); retire the stale baseline and write
// instead of silently dropping genuinely-new live output.
clearRestoredSnapshotBaseline()
return { action: 'write', data, meta }
}
const rawLength = meta.rawLength ?? data.length
const startSeq = meta.seq - rawLength
const expectedStartSeq = restoredSnapshotExpectedStartSeq
restoredSnapshotExpectedStartSeq = Math.max(expectedStartSeq ?? meta.seq, meta.seq)
if (expectedStartSeq !== null && startSeq > expectedStartSeq) {
// Why: the chunk starts past the continuity point — bytes between
// were dropped (pending-cap trim after the marker fired). Only the
// model snapshot can heal the gap.
return { action: 'force-fresh-restore' }
}
if (meta.seq <= restoredSnapshotBaselineSeq) {
return { action: 'drop-duplicate' }
}
if (startSeq >= restoredSnapshotBaselineSeq) {
return { action: 'write', data, meta }
}
if (rawLength !== data.length) {
// Why: renderer-only OSC stripping makes raw sequence offsets
// impossible to map onto cleaned text — fetch a fresh snapshot
// instead of risking duplicate visible output.
return { action: 'force-fresh-restore' }
}
const sliced = data.slice(restoredSnapshotBaselineSeq - startSeq)
return {
action: 'write',
data: sliced,
// Why: keep seq metadata consistent with the sliced payload so a
// later restore queue drain slices against accurate offsets.
meta: { ...meta, rawLength: sliced.length }
}
}
function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean {
if (hiddenOutputRestorePendingOverflow) {
hiddenOutputRestorePendingOverflow = false
@@ -4141,6 +4474,12 @@ export function connectPanePty(
hiddenOutputRestorePendingChars = 0
return false
}
// Why: drained chunks advance the post-restore continuity point so
// the live-chunk reconciliation neither re-drops them as duplicates
// nor misreads the next live chunk as a gap.
if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) {
restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq)
}
if (data) {
writePtyOutputToXterm(data, true)
}
@@ -4342,6 +4681,7 @@ export function connectPanePty(
// Why: renderer backlog is tied to the old PTY stream; after reattach,
// queued hidden bytes must not delay or replay before the new PTY.
clearHiddenOutputRestoreState()
clearRestoredSnapshotBaseline()
clearPaneMode2031State()
discardTerminalOutput(pane.terminal)
}
@@ -4534,6 +4874,10 @@ export function connectPanePty(
}
hiddenOutputRestoreDeferredRetryAttempts = 0
applyMainBufferSnapshot(snapshot)
// Why: everything at or before snapshot.seq is now painted; chunks
// still draining from main's ACK backlog below that point are
// duplicates the dataCallback reconciliation must suppress.
setRestoredSnapshotBaseline(currentPtyId, snapshot)
const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded
hiddenOutputRestoreFreshSnapshotNeeded = false
if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) {
@@ -4574,16 +4918,22 @@ export function connectPanePty(
return true
}
unregisterBacklogRecovery = registerTerminalBacklogRecovery(
pane.terminal,
requestHiddenOutputRestoreIfNeeded
)
unregisterBacklogRecovery = registerTerminalBacklogRecovery(pane.terminal, () => {
// Why: clear the hidden-delivery bit BEFORE the restore snapshot
// request — bytes arriving between the unhide IPC and the snapshot
// are reconciled by the existing seq guard.
syncHiddenRendererPtyDelivery()
return requestHiddenOutputRestoreIfNeeded()
})
if (
typeof document !== 'undefined' &&
typeof document.addEventListener === 'function' &&
typeof document.removeEventListener === 'function'
) {
const onDocumentVisibilityChange = (): void => {
// Why: document hide/show flips the foreground predicate without any
// pane lifecycle event — re-sync the hidden-delivery gate both ways.
syncHiddenRendererPtyDelivery()
if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) {
requestHiddenOutputRestoreIfNeeded()
}
@@ -4616,19 +4966,52 @@ export function connectPanePty(
}
respondToTerminalPixelSizeQueries(data)
observeTerminalBracketedPasteModeOutput(pane.terminal, data)
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link)
// Why: with main side-effect authority, command-finished, pr-link, and
// the Command Code scrape arrive as pty:sideEffect facts —
// byte-scanning here too would double-fire the same policy.
// Remote-runtime PTYs (and the kill switch off) keep this byte path as
// their only parser.
if (!mainSideEffectAuthority) {
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link)
}
commandLifecycle.handlePtyData(data)
}
commandCodeOutputStatusDetector.observe(data)
commandLifecycle.handlePtyData(data)
commandCodeOutputStatusDetector?.observe(data)
// Why: split-pane layouts have multiple visible-but-inactive panes whose
// output the user is watching. Throttle only when the pane or whole
// Electron document is hidden.
const foreground =
shouldWritePtyOutputForeground(deps.isVisibleRef.current) && meta?.background !== true
// Why: latch the hidden-delivery gate from the byte path too — covers a
// PTY id arriving after the initial sync. No-op when state is current.
if (!foreground) {
syncHiddenRendererPtyDelivery()
}
if (foreground && hiddenMode2031ScanTail) {
respondToSkippedMode2031Subscribe(data)
}
// Why: post-restore reconciliation — drop/slice backlog chunks the
// restored snapshot already covers, and force a fresh restore for seq
// gaps or overlaps whose offsets cannot be mapped. Runs after the byte
// observers above (those bytes were never delivered before; their side
// effects are still real) but before any xterm write decision.
const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta)
if (reconciliation.action === 'drop-duplicate') {
return
}
if (reconciliation.action === 'force-fresh-restore') {
// Why: in-flight captured BEFORE the mark — on a visible pane the
// mark starts the restore synchronously and must not flag itself.
const restoreWasInFlight = hiddenOutputRestoreInFlight !== null
markHiddenOutputRestoreNeeded()
if (restoreWasInFlight) {
hiddenOutputRestoreFreshSnapshotNeeded = true
}
return
}
data = reconciliation.data
meta = reconciliation.meta
// Why: a hidden Codex query can be split just before visibility changes;
// xterm needs the completed query, while other bytes still follow restore.
const pendingForegroundQuery = foreground
@@ -4677,7 +5060,15 @@ export function connectPanePty(
hiddenOutputRestoreNeeded = true
hiddenOutputRestoreFreshSnapshotNeeded = true
}
// Why: hidden chunks with a restore already latched are dropped here —
// the model snapshot fetched on reveal covers their bytes.
} else {
// Why: gate-managed hidden panes normally receive no bytes (main
// drops after model ingestion). Any hidden chunk that still arrives
// (kill switch off, interest-held delivery) rides the bounded
// background scheduler queue; on overflow the scheduler latches the
// model restore. The kill-switch-off startup-query grammar above is
// the byte-identical fallback.
if (pendingForegroundQuery?.statefulQueryData) {
writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, {
hiddenStartupRendererQuery: true
@@ -4749,6 +5140,8 @@ export function connectPanePty(
}
setPanePtyFitBinding(ptyId)
reportPanePtyVisibility(ptyId, deps.isVisibleRef.current)
registerSideEffectFactConsumerForPty(ptyId)
syncHiddenRendererPtyDelivery()
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
deps.updateTabPtyId(deps.tabId, ptyId)
agentCompletionCoordinator.startProcessTracking()
@@ -5035,6 +5428,7 @@ export function connectPanePty(
? { launchToken: coldRestoreStartup.launchToken }
: {}),
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -5212,6 +5606,7 @@ export function connectPanePty(
: {}),
...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}),
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -5460,6 +5855,12 @@ export function connectPanePty(
return {
syncProcessTracking() {
agentCompletionCoordinator.startProcessTracking()
// Why: the lifecycle hook calls this on every pane visibility flip —
// the hidden-delivery gate must follow the same transitions.
syncHiddenRendererPtyDelivery()
},
isHiddenDeliveryGateManagedPty() {
return isHiddenDeliveryGateManagedPty(transport.getPtyId())
},
// Why: called from the lifecycle visibility effect so the visible-resume
// size readback can repair dropped hidden resizes without refitting against
@@ -5482,6 +5883,9 @@ export function connectPanePty(
cancelAnimationFrame(pendingForegroundGridDriftCheckRaf)
pendingForegroundGridDriftCheckRaf = null
}
// Why: a pane unmount (tab move, parking teardown) must never leave its
// PTY gated — the parked watcher or the remounted pane re-decides.
releaseHiddenRendererPtyDelivery()
if (terminalKeyTargetSupportsEvents) {
terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true })
}
@@ -5522,6 +5926,9 @@ export function connectPanePty(
unregisterDocumentVisibilityRecovery?.()
unregisterDocumentVisibilityRecovery = null
reportPanePtyVisibility(activePanePtyBinding ?? transport.getPtyId(), false)
// Why: a parked-tab watcher may take over this PTY's facts in the same
// effect flush; the pane's consumer must be gone before that handoff.
dropSideEffectFactConsumer()
clearPanePtyFitBinding()
discardTerminalOutput(pane.terminal)
unregisterE2ePtyDataInjection()
@@ -1,9 +1,14 @@
import { acquirePtyDeliveryInterest } from './pty-delivery-interest'
import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher'
/** Register a side-channel data watcher for a PTY without taking ownership
* of the primary handler. Returns an unsubscribe fn. */
export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void {
ensurePtyDispatcher()
// Why: a sidecar is, by definition, a raw-byte consumer — its registration
// doubles as the delivery-interest signal that suppresses main's
// hidden-delivery gate (terminal-side-effect-authority.md, Open Items).
const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId)
let set = ptyDataSidecars.get(ptyId)
if (!set) {
set = new Set()
@@ -11,6 +16,7 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi
}
set.add(watcher)
return () => {
releaseDeliveryInterest()
const current = ptyDataSidecars.get(ptyId)
if (!current) {
return
@@ -0,0 +1,42 @@
/**
* Renderer-side delivery-interest registry for the Phase-4 hidden-delivery
* gate (docs/reference/terminal-side-effect-authority.md, Open Items).
*
* Why: main only drops hidden PTY byte delivery while NO renderer party needs
* raw bytes. Dispatcher sidecars and eager pre-mount buffers register
* interest here; ref-counted so main sees only the 0↔1 transitions.
*/
const ptyDeliveryInterestRefCounts = new Map<string, number>()
function sendPtyDeliveryInterest(ptyId: string, interested: boolean): void {
;(globalThis as { window?: Window }).window?.api?.pty?.setPtyDeliveryInterest?.(ptyId, interested)
}
/** Acquire a delivery-interest hold for a PTY. Returns a release fn that is
* safe to call more than once (only the first call decrements). */
export function acquirePtyDeliveryInterest(ptyId: string): () => void {
const next = (ptyDeliveryInterestRefCounts.get(ptyId) ?? 0) + 1
ptyDeliveryInterestRefCounts.set(ptyId, next)
if (next === 1) {
sendPtyDeliveryInterest(ptyId, true)
}
let released = false
return () => {
if (released) {
return
}
released = true
const current = ptyDeliveryInterestRefCounts.get(ptyId) ?? 0
if (current <= 1) {
ptyDeliveryInterestRefCounts.delete(ptyId)
sendPtyDeliveryInterest(ptyId, false)
} else {
ptyDeliveryInterestRefCounts.set(ptyId, current - 1)
}
}
}
/** Test seam: drop ref counts between tests (no IPC is sent). */
export function _resetPtyDeliveryInterestForTest(): void {
ptyDeliveryInterestRefCounts.clear()
}
@@ -0,0 +1,112 @@
// Why: the Phase-4 hidden-delivery gate only drops bytes while NO renderer
// party needs them. These tests pin the dispatcher-side interest signal: every
// subscribeToPtyData sidecar and every eager pre-mount buffer must surface a
// ref-counted delivery-interest hold to main, and release it exactly once.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('pty dispatcher delivery interest', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
let setPtyDeliveryInterest: ReturnType<typeof vi.fn>
let exitCallback: ((payload: { id: string; code: number }) => void) | null = null
beforeEach(() => {
vi.resetModules()
exitCallback = null
setPtyDeliveryInterest = vi.fn()
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
setPtyDeliveryInterest,
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => {
exitCallback ??= cb
return () => {}
}),
ackData: vi.fn()
}
}
} as unknown as typeof window
})
afterEach(() => {
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('registers interest on the first sidecar and releases on the last unsubscribe', async () => {
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
const unsubscribeFirst = subscribeToPtyData('pty-1', vi.fn())
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-1', true)
// Why: ref-counted — main only sees the 0↔1 transitions.
const unsubscribeSecond = subscribeToPtyData('pty-1', vi.fn())
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
unsubscribeFirst()
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
unsubscribeSecond()
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
})
it('releases sidecar interest only once for repeated unsubscribes', async () => {
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
const unsubscribe = subscribeToPtyData('pty-1', vi.fn())
unsubscribe()
unsubscribe()
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
})
it('holds interest for an eager pre-mount buffer until the pane attach disposes it', async () => {
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
const handle = registerEagerPtyBuffer('pty-eager', vi.fn())
expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-eager', true)
handle.dispose()
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-eager', false)
// Why: dispose + a later exit event must not double-release the hold a
// concurrent sidecar may still own.
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
})
it('releases eager-buffer interest when the PTY exits before any pane mounts', async () => {
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
registerEagerPtyBuffer('pty-eager', vi.fn())
expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-eager', true)
exitCallback?.({ id: 'pty-eager', code: 0 })
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-eager', false)
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
})
it('keeps interest held while a sidecar and an eager buffer overlap', async () => {
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
const handle = registerEagerPtyBuffer('pty-1', vi.fn())
const unsubscribe = subscribeToPtyData('pty-1', vi.fn())
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
handle.dispose()
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
unsubscribe()
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
})
})
@@ -6,6 +6,7 @@
* and the eager-buffer reconnection logic share.
*/
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits'
import { acquirePtyDeliveryInterest } from './pty-delivery-interest'
import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate'
import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp'
import {
@@ -218,6 +219,10 @@ export function registerEagerPtyBuffer(
onExit: (ptyId: string, code: number) => void
): EagerPtyHandle {
ensurePtyDispatcher()
// Why: an eager buffer means a pane mount is (potentially) pending — the
// hidden-delivery gate must keep bytes flowing until the pane attaches and
// takes over, so the buffer holds delivery interest for its lifetime.
const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId)
// Why: a head index instead of Array.shift() — shift() is O(n), making
// pre-attach buffering quadratic under many small chunks. Compaction is deferred.
@@ -246,6 +251,7 @@ export function registerEagerPtyBuffer(
const exitHandler = (code: number): void => {
// Shell died before TerminalPane attached — clean up and notify the store
// so the tab's ptyId is cleared and connectPanePty falls through to connect().
releaseDeliveryInterest()
ptyDataHandlers.delete(ptyId)
ptyReplayHandlers.delete(ptyId)
ptyExitHandlers.delete(ptyId)
@@ -268,6 +274,9 @@ export function registerEagerPtyBuffer(
return data
},
dispose() {
// Why: dispose runs at pane attach (mount completed) — the pane's own
// visibility sync now owns the hidden-delivery decision for this PTY.
releaseDeliveryInterest()
// Only remove if the current handler is still the temp one (compare by
// reference). After attach() replaces the handler this becomes a no-op.
if (ptyDataHandlers.get(ptyId) === dataHandler) {
@@ -0,0 +1,86 @@
// Why: the out-of-band pty:modelRestoreNeeded channel replaces the in-band
// empty-chunk sentinel (ambiguous with chunks fully consumed by OSC-9999
// stripping). These tests pin the channel routing: one channel subscription,
// handlers keyed by PTY id, replace-on-reregister semantics.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('pty model-restore channel routing', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
let onModelRestoreNeeded: ReturnType<typeof vi.fn>
let channelCallback: ((event: { id: string; reason: string; markerSeq?: number }) => void) | null
beforeEach(() => {
vi.resetModules()
channelCallback = null
onModelRestoreNeeded = vi.fn(
(callback: (event: { id: string; reason: string; markerSeq?: number }) => void) => {
channelCallback ??= callback
return () => {}
}
)
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
onModelRestoreNeeded
}
}
} as unknown as typeof window
})
afterEach(() => {
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('attaches the channel once and routes markers to the registered PTY handler', async () => {
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
const handlerA = vi.fn()
const handlerB = vi.fn()
registerPtyModelRestoreNeededHandler('pty-a', handlerA)
registerPtyModelRestoreNeededHandler('pty-b', handlerB)
expect(onModelRestoreNeeded).toHaveBeenCalledTimes(1)
channelCallback?.({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 })
expect(handlerA).toHaveBeenCalledWith({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 })
expect(handlerB).not.toHaveBeenCalled()
// Markers for PTYs without a registered handler are dropped silently.
channelCallback?.({ id: 'pty-unknown', reason: 'pending-cap' })
expect(handlerA).toHaveBeenCalledTimes(1)
expect(handlerB).not.toHaveBeenCalled()
})
it('lets a new registration replace a stale one without the stale unregister clobbering it', async () => {
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
const staleHandler = vi.fn()
const liveHandler = vi.fn()
const unregisterStale = registerPtyModelRestoreNeededHandler('pty-a', staleHandler)
registerPtyModelRestoreNeededHandler('pty-a', liveHandler)
// Why: a reattaching pane can re-register before the old connection's
// teardown runs — the stale unregister must not remove the live handler.
unregisterStale()
channelCallback?.({ id: 'pty-a', reason: 'unhide' })
expect(staleHandler).not.toHaveBeenCalled()
expect(liveHandler).toHaveBeenCalledTimes(1)
})
it('stops routing after the live handler unregisters', async () => {
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
const handler = vi.fn()
const unregister = registerPtyModelRestoreNeededHandler('pty-a', handler)
unregister()
channelCallback?.({ id: 'pty-a', reason: 'hidden-drop' })
expect(handler).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,55 @@
/**
* Singleton router for the out-of-band `pty:modelRestoreNeeded` channel
* (sibling of the pty-dispatcher's data/exit routing — split out to keep the
* dispatcher under the line limit).
*
* Why a dedicated channel + registry: the marker means "main dropped
* renderer-bound bytes (hidden gate / pending cap); restore from the model
* snapshot". It must NOT ride the transport data path — an in-band empty
* chunk is ambiguous with chunks fully consumed by OSC-9999 stripping, and
* remote-runtime transports (which never see main's gate) must stay
* structurally unaffected.
*/
import type { PtyModelRestoreNeededEvent } from '../../../../shared/pty-model-restore-marker'
const ptyModelRestoreNeededHandlers = new Map<string, (event: PtyModelRestoreNeededEvent) => void>()
let modelRestoreNeededChannelAttached = false
function dispatchPtyModelRestoreNeeded(event: PtyModelRestoreNeededEvent): void {
ptyModelRestoreNeededHandlers.get(event.id)?.(event)
}
function ensureModelRestoreNeededChannel(): void {
if (modelRestoreNeededChannelAttached) {
return
}
// Why optional-chained: unit tests and the web remote client expose a
// partial pty API; missing channel means "no markers", never a throw.
const onModelRestoreNeeded = (globalThis as { window?: Window }).window?.api?.pty
?.onModelRestoreNeeded
if (typeof onModelRestoreNeeded !== 'function') {
return
}
modelRestoreNeededChannelAttached = true
onModelRestoreNeeded(dispatchPtyModelRestoreNeeded)
}
/** Register the single model-restore-needed handler for a PTY (the pane
* connection that owns its view). A new registration replaces a stale one. */
export function registerPtyModelRestoreNeededHandler(
ptyId: string,
handler: (event: PtyModelRestoreNeededEvent) => void
): () => void {
ensureModelRestoreNeededChannel()
ptyModelRestoreNeededHandlers.set(ptyId, handler)
return () => {
if (ptyModelRestoreNeededHandlers.get(ptyId) === handler) {
ptyModelRestoreNeededHandlers.delete(ptyId)
}
}
}
/** Test seam: deliver a marker as if it arrived on the channel. */
export function _dispatchPtyModelRestoreNeededForTest(event: PtyModelRestoreNeededEvent): void {
dispatchPtyModelRestoreNeeded(event)
}
@@ -12,6 +12,11 @@ export type PtyBufferSnapshot = {
cols: number
rows: number
seq?: number
/** Lowest seq main could still deliver when the snapshot was taken (start
* of its pending renderer-delivery queue; equals `seq` when empty). Bytes
* are delivered once and in order, so a post-restore chunk at or below
* this seq can never be a duplicate the snapshot already covers. */
pendingDeliveryStartSeq?: number
source?: 'headless' | 'renderer'
/** True when the snapshot captures an alternate-screen TUI (Claude Code,
* vim). Restore must NOT clear xterm's buffer in that case — the TUI's
@@ -50,6 +55,11 @@ export type PtyTransport = {
cols?: number
rows?: number
sessionId?: string
/** Hidden-at-spawn declaration (terminal-query-authority.md): no visible
* view will consume this PTY's bytes, so main marks it hidden BEFORE the
* first byte and the gate + model responder own spawn-time queries.
* Ignored by remote-runtime transports (not gate-markable). */
initiallyHidden?: boolean
command?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
@@ -77,6 +87,10 @@ export type PtyTransport = {
getPtyId: () => string | null
getConnectionId?: () => string | null | undefined
getLocalSessionMetadata?: () => LocalPtySessionMetadata | null
/** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a
* model-restore marker reports dropped bytes — a carry spanning the gap
* would corrupt the next live chunk. IPC transports only. */
resetCrossChunkParserState?: () => void
serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise<PtyBufferSnapshot | null>
preserve?: () => void
detach?: () => void
@@ -215,6 +215,42 @@ describe('createIpcPtyTransport', () => {
transport.disconnect()
})
it('runs title side effects even when the data callback does not render the chunk', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const onTitleChange = vi.fn()
const onDataCallback = vi.fn()
const transport = createIpcPtyTransport({ onTitleChange })
await transport.connect({ url: '', callbacks: { onData: onDataCallback } })
onData?.({ id: 'pty-1', data: '\u001b]0;hidden-title\u0007' })
expect(onDataCallback).toHaveBeenCalledWith('\u001b]0;hidden-title\u0007')
expect(onTitleChange).not.toHaveBeenCalled()
await flushPtySideEffects()
expect(onTitleChange).toHaveBeenCalledWith('hidden-title', 'hidden-title')
transport.disconnect()
})
it('drops the OSC-9999 cross-chunk carry on resetAgentStatusCarry', async () => {
// Why: a model-restore marker means bytes were dropped between chunks —
// a partial OSC-9999 prefix carried across that gap would swallow the
// next live chunk's head as bogus status payload.
const { createPtyOutputProcessor } = await import('./pty-transport')
const processor = createPtyOutputProcessor({})
const callbacks = { onData: vi.fn() }
processor.processData('\x1b]9999;', callbacks)
expect(callbacks.onData).toHaveBeenLastCalledWith('')
processor.resetAgentStatusCarry()
processor.processData('plain output after the gap', callbacks)
expect(callbacks.onData).toHaveBeenLastCalledWith('plain output after the gap')
})
it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => {
vi.useFakeTimers()
try {
@@ -25,7 +25,7 @@ import {
import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer'
import type { PtyDataMeta } from './pty-dispatcher'
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'
import { createBellDetector } from './bell-detector'
import { createBellDetector } from '../../../../shared/terminal-bell-detector'
import {
hasTerminalDisplayContent,
trimIncompleteTerminalControlTail
@@ -80,7 +80,12 @@ type PtyOutputProcessorOptions = Pick<
| 'onAgentBecameWorking'
| 'onAgentExited'
| 'onAgentStatus'
>
> & {
/** Seed for processors that start mid-session (parked-tab byte watchers):
* the pane's last known title, so a working agent that finishes while the
* processor owns the stream still yields a working→idle transition. */
initialAgentTitle?: string
}
type ProcessPtyOutputOptions = {
replayingBufferedData?: boolean
@@ -102,7 +107,8 @@ export function createPtyOutputProcessor({
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited,
onAgentStatus
onAgentStatus,
initialAgentTitle
}: PtyOutputProcessorOptions): {
processData: (
data: string,
@@ -114,10 +120,18 @@ export function createPtyOutputProcessor({
clearStaleTitleTimer: () => void
flushPendingSideEffects: () => void
resetBellDetector: () => void
resetAgentStatusCarry: () => void
} {
const bellDetector = createBellDetector()
const processAgentStatusChunk = createAgentStatusOscProcessor()
let lastEmittedTitle: string | null = null
// Why `let`: a model-restore marker means bytes were dropped between
// chunks; a partial OSC-9999 prefix carried across that gap would swallow
// the next live chunk's head as bogus payload. Reset recreates the parser.
let processAgentStatusChunk = createAgentStatusOscProcessor()
// Why: seed both the emitted-title memory (stale-title probe) and the agent
// tracker so a mid-session processor behaves as if it had observed the
// pane's last live title — full parity with the live path it replaces.
let lastEmittedTitle: string | null =
initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null
let staleTitleTimer: ReturnType<typeof setTimeout> | null = null
let sideEffectDrainTimer: ReturnType<typeof setTimeout> | null = null
let pendingSideEffects: PendingPtySideEffect[] = []
@@ -130,7 +144,8 @@ export function createPtyOutputProcessor({
onAgentBecameIdle?.(title)
},
onAgentBecameWorking,
onAgentExited
onAgentExited,
initialAgentTitle
)
: null
@@ -433,7 +448,10 @@ export function createPtyOutputProcessor({
clearAccumulatedState,
clearStaleTitleTimer,
flushPendingSideEffects,
resetBellDetector: () => bellDetector.reset()
resetBellDetector: () => bellDetector.reset(),
resetAgentStatusCarry: () => {
processAgentStatusChunk = createAgentStatusOscProcessor()
}
}
}
@@ -703,6 +721,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
: {}),
...(connectionId ? { connectionId } : {}),
...(options.sessionId ? { sessionId: options.sessionId } : {}),
// Why: hidden-at-spawn mark must land in main before the PTY's
// first byte, so it rides the spawn IPC instead of the pane's
// first visibility sync (terminal-query-authority.md).
...(options.initiallyHidden ? { initiallyHidden: true } : {}),
worktreeId,
...(tabId ? { tabId } : {}),
...(leafId ? { leafId } : {}),
@@ -969,6 +991,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
}
},
resetCrossChunkParserState() {
// Why: only the OSC-9999 carry spans the dropped-byte gap a
// model-restore marker reports; title/bell trackers re-sync from the
// snapshot's side-effect replay and must not be reset here.
outputProcessor.resetAgentStatusCarry()
},
destroy() {
destroyed = true
this.disconnect()
@@ -1,13 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/headless'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import { getDefaultSettings } from '../../../../shared/constants'
import {
applyTerminalAppearance,
hexToRgba,
installMode2031Handlers,
maybePushMode2031Flip,
mode2031SequenceFor
mode2031SequenceFor,
publishTerminalViewAttributesAtAppStart
} from './terminal-appearance'
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher'
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): {
isConnected: () => boolean
@@ -373,6 +378,133 @@ describe('installMode2031Handlers', () => {
})
})
describe('applyTerminalAppearance theme assignment', () => {
// xterm's OptionsService fires the theme change on object IDENTITY, and
// ThemeService._setTheme then rebuilds the palette, discarding OSC
// 4/10/11/12 SET mutations. Attribute-neutral applies (font size, padding,
// zoom) compose a fresh-but-value-identical theme; assigning it anyway
// wipes TUI color mutations on visible panes while the deduped publisher
// keeps hidden overlays — so the assignment must be value-gated.
function makePane(id: number): ManagedPane {
return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane
}
function makeManager(panes: ManagedPane[]): PaneManager {
return {
getPanes: () => panes,
setPaneLigaturesEnabled: vi.fn(),
setPaneStyleOptions: vi.fn()
} as unknown as PaneManager
}
function apply(pane: ManagedPane, settings: ReturnType<typeof getDefaultSettings>): void {
applyTerminalAppearance(
makeManager([pane]),
settings,
true,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
}
it('keeps options.theme identity across attribute-neutral applies (font size tweak)', () => {
const pane = makePane(1)
const settings = getDefaultSettings('/tmp')
apply(pane, settings)
const firstTheme = pane.terminal.options.theme
expect(firstTheme).toBeDefined()
apply(pane, { ...settings, terminalFontSize: settings.terminalFontSize + 2 })
// Identity-stable theme means xterm never re-runs _setTheme, so a TUI's
// modifyColors mutation survives the font tweak.
expect(pane.terminal.options.theme).toBe(firstTheme)
expect(pane.terminal.options.fontSize).toBe(settings.terminalFontSize + 2)
})
it('still assigns a fresh theme when composed values actually change', () => {
const pane = makePane(1)
const settings = getDefaultSettings('/tmp')
apply(pane, settings)
const firstTheme = pane.terminal.options.theme
apply(pane, { ...settings, terminalColorOverrides: { background: '#102030' } })
expect(pane.terminal.options.theme).not.toBe(firstTheme)
expect(pane.terminal.options.theme?.background).toBe('#102030')
})
})
describe('publishTerminalViewAttributesAtAppStart', () => {
// Phase 6 prerequisite (terminal-query-authority.md): hidden-at-launch
// PTYs can query OSC 10/11 before any terminal pane mounts; the app-start
// publication must go out with no pane manager involved at all.
it('publishes composed attributes without any pane mount and dedupes repeats', () => {
_resetTerminalViewAttributesPublisherForTest()
const sent: TerminalViewAttributes[] = []
const send = (attributes: TerminalViewAttributes): boolean => {
sent.push(attributes)
return true
}
const settings = getDefaultSettings('/tmp')
expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true)
expect(sent).toHaveLength(1)
expect(sent[0]!.ansi).toHaveLength(256)
expect(sent[0]!.cursorStyle).toBe(settings.terminalCursorStyle ?? 'block')
expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(false)
expect(sent).toHaveLength(1)
})
it('makes the later pane-mount applyTerminalAppearance a deduped no-op re-push', () => {
_resetTerminalViewAttributesPublisherForTest()
const publishMock = vi.fn()
;(globalThis as unknown as { window: unknown }).window = {
api: { pty: { publishTerminalViewAttributes: publishMock } }
}
try {
const settings = getDefaultSettings('/tmp')
publishTerminalViewAttributesAtAppStart(settings, true)
expect(publishMock).toHaveBeenCalledTimes(1)
// The first pane mount composes the identical app-global snapshot, so
// the publisher dedupe keeps it a single push.
const manager = {
getPanes: () => [],
setPaneLigaturesEnabled: vi.fn(),
setPaneStyleOptions: vi.fn()
} as unknown as PaneManager
applyTerminalAppearance(
manager,
settings,
true,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
expect(publishMock).toHaveBeenCalledTimes(1)
} finally {
delete (globalThis as { window?: unknown }).window
_resetTerminalViewAttributesPublisherForTest()
}
})
it('publishes nothing before settings are loaded', () => {
_resetTerminalViewAttributesPublisherForTest()
const send = vi.fn(() => true)
expect(publishTerminalViewAttributesAtAppStart(null, true, send)).toBe(false)
expect(send).not.toHaveBeenCalled()
})
})
describe('hexToRgba', () => {
it('converts 6-char hex to rgba', () => {
expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)')
@@ -21,6 +21,8 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
import type { PtyTransport } from './pty-transport'
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
import { HEX_COLOR_RE } from '../../../../shared/color-validation'
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher'
export { mode2031SequenceFor }
@@ -206,6 +208,54 @@ export function composeActiveTerminalTheme(
return theme
}
/** App-start publication (terminal-query-authority.md §Phase 6
* prerequisites): hidden-at-launch PTYs can query OSC 10/11 before any
* terminal pane mounts, and main's responder is silent-until-first-push.
* Composes the same theme applyTerminalAppearance would and publishes it
* through the same deduped publisher, so the later pane-mount apply is a
* no-op re-push. Returns whether a publish actually went out. */
export function publishTerminalViewAttributesAtAppStart(
settings: GlobalSettings | null | undefined,
systemPrefersDark: boolean,
send?: (attributes: TerminalViewAttributes) => boolean
): boolean {
if (!settings) {
return false
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
const theme = composeActiveTerminalTheme(baseTheme, settings)
return send !== undefined
? publishTerminalViewAttributes(theme, appearance.mode, settings, send)
: publishTerminalViewAttributes(theme, appearance.mode, settings)
}
// Value equality over composed ITheme objects (flat string slots plus the
// extendedAnsi string array), used to gate the per-pane options.theme write.
function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean {
if (!a) {
return false
}
if (a === b) {
return true
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
for (const key of keys) {
if (key === 'extendedAnsi') {
continue
}
if (a[key as keyof ITheme] !== b[key as keyof ITheme]) {
return false
}
}
const extA = a.extendedAnsi
const extB = b.extendedAnsi
if (!extA || !extB) {
return extA === extB
}
return extA.length === extB.length && extA.every((value, i) => value === extB[i])
}
export function applyTerminalAppearance(
manager: PaneManager,
settings: GlobalSettings,
@@ -220,6 +270,11 @@ export function applyTerminalAppearance(
const paneStyles = resolvePaneStyleOptions(settings)
const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
const theme = composeActiveTerminalTheme(baseTheme, settings)
// View-attribute bridge (Phase 5 slice 2): this is the single point where
// the composed app-global terminal appearance exists, so publish it to
// main's hidden-PTY query responder here. Deduped inside the publisher —
// per-pane re-applies and attribute-neutral tweaks do not re-push.
publishTerminalViewAttributes(theme, appearance.mode, settings)
const paneBackground = theme?.background ?? '#000000'
const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight)
@@ -229,7 +284,14 @@ export function applyTerminalAppearance(
)
for (const pane of manager.getPanes()) {
if (theme) {
// Why value-gated: xterm's OptionsService fires on object identity, and
// ThemeService._setTheme rebuilds the palette, discarding TUI OSC
// 4/10/11/12 SET mutations. Attribute-neutral applies (font size/family,
// line height, padding, per-pane zoom) compose a fresh-but-identical
// theme; skipping the write keeps visible-pane mutations alive (a
// pre-existing loss this also fixes) and matches the hidden responder's
// deduped overlay behavior, so hidden and visible no longer drift.
if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) {
pane.terminal.options.theme = theme
}
// Why: xterm's allowTransparency has measurable rendering cost, so clear
@@ -1,99 +1,32 @@
import type { Terminal, IDisposable } from '@xterm/xterm'
import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished'
type TerminalCommandLifecycleOptions = {
onCommandFinished: (bestEffortExitCode: number | null) => void
}
type OscTerminator = {
index: number
length: number
}
const OSC_133_PREFIX = '\x1b]133;'
const MAX_OSC_CARRY_LENGTH = 4096
function findOscTerminator(data: string, startIndex: number): OscTerminator | null {
const bel = data.indexOf('\x07', startIndex)
const st = data.indexOf('\x1b\\', startIndex)
if (bel === -1 && st === -1) {
return null
}
if (bel !== -1 && (st === -1 || bel < st)) {
return { index: bel, length: 1 }
}
return { index: st, length: 2 }
}
function parseBestEffortExitCode(value: string | undefined): number | null {
if (!value) {
return null
}
const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed
}
function findPrefixCarry(data: string): string {
const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1)
for (let length = maxCarryLength; length > 0; length -= 1) {
const suffix = data.slice(data.length - length)
if (OSC_133_PREFIX.startsWith(suffix)) {
return suffix
}
}
return ''
}
export function createTerminalCommandLifecycle(options: TerminalCommandLifecycleOptions): {
handlePtyData: (data: string) => void
attachXtermConsumer: (terminal: Terminal) => IDisposable
dispose: () => void
} {
let carry = ''
// Why: the byte parsing lives in shared so main's side-effect tracker emits
// identical command-finished facts for local/SSH PTYs; this renderer wrapper
// remains the byte path for remote-runtime PTYs and the kill-switch-off mode.
const scanner = createOsc133CommandFinishedScanner(options.onCommandFinished)
const disposables: IDisposable[] = []
const handleOsc133 = (payload: string): void => {
const [sequence, exitCode] = payload.split(';')
if (sequence === 'D') {
options.onCommandFinished(parseBestEffortExitCode(exitCode))
}
}
const handlePtyData = (data: string): void => {
let combined = carry + data
carry = ''
while (combined.length > 0) {
const start = combined.indexOf(OSC_133_PREFIX)
if (start === -1) {
carry = findPrefixCarry(combined)
return
}
const payloadStart = start + OSC_133_PREFIX.length
const terminator = findOscTerminator(combined, payloadStart)
if (!terminator) {
carry = combined.slice(start)
if (carry.length > MAX_OSC_CARRY_LENGTH) {
carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH)
}
return
}
handleOsc133(combined.slice(payloadStart, terminator.index))
combined = combined.slice(terminator.index + terminator.length)
}
}
return {
handlePtyData,
handlePtyData: scanner.scan,
attachXtermConsumer(terminal) {
// Why: swallow OSC 133 so shell-integration markers never paint —
// rendering hygiene that applies regardless of side-effect authority.
const disposable = terminal.parser.registerOscHandler(133, () => true)
disposables.push(disposable)
return disposable
},
dispose() {
carry = ''
scanner.reset()
for (const disposable of disposables.splice(0)) {
disposable.dispose()
}
@@ -0,0 +1,45 @@
/**
* Renderer-side predicate for main's Phase-4 hidden PTY delivery gate.
*
* The gate only operates when main holds side-effect authority for the PTY
* (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill
* switch is on. Callers decide once at pane/watcher creation — the decision
* picks which mode-2031 responder is registered (byte sidecar vs fact reply),
* so it must never flip per chunk.
*/
import type { GlobalSettings } from '../../../../shared/types'
// Why: cached once per session — the blocking read should only ever run on
// the pre-hydration startup path, never per pane bind.
let persistedGateFlagCache: boolean | null | undefined
function readPersistedHiddenDeliveryGateFlagSync(): boolean | null {
if (persistedGateFlagCache === undefined) {
try {
const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync
persistedGateFlagCache =
typeof getSync === 'function' ? (getSync()?.terminalHiddenDeliveryGate ?? null) : null
} catch {
persistedGateFlagCache = null
}
}
return persistedGateFlagCache
}
export function isRendererHiddenPtyDeliveryGateEnabled(
settings: Pick<GlobalSettings, 'terminalHiddenDeliveryGate'> | null
): boolean {
if (settings !== null) {
return settings.terminalHiddenDeliveryGate !== false
}
// Why: settings hydrate asynchronously; a pane/watcher bound before
// hydration must honor the persisted kill switch — the responder-mode
// decision made here is never revisited (same rationale as the
// side-effect-authority sync read).
return readPersistedHiddenDeliveryGateFlagSync() !== false
}
/** Test seam: reset the persisted-flag cache between tests. */
export function _resetHiddenPtyDeliveryGateFlagCacheForTest(): void {
persistedGateFlagCache = undefined
}
@@ -0,0 +1,529 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_TAB_HOT_RETAIN_MS,
TERMINAL_WORKTREE_HOT_RETAIN_MS,
TERMINAL_WORKTREE_PARK_DELAY_MS,
canParkTerminalTabRenderer,
canParkTerminalWorktreeRenderers,
getTerminalTabColdParkRecheckDelayMs,
getTerminalWorktreeColdParkRecheckDelayMs,
isSnapshotBackedTerminalPty,
selectColdParkedTerminalTabs,
selectColdParkedTerminalWorktrees
} from './terminal-hidden-view-parking'
describe('isSnapshotBackedTerminalPty', () => {
it('allows local daemon sessions owned by the worktree', () => {
expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true)
expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true)
})
// Why: separator-less ids ('1', '2', 'pty-local-detached') come from the
// daemon-fail-open LocalPtyProvider and have no daemon session model —
// revealing a parked pane would silently respawn a fresh shell, so they
// must not count as snapshot-backed (changed from the ported prior art).
it('rejects separator-less local PTY ids that lack a daemon session model', () => {
expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(false)
expect(isSnapshotBackedTerminalPty('1', 'wt-1')).toBe(false)
})
it('rejects tabs that do not have a PTY yet', () => {
expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false)
})
it('rejects daemon sessions owned by another worktree', () => {
expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false)
expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false)
})
it('rejects SSH and remote runtime PTY handles', () => {
expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false)
expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false)
})
})
describe('canParkTerminalWorktreeRenderers', () => {
const hiddenSinceMs = 1_000
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
const base = {
worktreeId: 'repo::/worktree',
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
pendingStartupByTabId: {},
parkingEnabled: true,
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs,
nowMs
}
it('parks hidden local terminal renderers after the idle delay', () => {
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
})
it('never parks when the settings kill switch disables parking', () => {
expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false)
expect(
canParkTerminalWorktreeRenderers({
...base,
parkingEnabled: false,
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_HOT_RETAIN_MS * 10
})
).toBe(false)
})
it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => {
expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false)
expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe(
false
)
expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe(
false
)
expect(
canParkTerminalWorktreeRenderers({
...base,
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1
})
).toBe(false)
})
it('honors a per-call cold-park delay override', () => {
const shortDelayArgs = { ...base, coldParkDelayMs: 100 }
expect(canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 99 })).toBe(
false
)
expect(
canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 100 })
).toBe(true)
})
it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => {
expect(
canParkTerminalWorktreeRenderers({
...base,
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' },
{ id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' }
]
})
).toBe(false)
})
it('keeps renderers mounted while a tab has startup or activation work pending', () => {
expect(
canParkTerminalWorktreeRenderers({
...base,
pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } }
})
).toBe(false)
expect(
canParkTerminalWorktreeRenderers({
...base,
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true }
]
})
).toBe(false)
expect(
canParkTerminalWorktreeRenderers({
...base,
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 }
]
})
).toBe(false)
})
})
describe('canParkTerminalTabRenderer', () => {
const hiddenSinceMs = 1_000
const base = {
worktreeId: 'wt-1',
terminalTab: {
id: 'tab-1',
ptyId: 'wt-1@@session-1',
isVisible: false,
hasActivityTerminalPortal: false,
hiddenSinceMs
},
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
}
it('parks an idle hidden local tab and honors the kill switch', () => {
expect(canParkTerminalTabRenderer(base)).toBe(true)
expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false)
})
it('honors a per-call cold-park delay override', () => {
expect(
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 })
).toBe(false)
expect(
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 })
).toBe(true)
})
})
describe('selectColdParkedTerminalWorktrees', () => {
const nowMs = 500_000
function localCandidate(worktreeId: string, hiddenSinceMs: number) {
return {
worktreeId,
terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }],
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs
}
}
it('keeps recent hidden local worktrees hot up to the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set())
})
it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set(['wt-3']))
})
it('cold-parks aged local worktrees even when under the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 4
})
expect(selected).toEqual(new Set(['wt-1']))
})
it('selects nothing when the settings kill switch disables parking', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS * 2)
],
pendingStartupByTabId: {},
parkingEnabled: false,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
it('does not cold-park terminals without local snapshot recovery', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
{
...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }]
},
{
...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }]
}
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set(['wt-local']))
})
it('keeps visible, measuring, portaled, and pending terminals mounted', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
{
...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
isVisible: true
},
{
...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
shouldMeasureHiddenWorktree: true
},
{
...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
hasActivityTerminalPortal: true
},
{
...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [
{
id: 'tab-activation',
ptyId: 'wt-activation@@session-1',
pendingActivationSpawn: true
}
]
},
localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)
],
pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } },
parkingEnabled: true,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
})
describe('selectColdParkedTerminalTabs', () => {
const nowMs = 500_000
function localTab(id: string, hiddenSinceMs: number) {
return {
id,
ptyId: `wt-1@@session-${id}`,
pendingActivationSpawn: false,
isVisible: false,
hasActivityTerminalPortal: false,
hiddenSinceMs
}
}
it('keeps visible and recent inactive terminal tabs mounted', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
{ ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true },
localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set())
})
it('cold-parks the oldest inactive local tabs beyond the retain limit', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set(['tab-3']))
})
it('cold-parks aged inactive local tabs even when under the retain limit', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 12
})
expect(selected).toEqual(new Set(['tab-1']))
})
it('selects nothing when the settings kill switch disables parking', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
localTab('tab-2', nowMs - TERMINAL_TAB_HOT_RETAIN_MS * 2)
],
pendingStartupByTabId: {},
parkingEnabled: false,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
it('does not cold-park inactive terminal tabs without local snapshot recovery', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
{
...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
ptyId: 'ssh:ssh-1@@pty-1'
},
{
...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
ptyId: 'remote:env-1@@terminal-1'
}
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set(['tab-local']))
})
it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
{
...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
hasActivityTerminalPortal: true
},
localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
{
...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
pendingActivationSpawn: true
}
],
pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } },
parkingEnabled: true,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
})
describe('getTerminalWorktreeColdParkRecheckDelayMs', () => {
it('returns the next cold-park policy deadline', () => {
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: null,
nowMs: 1_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(50)
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_100,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(900)
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 2_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
it('schedules no recheck when the settings kill switch disables parking', () => {
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: false,
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
})
describe('getTerminalTabColdParkRecheckDelayMs', () => {
it('returns the next terminal-tab cold-park policy deadline', () => {
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: null,
nowMs: 1_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(50)
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_100,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(900)
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 2_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
it('schedules no recheck when the settings kill switch disables parking', () => {
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: false,
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
})
@@ -0,0 +1,285 @@
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import type { TerminalTab } from '../../../../shared/types'
// Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab
// flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible
// working set warm for 5 minutes beyond that.
export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000
export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000
export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4
export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000
export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000
export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12
// Why: tests override these per call (instead of process.env reads inside the
// module) to shrink the 30s hysteresis to test-friendly durations.
export type TerminalColdParkPolicyOverrides = {
coldParkDelayMs?: number
hotRetainMs?: number
hotRetainLimit?: number
}
export type ColdParkableTerminalTab = Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
export type TerminalWorktreeColdParkCandidate = {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
}
export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & {
isVisible: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
}
function getPendingActivationSpawnCount(value: boolean | number | undefined): number {
if (value === true) {
return 1
}
return typeof value === 'number' && value > 0 ? value : 0
}
// Why: parking relies on the daemon model snapshot to re-hydrate. Remote
// runtime and SSH PTYs have no local snapshot in this phase, and a session id
// minted for another worktree reattaches through a path parking cannot replay.
export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean {
if (!ptyId) {
return false
}
if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) {
return false
}
// Why: separator-less ids come from the daemon-fail-open LocalPtyProvider;
// they have no daemon session model, so revealing a parked pane would
// silently respawn a fresh shell instead of restoring the snapshot.
const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR)
return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId
}
export function canParkTerminalWorktreeRenderers(args: {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
// Why: callers pass settings.terminalHiddenViewParking !== false — the
// design-doc kill switch that disables parking entirely.
parkingEnabled: boolean
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
}): boolean {
if (
!args.parkingEnabled ||
args.isVisible ||
args.shouldMeasureHiddenWorktree ||
args.hasActivityTerminalPortal ||
args.hiddenSinceMs === null
) {
return false
}
if (
args.nowMs - args.hiddenSinceMs <
(args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS)
) {
return false
}
return args.terminalTabs.every((tab) => {
if (args.pendingStartupByTabId[tab.id] !== undefined) {
return false
}
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
})
}
export function canParkTerminalTabRenderer(args: {
worktreeId: string
terminalTab: TerminalTabColdParkCandidate
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
nowMs: number
coldParkDelayMs?: number
}): boolean {
const tab = args.terminalTab
if (
!args.parkingEnabled ||
tab.isVisible ||
tab.hasActivityTerminalPortal ||
tab.hiddenSinceMs === null
) {
return false
}
if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) {
return false
}
if (args.pendingStartupByTabId[tab.id] !== undefined) {
return false
}
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
}
type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number }
// Why: hot-retain keeps the most recently hidden ids warm up to the limit;
// ids hidden past hotRetainMs or beyond the limit cold-park. Ties sort by id
// so the selection is deterministic.
function selectIdsBeyondHotRetain(
candidates: ColdParkRetainCandidate[],
args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number }
): Set<string> {
const coldParkedIds = new Set<string>()
const retainedCandidates: ColdParkRetainCandidate[] = []
for (const candidate of candidates) {
if (args.nowMs - candidate.hiddenSinceMs >= args.hotRetainMs) {
coldParkedIds.add(candidate.id)
} else {
retainedCandidates.push(candidate)
}
}
retainedCandidates.sort((a, b) => {
const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs
return recencyDelta === 0 ? a.id.localeCompare(b.id) : recencyDelta
})
for (const candidate of retainedCandidates.slice(Math.max(0, args.hotRetainLimit))) {
coldParkedIds.add(candidate.id)
}
return coldParkedIds
}
export function selectColdParkedTerminalWorktrees(
args: {
worktrees: readonly TerminalWorktreeColdParkCandidate[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
nowMs: number
} & TerminalColdParkPolicyOverrides
): Set<string> {
if (!args.parkingEnabled) {
return new Set()
}
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
const candidates: ColdParkRetainCandidate[] = []
for (const worktree of args.worktrees) {
if (
worktree.hiddenSinceMs === null ||
!canParkTerminalWorktreeRenderers({
...worktree,
pendingStartupByTabId: args.pendingStartupByTabId,
parkingEnabled: args.parkingEnabled,
nowMs: args.nowMs,
coldParkDelayMs
})
) {
continue
}
candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs })
}
return selectIdsBeyondHotRetain(candidates, {
nowMs: args.nowMs,
hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS,
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT
})
}
export function selectColdParkedTerminalTabs(
args: {
worktreeId: string
terminalTabs: readonly TerminalTabColdParkCandidate[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
nowMs: number
} & TerminalColdParkPolicyOverrides
): Set<string> {
if (!args.parkingEnabled) {
return new Set()
}
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS
const candidates: ColdParkRetainCandidate[] = []
for (const tab of args.terminalTabs) {
if (
tab.hiddenSinceMs === null ||
!canParkTerminalTabRenderer({
worktreeId: args.worktreeId,
terminalTab: tab,
pendingStartupByTabId: args.pendingStartupByTabId,
parkingEnabled: args.parkingEnabled,
nowMs: args.nowMs,
coldParkDelayMs
})
) {
continue
}
candidates.push({ id: tab.id, hiddenSinceMs: tab.hiddenSinceMs })
}
return selectIdsBeyondHotRetain(candidates, {
nowMs: args.nowMs,
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS,
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT
})
}
// Why: parking decisions change only at the cold-park and hot-retain
// deadlines, so callers schedule one recheck at the next deadline instead of
// polling.
function nextColdParkDeadlineDelayMs(args: {
parkingEnabled: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs: number
hotRetainMs: number
}): number | null {
if (!args.parkingEnabled || args.hiddenSinceMs === null) {
return null
}
const pendingDeadlines = [
args.hiddenSinceMs + args.coldParkDelayMs,
args.hiddenSinceMs + args.hotRetainMs
].filter((deadlineMs) => deadlineMs > args.nowMs)
return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs
}
export function getTerminalWorktreeColdParkRecheckDelayMs(args: {
parkingEnabled: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
hotRetainMs?: number
}): number | null {
return nextColdParkDeadlineDelayMs({
parkingEnabled: args.parkingEnabled,
hiddenSinceMs: args.hiddenSinceMs,
nowMs: args.nowMs,
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
})
}
export function getTerminalTabColdParkRecheckDelayMs(args: {
parkingEnabled: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
hotRetainMs?: number
}): number | null {
return nextColdParkDeadlineDelayMs({
parkingEnabled: args.parkingEnabled,
hiddenSinceMs: args.hiddenSinceMs,
nowMs: args.nowMs,
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
})
}
@@ -0,0 +1,483 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
const WORKTREE_ID = 'repo::/worktree'
const OTHER_WORKTREE_ID = 'repo::/other-worktree'
const TAB_ID = 'tab-1'
const PTY_ID = `${WORKTREE_ID}@@session-1`
const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2`
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
type StartedWatcher = {
options: ParkedTerminalByteWatcherOptions
dispose: ReturnType<typeof vi.fn>
}
const startedWatchers: StartedWatcher[] = []
const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => {
const dispose = vi.fn()
startedWatchers.push({ options, dispose })
return dispose
})
vi.mock('./parked-terminal-byte-watcher', () => ({
startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) =>
startParkedTerminalByteWatcher(options)
}))
type ExitSubscription = {
ptyId: string
callback: (code: number) => void
unsubscribe: ReturnType<typeof vi.fn>
}
const exitSubscriptions: ExitSubscription[] = []
const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => {
const unsubscribe = vi.fn()
exitSubscriptions.push({ ptyId, callback, unsubscribe })
return unsubscribe
})
vi.mock('./pty-dispatcher', () => ({
subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) =>
subscribeToPtyExit(ptyId, callback)
}))
type MockStoreState = {
terminalLayoutsByTabId: Record<
string,
{
root: unknown
activeLeafId: string | null
expandedLeafId: string | null
ptyIdsByLeafId?: Record<string, string>
}
>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
}
let mockStoreState: MockStoreState
vi.mock('@/store', () => ({
useAppStore: { getState: () => mockStoreState }
}))
import {
canWatcherCoverParkedTerminalTab,
captureParkedTerminalPaneCandidates,
disposeParkedTerminalWatchersForPtyIds,
disposeParkedTerminalWatchersForWorktree,
fallbackParkedPaneCandidates,
getParkedTerminalWatcherTabIds,
pruneParkedTerminalWatchers,
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
const ptyWrite = vi.fn()
const originalWindow = (globalThis as { window?: unknown }).window
function capturePanes(
panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[],
args?: { tabId?: string; worktreeId?: string }
): void {
captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes)
}
function syncParked(args?: {
worktreeId?: string
tabs?: { id: string; ptyId: string | null }[]
parkedTabIds?: Iterable<string>
}): void {
syncParkedTerminalTabWatchers({
worktreeId: args?.worktreeId ?? WORKTREE_ID,
tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }],
parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID])
})
}
describe('terminal-parked-tab-watchers', () => {
beforeEach(() => {
mockStoreState = {
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
clearRuntimePaneTitle: vi.fn()
}
;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } }
})
afterEach(() => {
// Module-level registries persist across tests; clear them through the
// public prune path so each test starts from an empty parked state.
pruneParkedTerminalWatchers(new Set())
startedWatchers.length = 0
exitSubscriptions.length = 0
vi.clearAllMocks()
;(globalThis as { window?: unknown }).window = originalWindow
})
it('starts one watcher per captured snapshot-backed PTY with the captured pane identity', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
expect(startedWatchers[0].options).toMatchObject({
ptyId: PTY_ID,
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneId: 1,
drivesTabTitle: true
})
expect(startedWatchers[1].options).toMatchObject({
ptyId: SECOND_PTY_ID,
paneId: 2,
drivesTabTitle: false
})
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
startedWatchers[0].options.sendInput('\x1b[?2031;1$y')
expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y')
})
it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID })
})
it('never starts watchers for remote-runtime or SSH PTYs', () => {
capturePanes([
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled()
// Why: the tab is still tracked as parked so debug introspection
// (window.__terminalParkingDebug) reflects every parked tab.
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('keeps existing watchers across repeated syncs of the same parked state', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
syncParked()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
})
it('disposes the watcher and exit subscription when the tab unparks', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
syncParked({ parkedTabIds: [] })
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
it('disposes the watcher when the tab closes while parked', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
syncParked({ tabs: [], parkedTabIds: [TAB_ID] })
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
it('disposes a PTY watcher when that PTY exits while parked', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)
exited?.callback(0)
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
// The tab itself is still parked, only the exited PTY's watcher is gone.
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('seeds each watcher with the pane slot last known runtime title', () => {
mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } }
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
expect(startedWatchers[0].options.initialTitle).toBe('⠋ Build feature')
expect(startedWatchers[1].options.initialTitle).toBeUndefined()
})
it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
disposeParkedTerminalWatchersForPtyIds([PTY_ID])
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
// Why: the entry survives so a sleeping parked tab cannot restart a
// watcher against its stale PTY ids before wake re-mints them.
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
syncParked()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
})
it('restarts watchers from store layout when the tab PTY was re-minted', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
const remintedPtyId = `${WORKTREE_ID}@@session-after-wake`
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId }
}
syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] })
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID })
})
it('scopes sync disposal to the given worktree', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
const otherPtyId = `${OTHER_WORKTREE_ID}@@session-9`
capturePanes([{ ptyId: otherPtyId, paneId: 1, leafId: SECOND_LEAF_ID, drivesTabTitle: true }], {
tabId: 'tab-other',
worktreeId: OTHER_WORKTREE_ID
})
syncParked({
worktreeId: OTHER_WORKTREE_ID,
tabs: [{ id: 'tab-other', ptyId: otherPtyId }],
parkedTabIds: ['tab-other']
})
// Unparking everything in the other worktree must not touch this one.
syncParked({ worktreeId: OTHER_WORKTREE_ID, tabs: [], parkedTabIds: [] })
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
})
it('disposes all of a worktree watchers on worktree teardown and prunes deleted worktrees', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
disposeParkedTerminalWatchersForWorktree(WORKTREE_ID)
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([])
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
pruneParkedTerminalWatchers(new Set([OTHER_WORKTREE_ID]))
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
describe('shouldDeferParkedPtyExitTabClose', () => {
const closeTab = vi.fn()
// Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab.
function hostOnPtyExit(tabId: string, ptyId: string): void {
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
return
}
closeTab(tabId)
}
it('defers tab close on PTY exit in a parked multi-leaf tab and clears the dead slot', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
hostOnPtyExit(TAB_ID, PTY_ID)
expect(closeTab).not.toHaveBeenCalled()
// The dead leaf's runtime-title slot cannot pin worktree status.
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1)
})
it('keeps exit→closeTab parity for a parked single-leaf tab', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
hostOnPtyExit(TAB_ID, PTY_ID)
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
})
it('keeps exit→closeTab parity when the tab is not parked', () => {
hostOnPtyExit(TAB_ID, PTY_ID)
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
})
it('closes the tab when the last surviving leaf of a parked split exits', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
// First leaf dies: deferred, then its exit sidecar drops the watcher.
hostOnPtyExit(TAB_ID, PTY_ID)
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
expect(closeTab).not.toHaveBeenCalled()
hostOnPtyExit(TAB_ID, SECOND_PTY_ID)
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
})
})
describe('canWatcherCoverParkedTerminalTab', () => {
it('rejects a tab with no unmount capture and no layout snapshot', () => {
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
it('accepts a current capture whose panes are all snapshot-backed', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
true
)
})
it('rejects a capture containing a legacy non-UUID leaf id', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
it('rejects a capture containing a PTY without snapshot backing', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
it('accepts layout-derived candidates when the capture is stale', () => {
capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
}
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
true
)
})
it('rejects layout-derived candidates missing a leaf PTY binding', () => {
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
root: {
type: 'split',
direction: 'row',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
},
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
}
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
})
})
describe('fallbackParkedPaneCandidates', () => {
it('returns nothing without a layout snapshot', () => {
expect(
fallbackParkedPaneCandidates(
{ id: TAB_ID, ptyId: PTY_ID },
{ terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} }
)
).toEqual([])
})
it('reuses the single runtime-title slot for a single-pane tab', () => {
expect(
fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, {
terminalLayoutsByTabId: {
[TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null }
},
runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } }
} as never)
).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }])
})
it('maps split leaves to layout PTYs with collision-free negative pane ids', () => {
expect(
fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, {
terminalLayoutsByTabId: {
[TAB_ID]: {
root: {
type: 'split',
direction: 'row',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
},
activeLeafId: SECOND_LEAF_ID,
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID }
}
},
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } }
} as never)
).toEqual([
{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false },
{ ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true }
])
})
})
@@ -0,0 +1,243 @@
/**
* Parked terminal tab watcher lifecycle.
*
* Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer
* byte parsers. This module owns the pane-less replacement: it remembers the
* unmounted panes' identities (pane id / leaf id), starts one
* parked-terminal-byte-watcher per PTY when a tab parks, and disposes them on
* reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps
* live in terminal-parked-watcher-registry so the terminals store slice can
* dispose watchers without importing this store-coupled module.
* See docs/reference/terminal-hidden-view-parking.md.
*/
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids'
import { subscribeToPtyExit } from './pty-dispatcher'
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
import {
capturedPanesByTabId,
disposeParkedTabWatchers,
parkedWatchersByTabId,
type ParkedTerminalPaneCapture
} from './terminal-parked-watcher-registry'
// Why: re-exported so park wiring keeps one import surface; the registry
// split exists only to break the store-slice import cycle.
export {
captureParkedTerminalPaneCandidates,
disposeParkedTerminalWatchersForPtyIds,
disposeParkedTerminalWatchersForWorktree,
getParkedTerminalWatcherTabIds,
pruneParkedTerminalWatchers
} from './terminal-parked-watcher-registry'
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId'>
type ParkedPaneFallbackState = {
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
runtimePaneTitlesByTabId: ReturnType<typeof useAppStore.getState>['runtimePaneTitlesByTabId']
}
// Why: if no unmount capture exists (or it predates a PTY respawn), derive
// pane identities from the persisted layout snapshot. Numeric pane ids are
// unknown here: reuse the single existing runtime-title slot when unambiguous
// so a stale "working" title still gets overwritten, otherwise use negative
// slots that can never collide with real PaneManager ids.
export function fallbackParkedPaneCandidates(
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
): ParkedTerminalPaneCapture[] {
const layout = state.terminalLayoutsByTabId[tab.id]
const leafIds = collectLeafIdsInOrder(layout?.root)
if (leafIds.length === 0) {
return []
}
const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {}
const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {})
const reusableSlot =
leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null
return leafIds.map((leafId, index) => ({
ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null),
paneId: reusableSlot ?? -(index + 1),
leafId,
drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0
}))
}
// Why: unmount captures and layout fallbacks must resolve identically for the
// watcher start path and the park-eligibility coverage check, or a tab could
// pass the check and then start with different (uncoverable) candidates.
function resolveParkedTerminalPaneCandidates(
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
): ParkedTerminalPaneCapture[] {
const captured = capturedPanesByTabId.get(tab.id)
// Why: a capture that no longer mentions the tab's current PTY is stale
// (the PTY was re-minted since the unmount); fall back to the layout.
const capturedIsCurrent =
captured !== undefined &&
captured.panes.length > 0 &&
(tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId))
return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state)
}
/**
* Whether the parked byte watchers can fully cover this tab's PTYs (some
* candidate exists and every candidate has a snapshot-backed PTY bound to a
* valid leaf). Hosts must refuse to park a tab that fails this check —
* parking it would silently drop bell/title/completion side effects, the
* exact failure that sank the first parking attempt.
*/
export function canWatcherCoverParkedTerminalTab(
worktreeId: string,
tab: ParkableTerminalTabModel
): boolean {
const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())
return (
panes.length > 0 &&
panes.every(
(pane) =>
pane.ptyId !== null &&
isTerminalLeafId(pane.leafId) &&
isSnapshotBackedTerminalPty(pane.ptyId, worktreeId)
)
)
}
function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabModel): void {
const state = useAppStore.getState()
const panes = resolveParkedTerminalPaneCandidates(tab, state)
const disposersByPtyId = new Map<string, () => void>()
const paneIdByPtyId = new Map<string, number>()
for (const pane of panes) {
const ptyId = pane.ptyId
// Why: the park policy already excludes non-snapshot-backed PTYs, but the
// tab model can change between the park decision and this effect — guard
// again so remote-runtime/SSH PTYs never get a local watcher. Legacy
// non-UUID leaf ids are skipped because makePaneKey throws on them.
if (
!ptyId ||
disposersByPtyId.has(ptyId) ||
!isTerminalLeafId(pane.leafId) ||
!isSnapshotBackedTerminalPty(ptyId, worktreeId)
) {
continue
}
const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId]
const disposeWatcher = startParkedTerminalByteWatcher({
ptyId,
tabId: tab.id,
worktreeId,
leafId: pane.leafId,
paneId: pane.paneId,
drivesTabTitle: pane.drivesTabTitle,
// Why: seed the watcher's agent tracker with the pane's last known
// title so an agent already working at park time still notifies when
// it finishes while parked.
...(initialTitle !== undefined ? { initialTitle } : {}),
// Why: no pane transport exists while parked; write straight to the
// PTY, the same channel background agent launches use.
sendInput: (data) => window.api.pty.write(ptyId, data)
})
// Why: a PTY that exits while parked has no pane to run exit cleanup; at
// minimum its watcher must not outlive it.
const unsubscribeExit = subscribeToPtyExit(ptyId, () => {
disposersByPtyId.get(ptyId)?.()
disposersByPtyId.delete(ptyId)
// Why: with the last watcher gone there is nothing left to watch or
// dispose; dropping the entry keeps the registry bounded to parked
// tabs that still hold live PTYs.
const entry = parkedWatchersByTabId.get(tab.id)
if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) {
parkedWatchersByTabId.delete(tab.id)
}
})
paneIdByPtyId.set(ptyId, pane.paneId)
disposersByPtyId.set(ptyId, () => {
unsubscribeExit()
disposeWatcher()
})
}
// Why: tracked even with zero watchers so parked-state introspection
// (window.__terminalParkingDebug) reflects every parked tab.
parkedWatchersByTabId.set(tab.id, {
worktreeId,
tabPtyId: tab.ptyId,
paneIdByPtyId,
disposersByPtyId
})
}
/**
* Hosts call this from their onPtyExit handlers before closing the tab.
* Returns true when the close must be deferred: a parked tab has no
* PaneManager to promote split siblings, so the live exit path degenerates to
* "close the whole tab" — which would kill the surviving sibling panes. The
* reveal remount handles dead PTYs per leaf instead. Single-leaf parked tabs
* return false so exit→closeTab parity is preserved. Also clears the dead
* leaf's runtime-title slot so a stale title cannot pin worktree status.
*/
export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean {
const entry = parkedWatchersByTabId.get(tabId)
if (!entry) {
return false
}
const paneId = entry.paneIdByPtyId.get(ptyId)
if (paneId !== undefined) {
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
}
const remaining = entry.disposersByPtyId.size
if (remaining === 0) {
return false
}
// Why: this runs from the PTY exit handler, before the exit sidecar above
// removes the dead PTY's watcher — so the watcher count still includes the
// exiting PTY. More than one watcher (or an exit for an unwatched PTY)
// means live sibling leaves remain.
return remaining > 1 || !entry.disposersByPtyId.has(ptyId)
}
/**
* Reconciles watchers for one worktree against its rendered parked set.
* Callers run this from an effect keyed on the committed render state, so
* disposal lands in the same effect flush as a reveal remount (before any
* PTY data IPC can be delivered) and start lands after the park unmount.
*/
export function syncParkedTerminalTabWatchers(args: {
worktreeId: string
tabs: readonly ParkableTerminalTabModel[]
parkedTabIds: ReadonlySet<string>
}): void {
const liveTabIds = new Set(args.tabs.map((tab) => tab.id))
for (const [tabId, entry] of parkedWatchersByTabId) {
if (entry.worktreeId !== args.worktreeId) {
continue
}
if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) {
disposeParkedTabWatchers(tabId)
}
}
// Why: captures for closed tabs have no future park/reveal; drop them so
// the registry stays bounded by live tabs.
for (const [tabId, capture] of capturedPanesByTabId) {
if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) {
capturedPanesByTabId.delete(tabId)
}
}
for (const tab of args.tabs) {
if (!args.parkedTabIds.has(tab.id)) {
continue
}
const entry = parkedWatchersByTabId.get(tab.id)
if (entry && entry.tabPtyId !== tab.ptyId) {
disposeParkedTabWatchers(tab.id)
}
if (!parkedWatchersByTabId.has(tab.id)) {
startParkedTabWatchers(args.worktreeId, tab)
}
}
}
@@ -0,0 +1,107 @@
/**
* Parked terminal watcher registry (store-free bookkeeping).
*
* Why a separate module: shutdownWorktreeTerminals (a store slice) must
* synchronously dispose parked watchers, but the watcher lifecycle module
* imports the store — a slice importing it would re-enter store creation
* mid-evaluation. Keeping the maps and pure disposal here lets the slice
* import cycle-free, mirroring how pty-dispatcher exports its handler maps.
*/
export type ParkedTerminalPaneCapture = {
ptyId: string | null
/** PaneManager numeric pane id the live pane used for runtime titles. */
paneId: number
/** Stable terminal-layout leaf UUID (paneKey attribution). */
leafId: string
drivesTabTitle: boolean
}
export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] }
export const capturedPanesByTabId = new Map<string, CapturedTabPanes>()
// Why: PaneManager pane ids die with the unmounted pane, but the watcher must
// keep writing the exact runtime-title slots the live pane used — a different
// slot would strand a stale "working" title that pins worktree status.
// TerminalPane unmount records the identities here for the park wiring.
export function captureParkedTerminalPaneCandidates(
tabId: string,
worktreeId: string,
panes: ParkedTerminalPaneCapture[]
): void {
capturedPanesByTabId.set(tabId, { worktreeId, panes })
}
export type ParkedTabWatcherEntry = {
worktreeId: string
/** Tab-level ptyId at watcher start; a change means the PTY was re-minted
* (e.g. wake respawn) and the watchers must restart against fresh ids. */
tabPtyId: string | null
/** Runtime-title slot each watcher writes, so parked PTY-exit handling can
* clear the dead leaf's slot (no live pane will ever overwrite it). */
paneIdByPtyId: Map<string, number>
disposersByPtyId: Map<string, () => void>
}
export const parkedWatchersByTabId = new Map<string, ParkedTabWatcherEntry>()
export function getParkedTerminalWatcherTabIds(): string[] {
return Array.from(parkedWatchersByTabId.keys())
}
export function disposeParkedTabWatchers(tabId: string): void {
const entry = parkedWatchersByTabId.get(tabId)
if (!entry) {
return
}
parkedWatchersByTabId.delete(tabId)
for (const dispose of entry.disposersByPtyId.values()) {
dispose()
}
entry.disposersByPtyId.clear()
}
/**
* Synchronously disposes any parked watcher subscribed to these PTYs.
* shutdownWorktreeTerminals silences the live transports' final teardown
* flush via unregisterPtyDataHandlers, but parked watchers ride the
* dispatcher SIDECAR channel that call does not touch — without this, the
* flush still marks unread and arms notification timers for a worktree that
* is already sleeping or deleted. The tab entries are kept so a sleeping
* parked tab does not restart watchers against its stale PTY ids; wake
* re-mints the ids and the sync path restarts watchers then.
*/
export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]): void {
for (const entry of parkedWatchersByTabId.values()) {
for (const ptyId of ptyIds) {
const dispose = entry.disposersByPtyId.get(ptyId)
if (dispose) {
entry.disposersByPtyId.delete(ptyId)
dispose()
}
}
}
}
export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void {
for (const [tabId, entry] of parkedWatchersByTabId) {
if (entry.worktreeId === worktreeId) {
disposeParkedTabWatchers(tabId)
}
}
}
/** Drops watchers and captures for worktrees that no longer exist. */
export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet<string>): void {
for (const [tabId, entry] of parkedWatchersByTabId) {
if (!liveWorktreeIds.has(entry.worktreeId)) {
disposeParkedTabWatchers(tabId)
}
}
for (const [tabId, capture] of capturedPanesByTabId) {
if (!liveWorktreeIds.has(capture.worktreeId)) {
capturedPanesByTabId.delete(tabId)
}
}
}
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null }
let mockE2EConfig: MockE2EConfig
vi.mock('@/lib/e2e-config', () => ({
get e2eConfig() {
return mockE2EConfig
}
}))
vi.mock('./terminal-parked-tab-watchers', () => ({
getParkedTerminalWatcherTabIds: () => ['tab-parked']
}))
const originalWindow = (globalThis as { window?: unknown }).window
type TerminalParkingE2EOverridesModule = {
getTerminalParkingPolicyOverrides: () => {
coldParkDelayMs?: number
hotRetainMs?: number
hotRetainLimit?: number
}
registerTerminalParkingDebugHandle: () => void
}
async function importOverridesModule(): Promise<TerminalParkingE2EOverridesModule> {
vi.resetModules()
return import('./terminal-parking-e2e-overrides')
}
describe('getTerminalParkingPolicyOverrides', () => {
beforeEach(() => {
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
delete (globalThis as { window?: unknown }).window
})
afterEach(() => {
;(globalThis as { window?: unknown }).window = originalWindow
})
it('ignores the delay override outside e2e (exposeStore off)', async () => {
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({})
})
it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => {
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({
coldParkDelayMs: 500,
hotRetainMs: 500
})
})
it('returns no overrides when no delay is configured', async () => {
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({})
})
it('registers window.__terminalParkingDebug on import under exposeStore', async () => {
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
const testWindow: {
__terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] }
} = {}
;(globalThis as { window?: unknown }).window = testWindow
await importOverridesModule()
expect(testWindow.__terminalParkingDebug?.parkDelayMs).toBe(500)
expect(testWindow.__terminalParkingDebug?.parkedTabIds()).toEqual(['tab-parked'])
})
it('does not register the debug handle outside e2e', async () => {
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
const testWindow: { __terminalParkingDebug?: unknown } = {}
;(globalThis as { window?: unknown }).window = testWindow
await importOverridesModule()
expect(testWindow.__terminalParkingDebug).toBeUndefined()
})
})
@@ -0,0 +1,34 @@
import { e2eConfig } from '@/lib/e2e-config'
import {
TERMINAL_TAB_COLD_PARK_DELAY_MS,
type TerminalColdParkPolicyOverrides
} from './terminal-hidden-view-parking'
import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers'
// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park
// hysteresis and the hot-retain window — recently hidden tabs otherwise sit
// in the hot-retain working set for 5 minutes and never park within a test
// run. Gated on exposeStore so packaged builds ignore stray env vars.
export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides {
const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null
return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0
? { coldParkDelayMs: delayMs, hotRetainMs: delayMs }
: {}
}
export function registerTerminalParkingDebugHandle(): void {
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
return
}
window.__terminalParkingDebug = {
parkDelayMs:
getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
parkedTabIds: () => getParkedTerminalWatcherTabIds()
}
}
// Why: the parking e2e spec gates on window.__terminalParkingDebug existing
// shortly after launch. This module is statically imported by the park
// wiring, so registering at module load makes the handle visible before any
// tab parks.
registerTerminalParkingDebugHandle()
@@ -0,0 +1,509 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalSideEffectBatch } from '../../../../shared/terminal-side-effect-facts'
import {
_dispatchTerminalSideEffectBatchForTest,
_resetTerminalSideEffectFactConsumersForTest,
isMainTerminalSideEffectAuthorityForPty,
registerTerminalSideEffectFactConsumer,
type TerminalSideEffectFactConsumerCallbacks
} from './terminal-side-effect-facts-handler'
const PTY_ID = 'wt-1#1'
function createCallbackRecorder(): {
callbacks: TerminalSideEffectFactConsumerCallbacks
events: unknown[][]
} {
const events: unknown[][] = []
return {
events,
callbacks: {
onTitleChange: (normalizedTitle, rawTitle) =>
events.push(['title', normalizedTitle, rawTitle]),
onBell: () => events.push(['bell']),
onAgentBecameIdle: (title) => events.push(['idle', title]),
onAgentBecameWorking: () => events.push(['working']),
onAgentExited: () => events.push(['exited'])
}
}
}
function batch(
facts: TerminalSideEffectBatch['facts'],
options: Partial<TerminalSideEffectBatch> = {}
): TerminalSideEffectBatch {
return { ptyId: PTY_ID, seq: 0, facts, ...options }
}
describe('isMainTerminalSideEffectAuthorityForPty', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
beforeEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
delete (globalThis as { window?: unknown }).window
})
afterEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
function setPersistedSettingsSync(settings: unknown): void {
;(globalThis as { window: unknown }).window = {
api: { settings: { getSync: () => settings } }
}
}
it('is on by default for PTYs whose bytes transit local main', () => {
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: {}, runtimeEnvironmentId: null })
).toBe(true)
// Why: settings hydrate asynchronously; the default-on switch must not
// flip authority off during the null-settings startup window.
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
).toBe(true)
})
it('is off for remote-runtime PTYs regardless of the setting', () => {
expect(
isMainTerminalSideEffectAuthorityForPty({
settings: { terminalMainSideEffectAuthority: true },
runtimeEnvironmentId: 'env-1'
})
).toBe(false)
})
it('is off when the kill switch is disabled', () => {
expect(
isMainTerminalSideEffectAuthorityForPty({
settings: { terminalMainSideEffectAuthority: false },
runtimeEnvironmentId: null
})
).toBe(false)
})
it('honors the persisted kill switch before settings hydrate', () => {
// Why: the authority decision is made once at transport creation; a pane
// bound during startup must not pick main authority when the user
// persisted the switch off.
setPersistedSettingsSync({ terminalMainSideEffectAuthority: false })
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
).toBe(false)
})
it('stays on pre-hydration when the persisted switch is on or unset', () => {
setPersistedSettingsSync({ terminalMainSideEffectAuthority: true })
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
).toBe(true)
_resetTerminalSideEffectFactConsumersForTest()
setPersistedSettingsSync({})
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
).toBe(true)
})
it('prefers hydrated settings over the persisted sync read', () => {
setPersistedSettingsSync({ terminalMainSideEffectAuthority: false })
expect(
isMainTerminalSideEffectAuthorityForPty({
settings: { terminalMainSideEffectAuthority: true },
runtimeEnvironmentId: null
})
).toBe(true)
})
it('caches the sync read so panes do not re-block per bind', () => {
const getSync = vi.fn(() => ({ terminalMainSideEffectAuthority: false }))
;(globalThis as { window: unknown }).window = { api: { settings: { getSync } } }
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
expect(getSync).toHaveBeenCalledTimes(1)
})
})
describe('registerTerminalSideEffectFactConsumer', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
beforeEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
delete (globalThis as { window?: unknown }).window
})
afterEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('routes live facts to the registered consumer in batch order', () => {
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
_dispatchTerminalSideEffectBatchForTest(
batch([
{ kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' },
{ kind: 'agent-working' },
{ kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' },
{ kind: 'agent-idle', title: '✳ Claude' },
{ kind: 'bell' }
])
)
expect(events).toEqual([
['title', '⠋ Claude', '⠋ Claude'],
['working'],
['title', '✳ Claude', '✳ Claude'],
['idle', '✳ Claude'],
['bell']
])
})
it('routes command-finished and pr-link facts to the registered consumer', () => {
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
onPrLink: (link) => events.push(['pr', link.url, link.number])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch([
{ kind: 'command-finished', exitCode: 130 },
{
kind: 'pr-link',
link: {
url: 'https://github.com/acme/orca/pull/42',
slug: { owner: 'acme', repo: 'orca' },
number: 42
}
},
{ kind: 'command-finished', exitCode: null }
])
)
expect(events).toEqual([
['finished', 130],
['pr', 'https://github.com/acme/orca/pull/42', 42],
['finished', null]
])
})
it('routes command-code scrape facts to the registered consumer', () => {
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]),
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch([
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
])
)
expect(events).toEqual([
['cc-working', 'Fix the spinner'],
['cc-done', 'Fix the spinner']
])
})
it('never replays command-code scrape facts', () => {
// Why: a replayed working/done seed would resurrect a finished turn's
// status row — replay batches restore title state only.
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]),
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
],
{ replay: true, seq: 5 }
)
)
expect(events).toEqual([['title', 'restored']])
})
it('routes 2031-subscribe facts to the registered consumer but never replays them', () => {
// Why: the fact lets hidden-delivery-gated views answer the color-scheme
// query without byte access; a replayed subscribe would re-answer a query
// the snapshot already satisfied.
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
onMode2031Subscribe: () => events.push(['2031-subscribe'])
}
})
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: '2031-subscribe' }]))
expect(events).toEqual([['2031-subscribe']])
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
{ kind: '2031-subscribe' }
],
{ replay: true, seq: 5 }
)
)
expect(events).toEqual([['2031-subscribe'], ['title', 'restored']])
})
it('never replays command-finished or pr-link facts', () => {
// Why: like bells and agent transitions, command/PR facts are attention
// signals — replay snapshots restore title state only.
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
onPrLink: (link) => events.push(['pr', link.url])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
{ kind: 'command-finished', exitCode: 0 },
{
kind: 'pr-link',
link: {
url: 'https://github.com/acme/orca/pull/42',
slug: { owner: 'acme', repo: 'orca' },
number: 42
}
}
],
{ replay: true, seq: 5 }
)
)
expect(events).toEqual([['title', 'restored']])
})
it('passes stale-clear provenance through to the title and idle callbacks', () => {
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onTitleChange: (normalizedTitle, _rawTitle, meta) =>
events.push(['title', normalizedTitle, meta]),
onAgentBecameIdle: (title, meta) => events.push(['idle', title, meta])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch([
{ kind: 'agent-idle', title: 'Codex done' },
{
kind: 'title',
normalizedTitle: 'Codex',
rawTitle: 'Codex',
staleWorkingTitleClear: true
},
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
])
)
expect(events).toEqual([
['idle', 'Codex done', undefined],
['title', 'Codex', { staleWorkingTitleClear: true }],
['idle', 'Codex', { staleWorkingTitleClear: true }]
])
})
it('drops batches for PTYs without a registered consumer', () => {
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'other-pty' }))
expect(events).toEqual([])
})
it('applies only title facts from replay batches — no attention replay', () => {
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{ kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' },
{ kind: 'bell' },
{ kind: 'agent-idle', title: '✳ Claude' }
],
{ replay: true, seq: 10 }
)
)
expect(events).toEqual([['title', '✳ Claude', '✳ Claude']])
})
it('drops a replay title not newer than the last applied live title', () => {
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 })
)
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], {
replay: true,
seq: 20
})
)
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'title', normalizedTitle: 'newer', rawTitle: 'newer' }], {
replay: true,
seq: 21
})
)
expect(events).toEqual([
['title', 'live', 'live'],
['title', 'newer', 'newer']
])
})
it('keeps exactly one consumer per PTY: a new registration replaces the old', () => {
const first = createCallbackRecorder()
const second = createCallbackRecorder()
const disposeFirst = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: first.callbacks
})
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks })
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
// A stale registration's dispose must not evict the live consumer.
disposeFirst()
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
expect(first.events).toEqual([])
expect(second.events).toEqual([['bell'], ['bell']])
})
it('stops routing after the consumer unregisters', () => {
const { callbacks, events } = createCallbackRecorder()
const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
dispose()
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
expect(events).toEqual([])
})
it('subscribes to the channel once and routes IPC batches', () => {
let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null
const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => {
channelCallback = callback
return () => {
channelCallback = null
}
})
;(globalThis as { window: unknown }).window = {
api: { pty: { onSideEffect } }
}
const first = createCallbackRecorder()
const second = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: first.callbacks })
registerTerminalSideEffectFactConsumer({ ptyId: 'pty-2', callbacks: second.callbacks })
expect(onSideEffect).toHaveBeenCalledTimes(1)
channelCallback!(batch([{ kind: 'bell' }], { ptyId: 'pty-2' }))
expect(second.events).toEqual([['bell']])
})
it('applies the title snapshot on register unless the registration was replaced', async () => {
let resolveSnapshot: (value: TerminalSideEffectBatch | null) => void = () => {}
const getSideEffectSnapshot = vi.fn(
() =>
new Promise<TerminalSideEffectBatch | null>((resolve) => {
resolveSnapshot = resolve
})
)
;(globalThis as { window: unknown }).window = {
api: { pty: { getSideEffectSnapshot } }
}
const first = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: first.callbacks,
restoreTitleOnRegister: true
})
expect(getSideEffectSnapshot).toHaveBeenCalledWith(PTY_ID)
// Replace before the snapshot resolves: the slow snapshot must not fire
// into the superseded registration.
const second = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks })
resolveSnapshot(
batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], {
replay: true,
seq: 5
})
)
await Promise.resolve()
expect(first.events).toEqual([])
expect(second.events).toEqual([])
})
it('restores the snapshot title for a live registration', async () => {
const snapshot = batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], {
replay: true,
seq: 5
})
;(globalThis as { window: unknown }).window = {
api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } }
}
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks,
restoreTitleOnRegister: true
})
await Promise.resolve()
await Promise.resolve()
expect(events).toEqual([['title', 'restored', 'restored']])
})
})
@@ -0,0 +1,242 @@
/**
* Renderer consumer registry for the `pty:sideEffect` channel.
*
* Why: with main as the side-effect parser for local-daemon/SSH PTYs
* (docs/reference/terminal-side-effect-authority.md), the renderer no longer
* derives title/bell/agent facts from bytes for those PTYs. This module is
* the single channel subscriber; mounted panes and parked-tab watchers
* register exactly one fact consumer per PTY (their existing policy
* callbacks), so every fact has exactly one policy consumer regardless of
* whether the tab is mounted, hidden, or parked. Facts for PTYs without a
* registered consumer are dropped — mirroring today's eager-buffer behavior
* where pre-mount output produces no attention side effects.
*/
import type { GlobalSettings } from '../../../../shared/types'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type {
TerminalSideEffectBatch,
TerminalSideEffectFact
} from '../../../../shared/terminal-side-effect-facts'
// Why: cached once per session — the blocking read should only ever run on
// the pre-hydration startup path, never per pane bind.
let persistedAuthorityFlagCache: boolean | null | undefined
function readPersistedSideEffectAuthorityFlagSync(): boolean | null {
if (persistedAuthorityFlagCache === undefined) {
try {
const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync
persistedAuthorityFlagCache =
typeof getSync === 'function' ? (getSync()?.terminalMainSideEffectAuthority ?? null) : null
} catch {
persistedAuthorityFlagCache = null
}
}
return persistedAuthorityFlagCache
}
/**
* Structural authority predicate: main owns side effects for a PTY when its
* bytes transit local main (everything except remote-runtime PTYs) and the
* kill switch is on. Decided at transport/watcher creation — never per chunk —
* so each fact has one consumer with no race.
*/
export function isMainTerminalSideEffectAuthorityForPty(args: {
settings: Pick<GlobalSettings, 'terminalMainSideEffectAuthority'> | null
/** Remote-runtime owner environment; null means bytes transit local main. */
runtimeEnvironmentId: string | null
}): boolean {
if (args.runtimeEnvironmentId !== null) {
return false
}
if (args.settings !== null) {
return args.settings.terminalMainSideEffectAuthority !== false
}
// Why: settings hydrate asynchronously, and the authority decision made
// here at transport/watcher creation is never revisited. A pane bound
// before hydration must honor the persisted kill switch — otherwise a user
// who turned main authority off gets startup panes with no byte parsers
// and a fact consumer they disabled. Surfaces without the sync read (web
// remote clients, tests) keep the default-on behavior.
return readPersistedSideEffectAuthorityFlagSync() !== false
}
export type TerminalSideEffectFactConsumerCallbacks = {
/** `meta.staleWorkingTitleClear` marks facts derived from main's 3s
* stale-title timer — policy must clear title/cache state without
* scheduling task-complete notifications or unread attention. */
onTitleChange?: (
normalizedTitle: string,
rawTitle: string,
meta?: { staleWorkingTitleClear?: boolean }
) => void
onBell?: () => void
onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void
onAgentBecameWorking?: () => void
onAgentExited?: () => void
/** OSC 133;D — same policy hook the byte-mode commandLifecycle drove
* (stale agent-status row drop + interrupt-inference coordination). */
onCommandFinished?: (bestEffortExitCode: number | null) => void
onPrLink?: (link: TerminalGitHubPRLink) => void
/** Command Code output scrape (no hooks): working seeds the status row;
* done is settle-checked by the pane policy before completing the turn. */
onCommandCodeWorking?: (prompt: string) => void
onCommandCodeDone?: (prompt: string) => void
/** DECSET 2031 subscribe observed by main's tracker. Registered only by
* hidden-delivery-gated consumers (their bytes never arrive); the theme
* reply is sent renderer-side — query authority stays with the view. */
onMode2031Subscribe?: () => void
}
type ConsumerEntry = {
callbacks: TerminalSideEffectFactConsumerCallbacks
/** Output sequence of the last live title fact applied. Replay snapshots at
* or before this point are stale and must not regress the title state. */
lastLiveTitleSeq: number | null
}
const consumersByPtyId = new Map<string, ConsumerEntry>()
let channelUnsubscribe: (() => void) | null = null
function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void {
switch (fact.kind) {
case 'title':
entry.lastLiveTitleSeq = seq
entry.callbacks.onTitleChange?.(
fact.normalizedTitle,
fact.rawTitle,
fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
)
return
case 'bell':
entry.callbacks.onBell?.()
return
case 'agent-working':
entry.callbacks.onAgentBecameWorking?.()
return
case 'agent-idle':
entry.callbacks.onAgentBecameIdle?.(
fact.title,
fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
)
return
case 'agent-exited':
entry.callbacks.onAgentExited?.()
return
case 'command-finished':
entry.callbacks.onCommandFinished?.(fact.exitCode)
return
case 'pr-link':
entry.callbacks.onPrLink?.(fact.link)
return
case 'command-code-working':
entry.callbacks.onCommandCodeWorking?.(fact.prompt)
return
case 'command-code-done':
entry.callbacks.onCommandCodeDone?.(fact.prompt)
return
case '2031-subscribe':
entry.callbacks.onMode2031Subscribe?.()
}
}
function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void {
if (batch.replay) {
// Why: the no-attention-replay rule — (re)attach snapshots restore title
// state only; historical bells/completions must never fire again. A replay
// older (by output sequence) than the last live title fact is stale.
if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) {
return
}
for (const fact of batch.facts) {
if (fact.kind === 'title') {
entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle)
}
}
return
}
for (const fact of batch.facts) {
applyLiveFact(entry, fact, batch.seq)
}
}
function handleSideEffectBatch(batch: TerminalSideEffectBatch): void {
const entry = consumersByPtyId.get(batch.ptyId)
if (!entry) {
return
}
applyBatchToConsumer(entry, batch)
}
function ensureSideEffectChannelSubscription(): void {
if (channelUnsubscribe !== null) {
return
}
// Why: optional-chained from globalThis so unit tests (and any non-preload
// surface) without window.api degrade to "no channel" instead of throwing.
const onSideEffect = (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect
if (typeof onSideEffect !== 'function') {
return
}
channelUnsubscribe = onSideEffect(handleSideEffectBatch)
}
export type TerminalSideEffectFactConsumerOptions = {
ptyId: string
callbacks: TerminalSideEffectFactConsumerCallbacks
/** Pull main's title-only replay snapshot on registration. Pane transports
* use this in place of deriving titles from eager-buffer byte replay;
* parked watchers skip it because the pane's runtime title slot is already
* current at park time. */
restoreTitleOnRegister?: boolean
}
/**
* Register the single fact consumer for a PTY. A new registration replaces a
* stale one for the same PTY (same semantics as the parked watcher registry):
* two consumers would double-fire bell/completion policy for the same bytes.
*/
export function registerTerminalSideEffectFactConsumer(
options: TerminalSideEffectFactConsumerOptions
): () => void {
ensureSideEffectChannelSubscription()
const entry: ConsumerEntry = {
callbacks: options.callbacks,
lastLiveTitleSeq: null
}
consumersByPtyId.set(options.ptyId, entry)
if (options.restoreTitleOnRegister) {
const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot
if (typeof getSnapshot === 'function') {
void getSnapshot(options.ptyId)
.then((batch) => {
// Why: apply only while this registration is still the live
// consumer; a slow snapshot must not fire into a replaced one.
if (batch && consumersByPtyId.get(options.ptyId) === entry) {
applyBatchToConsumer(entry, { ...batch, replay: true })
}
})
.catch(() => {})
}
}
return () => {
if (consumersByPtyId.get(options.ptyId) === entry) {
consumersByPtyId.delete(options.ptyId)
}
}
}
/** Test seam: deliver a batch as if it arrived on the channel. */
export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void {
handleSideEffectBatch(batch)
}
/** Test seam: reset module state between tests. */
export function _resetTerminalSideEffectFactConsumersForTest(): void {
consumersByPtyId.clear()
channelUnsubscribe?.()
channelUnsubscribe = null
persistedAuthorityFlagCache = undefined
}
@@ -0,0 +1,378 @@
// Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY
// title tracker in main alongside the renderer transport's byte parser. Both
// must derive IDENTICAL ordered title/status facts from the same bytes, or
// main-side consumers (tui-idle waiters, worktree ps, mobile titles) drift
// from what the renderer shows. This harness feeds identical byte fixtures
// through the renderer `createPtyOutputProcessor` and through main's
// consumption shape (OSC 9999 strip → shared title tracker) and asserts the
// event sequences match.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc'
import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects'
import { createPtyOutputProcessor } from './pty-transport'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
const ESC = '\x1b'
const BEL = '\x07'
const ST = `${ESC}\\`
type TitleFactEvent =
| { kind: 'title'; normalized: string; raw: string }
| { kind: 'became-working' }
| { kind: 'became-idle'; title: string }
| { kind: 'agent-exited' }
| { kind: 'bell' }
type TitleFactPath = {
events: TitleFactEvent[]
feed: (chunk: string) => void
}
function createRendererPath(): TitleFactPath {
const events: TitleFactEvent[] = []
const processor = createPtyOutputProcessor({
onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
onAgentExited: () => events.push({ kind: 'agent-exited' }),
onBell: () => events.push({ kind: 'bell' })
})
const callbacks = { onData: () => {} }
return {
events,
feed(chunk: string): void {
processor.processData(chunk, callbacks)
// Why: the renderer defers side effects behind a setTimeout(0) drain to
// protect xterm paint. Flush synchronously so both paths observe each
// chunk at the same fake-timer instant.
processor.flushPendingSideEffects()
}
}
}
function createMainPath(): TitleFactPath {
const events: TitleFactEvent[] = []
// Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999
// processor strips status payloads before the title tracker sees the chunk.
const processAgentStatusChunk = createAgentStatusOscProcessor()
const tracker = createTerminalTitleTracker({
onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
onAgentExited: () => events.push({ kind: 'agent-exited' }),
onBell: () => events.push({ kind: 'bell' })
})
return {
events,
feed(chunk: string): void {
tracker.handleChunk(processAgentStatusChunk(chunk).cleanData)
}
}
}
type ChunkFeed = { feed: (chunk: string) => void }
function feedBoth(paths: { renderer: ChunkFeed; main: ChunkFeed }, chunk: string): void {
paths.renderer.feed(chunk)
paths.main.feed(chunk)
}
describe('main title tracker parity with the renderer transport processor', () => {
let paths: { renderer: TitleFactPath; main: TitleFactPath }
beforeEach(() => {
vi.useFakeTimers()
paths = { renderer: createRendererPath(), main: createMainPath() }
})
afterEach(() => {
vi.useRealTimers()
})
it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => {
// One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's
// trailing idle title. A last-title reader sees only the idle title and
// never observes the working state.
const chunk =
`${ESC}]0;⠋ π - cwd${BEL}response text\r\n` +
`${ESC}]0;⠙ π - cwd${BEL}more text\r\n` +
`${ESC}]0;π - cwd${BEL}`
feedBoth(paths, chunk)
expect(paths.main.events).toEqual(paths.renderer.events)
const kinds = paths.main.events.map((event) => event.kind)
expect(kinds).toContain('became-working')
expect(kinds.indexOf('became-working')).toBeLessThan(kinds.indexOf('became-idle'))
})
it('derives identical facts from BEL- and ST-terminated titles', () => {
feedBoth(paths, `${ESC}]2;Codex working${ST}body bytes`)
feedBoth(paths, `${ESC}]0;Codex done${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' })
})
it('drops the bare cursor-agent native title in both paths', () => {
feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`)
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
const titles = paths.main.events.filter((event) => event.kind === 'title')
expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }])
})
it('clears a stale working title after the 3s timeout in both paths', () => {
feedBoth(paths, `${ESC}]0;. Claude working${BEL}`)
feedBoth(paths, 'output with no title\r\n')
vi.advanceTimersByTime(3_000)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Claude' })
})
it('keeps the stale-title timer unperturbed by pure OSC 9999 status chunks', () => {
feedBoth(paths, `${ESC}]0;Codex working${BEL}`)
feedBoth(paths, 'plain output arms the timer\r\n')
vi.advanceTimersByTime(2_000)
// Why: a chunk that is ONLY an Orca status payload strips to empty
// cleanData; neither path may restart (or newly arm) the stale probe.
feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`)
vi.advanceTimersByTime(1_000)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Codex' })
})
it('ignores a title split across chunk boundaries in both paths', () => {
feedBoth(paths, `${ESC}]0;split-ti`)
feedBoth(paths, `tle${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([])
})
it('orders a real BEL after the same chunk titles in both paths', () => {
feedBoth(paths, `${ESC}]0;⠋ Claude working${BEL}done text${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events.map((event) => event.kind)).toEqual([
'title',
'became-working',
'bell'
])
})
it('never reports an OSC-terminator BEL as a bell, even spanning chunks', () => {
feedBoth(paths, `${ESC}]0;par`)
feedBoth(paths, `tial title${BEL}`)
feedBoth(paths, `${ESC}]2;st-terminated${ST}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events.filter((event) => event.kind === 'bell')).toEqual([])
})
it('treats a BEL after a CAN-cancelled OSC as a real bell in both paths', () => {
// ECMA-48 CAN aborts the in-progress OSC; the next BEL is a real bell.
feedBoth(paths, `${ESC}]0;truncated`)
feedBoth(paths, `\x18${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([{ kind: 'bell' }])
})
it('keeps bells suppressed inside OSC 9999 status payloads in both paths', () => {
feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([])
})
})
// Why: slice 3 moves the renderer's OSC 133;D and PR-link byte parsing into
// main's tracker for local/SSH PTYs. Both must derive identical fact
// sequences from the same chunk boundaries, or flipping the kill switch
// changes which commands/links are observed.
type LifecycleFactEvent = ['command-finished', number | null] | ['pr-link', string, number]
type LifecycleFactPath = {
events: LifecycleFactEvent[]
feed: (chunk: string) => void
}
function createRendererLifecyclePath(): LifecycleFactPath {
const events: LifecycleFactEvent[] = []
// Why: mirrors pty-connection's dataCallback wiring — the transport
// processor strips OSC 9999 before the lifecycle/PR-link byte scans run.
const processAgentStatusChunk = createAgentStatusOscProcessor()
const lifecycle = createTerminalCommandLifecycle({
onCommandFinished: (exitCode) => events.push(['command-finished', exitCode])
})
const detectPRLinks = createTerminalGitHubPRLinkDetector()
return {
events,
feed(chunk: string): void {
const clean = processAgentStatusChunk(chunk).cleanData
lifecycle.handlePtyData(clean)
for (const link of detectPRLinks(clean)) {
events.push(['pr-link', link.url, link.number])
}
}
}
}
function createMainLifecyclePath(): LifecycleFactPath {
const events: LifecycleFactEvent[] = []
const processAgentStatusChunk = createAgentStatusOscProcessor()
const tracker = createTerminalTitleTracker({
onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]),
onPrLink: (link) => events.push(['pr-link', link.url, link.number])
})
return {
events,
feed(chunk: string): void {
tracker.handleChunk(processAgentStatusChunk(chunk).cleanData)
}
}
}
describe('main tracker parity with renderer 133;D and PR-link byte parsers', () => {
let paths: { renderer: LifecycleFactPath; main: LifecycleFactPath }
beforeEach(() => {
paths = { renderer: createRendererLifecyclePath(), main: createMainLifecyclePath() }
})
it('derives identical command-finished facts from split OSC 133;D chunks', () => {
feedBoth(paths, `output${ESC}]133`)
feedBoth(paths, ';D;13')
feedBoth(paths, `0${BEL}prompt $ `)
feedBoth(paths, `${ESC}]133;D;0${BEL}`)
feedBoth(paths, `${ESC}]133;D${ST}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([
['command-finished', 130],
['command-finished', 0],
['command-finished', null]
])
})
it('derives identical pr-link facts from split and repeated URLs', () => {
feedBoth(paths, 'Created https://github.com/acme/orca/pull/4')
feedBoth(paths, '2\r\nAlso https://github.com/acme/orca/pull/43 merged\r\n')
feedBoth(paths, 'again https://github.com/acme/orca/pull/42\r\n')
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([
['pr-link', 'https://github.com/acme/orca/pull/42', 42],
['pr-link', 'https://github.com/acme/orca/pull/43', 43]
])
})
it('ignores 133;D and PR URLs inside stripped OSC 9999 payloads in both paths', () => {
feedBoth(
paths,
`${ESC}]9999;{"state":"done","prompt":"https://github.com/acme/orca/pull/9"}${BEL}\r\n`
)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([])
})
})
// Why: slice 4 moves the Command Code output scrape into main for local/SSH
// PTYs. The renderer byte path observes raw transport data; main observes the
// OSC 9999-stripped cleanData. Both must derive identical working/done
// sequences from the same chunk boundaries, or flipping the kill switch
// changes Command Code status rows.
type CommandCodeFactEvent = ['working' | 'done', string]
type CommandCodeFactPath = {
events: CommandCodeFactEvent[]
feed: (chunk: string) => void
}
function createCommandCodePath(options: { stripStatusPayloads: boolean }): CommandCodeFactPath {
const events: CommandCodeFactEvent[] = []
const processAgentStatusChunk = createAgentStatusOscProcessor()
const detector = createCommandCodeOutputStatusDetector({
startupCommand: null,
onWorking: (prompt) => events.push(['working', prompt]),
onDone: (prompt) => events.push(['done', prompt])
})
return {
events,
feed(chunk: string): void {
detector.observe(
options.stripStatusPayloads ? processAgentStatusChunk(chunk).cleanData : chunk
)
}
}
}
describe('main Command Code scrape parity with the renderer byte detector', () => {
let paths: { renderer: CommandCodeFactPath; main: CommandCodeFactPath }
beforeEach(() => {
paths = {
renderer: createCommandCodePath({ stripStatusPayloads: false }),
main: createCommandCodePath({ stripStatusPayloads: true })
}
})
it('derives identical working facts after the banner arms across chunks', () => {
feedBoth(paths, '# Command')
feedBoth(paths, ' Code v0.27.3\r\n')
feedBoth(paths, '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m')
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([['working', 'Fix the spinner']])
})
it('derives identical done facts for a no-tool turn in both paths', () => {
feedBoth(paths, '# Command Code v0.27.3\r\n')
feedBoth(paths, '❯ say hi\r\n✻ Thinking...')
feedBoth(paths, '\r\n:: Hi!\r\n❯ Ask your question...')
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([
['working', 'say hi'],
['done', 'say hi']
])
})
it('recovers prompt capture from interleaved OSC 9999 payloads (main improvement)', () => {
// Deliberate divergence, not drift: the renderer's raw byte path lets an
// OSC 9999 payload leak partial text into the scrape window (its ANSI
// strip consumes only the ESC] introducer), which breaks the prompt-echo
// line match. Main feeds the OSC 9999-stripped cleanData, so the prompt
// (and therefore the done settle hint) survives an adjacent payload.
const payloadThenPrompt = [
'# Command Code v0.27.3\r\n',
`${ESC}]9999;{"state":"working","agentType":"command-code"}${BEL}`,
'❯ say hi\r\n✻ Thinking...',
'\r\n:: Hi!\r\n❯ Ask your question...'
]
for (const chunk of payloadThenPrompt) {
feedBoth(paths, chunk)
}
expect(paths.renderer.events).toEqual([['working', '']])
expect(paths.main.events).toEqual([
['working', 'say hi'],
['done', 'say hi']
])
})
it('stays silent without the Command Code banner in both paths', () => {
feedBoth(paths, '❯ Fix the spinner\r\nThinking about unrelated shell output...')
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([])
})
})
@@ -0,0 +1,249 @@
/**
* View-attribute bridge publication (terminal-query-authority.md §View-
* attribute bridge): the composed snapshot must mirror xterm ThemeService
* resolution (defaults, cursor blend, 256-entry palette), and pushes must
* happen once per actual change — not per pane, not per font tweak.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
import { getDefaultSettings } from '../../../../shared/constants'
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
import { applyTerminalAppearance } from './terminal-appearance'
import {
_resetTerminalViewAttributesPublisherForTest,
composeTerminalViewAttributes,
publishTerminalViewAttributes
} from './terminal-view-attributes-publisher'
const cursorSettings = {
terminalCursorStyle: 'block' as const,
terminalCursorBlink: true
}
beforeEach(() => {
_resetTerminalViewAttributesPublisherForTest()
vi.unstubAllGlobals()
})
describe('composeTerminalViewAttributes', () => {
it('resolves a null theme to the xterm ThemeService defaults', () => {
const attrs = composeTerminalViewAttributes(null, 'dark', cursorSettings)
expect(attrs.foreground).toEqual([0xff, 0xff, 0xff])
expect(attrs.background).toEqual([0x00, 0x00, 0x00])
expect(attrs.cursor).toEqual([0xff, 0xff, 0xff])
expect(attrs.ansi).toHaveLength(256)
// DEFAULT_ANSI_COLORS parity: named 16, color cube, greys.
expect(attrs.ansi[0]).toEqual([0x2e, 0x34, 0x36])
expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00])
expect(attrs.ansi[15]).toEqual([0xee, 0xee, 0xec])
expect(attrs.ansi[16]).toEqual([0x00, 0x00, 0x00])
expect(attrs.ansi[196]).toEqual([0xff, 0x00, 0x00])
expect(attrs.ansi[232]).toEqual([8, 8, 8])
expect(attrs.ansi[255]).toEqual([238, 238, 238])
expect(attrs.colorSchemeMode).toBe('dark')
expect(attrs.cursorStyle).toBe('block')
expect(attrs.cursorBlink).toBe(true)
})
it('parses composed theme colors including rgba() opacity forms', () => {
const attrs = composeTerminalViewAttributes(
{
// composeActiveTerminalTheme emits rgba() when terminalBackgroundOpacity
// or terminalCursorOpacity apply; the reply drops alpha like xterm's
// toColorRGB, except the cursor which blends over the background.
background: 'rgba(30, 30, 46, 0.9)',
foreground: '#d0d0d0',
cursor: 'rgba(255, 0, 0, 0.5)',
red: '#ff8800'
},
'light',
{ terminalCursorStyle: 'underline', terminalCursorBlink: false }
)
expect(attrs.background).toEqual([30, 30, 46])
expect(attrs.foreground).toEqual([0xd0, 0xd0, 0xd0])
// color.blend parity: a = round(0.5*255)/255; ch = bg + round((fg-bg)*a).
expect(attrs.cursor).toEqual([143, 15, 23])
expect(attrs.ansi[1]).toEqual([0xff, 0x88, 0x00])
expect(attrs.colorSchemeMode).toBe('light')
expect(attrs.cursorStyle).toBe('underline')
expect(attrs.cursorBlink).toBe(false)
})
it('keeps an opaque cursor un-blended and blends short-hex alpha', () => {
const attrs = composeTerminalViewAttributes(
{ background: '#000000', cursor: '#ff0000' },
'dark',
cursorSettings
)
expect(attrs.cursor).toEqual([255, 0, 0])
const blended = composeTerminalViewAttributes(
{ background: '#000000', cursor: '#f00a' },
'dark',
cursorSettings
)
// #f00a → alpha 0xaa: 0 + round(255 * (0xaa/0xff)) = 170.
expect(blended.cursor).toEqual([170, 0, 0])
})
it('overlays extendedAnsi onto the default 256 palette tail', () => {
const attrs = composeTerminalViewAttributes(
{ extendedAnsi: ['#102030'] },
'dark',
cursorSettings
)
expect(attrs.ansi[16]).toEqual([0x10, 0x20, 0x30])
// Untouched tail entries stay on the generated cube.
expect(attrs.ansi[17]).toEqual([0x00, 0x00, 0x5f])
})
it('falls back to slot defaults for named colors (hand-edited settings divergence)', () => {
// A visible pane resolves named CSS via canvas; the composer cannot, so
// hand-edited values fall back — the documented divergence boundary.
const attrs = composeTerminalViewAttributes(
{ red: 'darkred', foreground: 'hotpink' },
'dark',
cursorSettings
)
expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00])
expect(attrs.foreground).toEqual([0xff, 0xff, 0xff])
})
})
describe('publishTerminalViewAttributes dedupe', () => {
it('publishes once per snapshot change, not per call', () => {
const send = vi.fn(() => true)
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true)
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(false)
expect(send).toHaveBeenCalledTimes(1)
// A real attribute change (theme flip) publishes again.
expect(publishTerminalViewAttributes(null, 'light', cursorSettings, send)).toBe(true)
expect(send).toHaveBeenCalledTimes(2)
})
it('does not record a failed send, so the next call retries', () => {
const failingSend = vi.fn(() => false)
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, failingSend)).toBe(false)
const send = vi.fn(() => true)
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true)
})
it('skips silently when the preload bridge is unavailable (web client, tests)', () => {
// No window stub: default send must be a safe no-op.
expect(publishTerminalViewAttributes(null, 'dark', cursorSettings)).toBe(false)
})
})
describe('applyTerminalAppearance publication', () => {
function makePane(id: number): ManagedPane {
return {
id,
terminal: { options: {}, cols: 80, rows: 24 }
} as unknown as ManagedPane
}
function makeManager(panes: ManagedPane[]): PaneManager {
return {
getPanes: () => panes,
setPaneLigaturesEnabled: vi.fn(),
setPaneStyleOptions: vi.fn()
} as unknown as PaneManager
}
function stubPublishBridge(): ReturnType<typeof vi.fn> {
const publish = vi.fn<(attributes: TerminalViewAttributes) => void>()
vi.stubGlobal('window', { api: { pty: { publishTerminalViewAttributes: publish } } })
return publish
}
it('pushes the app-global snapshot once per change, not per pane or per manager', () => {
const publish = stubPublishBridge()
const settings = getDefaultSettings('/tmp')
// Two panes in one manager plus a second manager (another tab): the
// attributes are app-global, so identical applies publish exactly once.
applyTerminalAppearance(
makeManager([makePane(1), makePane(2)]),
settings,
true,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
applyTerminalAppearance(
makeManager([makePane(3)]),
settings,
true,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
expect(publish).toHaveBeenCalledTimes(1)
const attributes = publish.mock.calls[0][0] as TerminalViewAttributes
expect(attributes.ansi).toHaveLength(256)
expect(attributes.cursorStyle).toBe(settings.terminalCursorStyle)
// Attribute-neutral tweak (font size) must not re-push…
applyTerminalAppearance(
makeManager([makePane(1)]),
{ ...settings, terminalFontSize: settings.terminalFontSize + 2 },
true,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
expect(publish).toHaveBeenCalledTimes(1)
// …while a cursor-style change is a real attribute change.
applyTerminalAppearance(
makeManager([makePane(1)]),
{ ...settings, terminalCursorStyle: 'underline' },
true,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
expect(publish).toHaveBeenCalledTimes(2)
})
it('publishes the resolved color-scheme mode flip (system dark toggle)', () => {
const publish = stubPublishBridge()
const settings = { ...getDefaultSettings('/tmp'), theme: 'system' as const }
applyTerminalAppearance(
makeManager([makePane(1)]),
settings,
true,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
applyTerminalAppearance(
makeManager([makePane(1)]),
settings,
false,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
const modes = publish.mock.calls.map(
(call) => (call[0] as TerminalViewAttributes).colorSchemeMode
)
expect(modes).toEqual(['dark', 'light'])
})
})
@@ -0,0 +1,247 @@
/**
* Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute
* bridge): renderer→main `pty:terminalViewAttributes` publication. Composes
* the reply-relevant slots of the active terminal theme exactly the way
* xterm's browser ThemeService resolves an ITheme (defaults, cursor blend,
* 256-entry palette), so main's hidden-PTY responder replies byte-identically
* to a visible pane's xterm. Deduped module-globally: applyTerminalAppearance
* runs per pane manager and on every font/opacity tweak, but the attributes
* are app-global, so identical snapshots publish once.
*/
import type { ITheme } from '@xterm/xterm'
import type { GlobalSettings } from '../../../../shared/types'
import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol'
import type {
TerminalViewAttributes,
TerminalViewRgb
} from '../../../../shared/terminal-view-attributes'
type ParsedCssColor = {
rgb: TerminalViewRgb
/** 0-255, the precision xterm stores (rgba byte) — blend parity needs it. */
alpha: number
}
// ThemeService defaults for the reply-relevant slots (browser/services/
// ThemeService.ts): fg #ffffff, bg #000000, cursor #ffffff.
const DEFAULT_FOREGROUND: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff }
const DEFAULT_BACKGROUND: ParsedCssColor = { rgb: [0x00, 0x00, 0x00], alpha: 0xff }
const DEFAULT_CURSOR: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff }
// xterm's DEFAULT_ANSI_COLORS first 16 entries (browser/Types.ts).
const DEFAULT_ANSI_16: readonly string[] = [
'#2e3436',
'#cc0000',
'#4e9a06',
'#c4a000',
'#3465a4',
'#75507b',
'#06989a',
'#d3d7cf',
'#555753',
'#ef2929',
'#8ae234',
'#fce94f',
'#729fcf',
'#ad7fa8',
'#34e2e2',
'#eeeeec'
]
const THEME_ANSI_KEYS: readonly (keyof ITheme)[] = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'brightBlack',
'brightRed',
'brightGreen',
'brightYellow',
'brightBlue',
'brightMagenta',
'brightCyan',
'brightWhite'
]
function buildDefaultAnsiPalette(): TerminalViewRgb[] {
const palette = DEFAULT_ANSI_16.map((hex) => parseThemeColor(hex, DEFAULT_BACKGROUND).rgb)
// 16-231: the 6x6x6 color cube, 232-255: greys — same generator as xterm's
// DEFAULT_ANSI_COLORS IIFE so untouched extended slots reply identically.
const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
for (let i = 0; i < 216; i++) {
palette.push([v[((i / 36) % 6) | 0], v[((i / 6) % 6) | 0], v[i % 6]])
}
for (let i = 0; i < 24; i++) {
const c = 8 + i * 10
palette.push([c, c, c])
}
return palette
}
const DEFAULT_ANSI_PALETTE: readonly TerminalViewRgb[] = buildDefaultAnsiPalette()
/** Mirror of xterm's css.toColor fast paths (#rgb[a], #rrggbb[aa], rgb(),
* rgba()) — every format first-party inputs produce (builtin themes and the
* ghostty import are hex-validated; composeActiveTerminalTheme only adds the
* rgba() form this regex accepts). Known divergence boundary: the renderer's
* css.toColor also resolves named/modern CSS via a canvas litmus, so a
* hand-edited settings value like `background: 'darkslategray'` renders on
* a visible pane but falls back to the slot default in the hidden reply. */
export function parseCssColor(css: string): ParsedCssColor | null {
if (/^#[\da-f]{3,8}$/i.test(css)) {
switch (css.length) {
case 4:
return {
rgb: [
parseInt(css.slice(1, 2).repeat(2), 16),
parseInt(css.slice(2, 3).repeat(2), 16),
parseInt(css.slice(3, 4).repeat(2), 16)
],
alpha: 0xff
}
case 5:
return {
rgb: [
parseInt(css.slice(1, 2).repeat(2), 16),
parseInt(css.slice(2, 3).repeat(2), 16),
parseInt(css.slice(3, 4).repeat(2), 16)
],
alpha: parseInt(css.slice(4, 5).repeat(2), 16)
}
case 7:
return {
rgb: [
parseInt(css.slice(1, 3), 16),
parseInt(css.slice(3, 5), 16),
parseInt(css.slice(5, 7), 16)
],
alpha: 0xff
}
case 9:
return {
rgb: [
parseInt(css.slice(1, 3), 16),
parseInt(css.slice(3, 5), 16),
parseInt(css.slice(5, 7), 16)
],
alpha: parseInt(css.slice(7, 9), 16)
}
default:
return null
}
}
const rgbaMatch = css.match(
/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/
)
if (rgbaMatch) {
return {
rgb: [parseInt(rgbaMatch[1], 10), parseInt(rgbaMatch[2], 10), parseInt(rgbaMatch[3], 10)],
alpha: Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xff)
}
}
return null
}
function parseThemeColor(css: string | undefined, fallback: ParsedCssColor): ParsedCssColor {
if (css !== undefined) {
const parsed = parseCssColor(css)
if (parsed) {
return parsed
}
}
return fallback
}
// Mirror of xterm's color.blend: ThemeService blends the cursor color's
// alpha over the background at theme-set time (terminalCursorOpacity), and
// the OSC 12 reply reports the blended value.
function blendOverBackground(background: TerminalViewRgb, color: ParsedCssColor): TerminalViewRgb {
if (color.alpha === 0xff) {
return color.rgb
}
const a = color.alpha / 0xff
return [
background[0] + Math.round((color.rgb[0] - background[0]) * a),
background[1] + Math.round((color.rgb[1] - background[1]) * a),
background[2] + Math.round((color.rgb[2] - background[2]) * a)
]
}
export function composeTerminalViewAttributes(
theme: ITheme | null,
mode: TerminalColorSchemeMode,
settings: Pick<GlobalSettings, 'terminalCursorStyle' | 'terminalCursorBlink'>
): TerminalViewAttributes {
const foreground = parseThemeColor(theme?.foreground, DEFAULT_FOREGROUND)
const background = parseThemeColor(theme?.background, DEFAULT_BACKGROUND)
const cursor = parseThemeColor(theme?.cursor, DEFAULT_CURSOR)
const ansi: TerminalViewRgb[] = THEME_ANSI_KEYS.map((key, i) => {
const value = theme?.[key]
return parseThemeColor(typeof value === 'string' ? value : undefined, {
rgb: DEFAULT_ANSI_PALETTE[i],
alpha: 0xff
}).rgb
})
for (let i = 16; i < DEFAULT_ANSI_PALETTE.length; i++) {
const extended = theme?.extendedAnsi?.[i - 16]
ansi.push(
parseThemeColor(extended, {
rgb: DEFAULT_ANSI_PALETTE[i],
alpha: 0xff
}).rgb
)
}
return {
foreground: foreground.rgb,
background: background.rgb,
cursor: blendOverBackground(background.rgb, cursor),
ansi,
colorSchemeMode: mode,
// Same resolution as the per-pane option writes in applyTerminalAppearance.
cursorStyle: settings.terminalCursorStyle ?? 'block',
cursorBlink: settings.terminalCursorBlink === true
}
}
let lastPublishedSnapshot: string | null = null
function sendViaPreload(attributes: TerminalViewAttributes): boolean {
// Guarded: unit tests and the web client run without the preload bridge
// (remote-runtime PTYs are never hidden-gate markable anyway).
if (typeof window === 'undefined' || !window.api?.pty?.publishTerminalViewAttributes) {
return false
}
window.api.pty.publishTerminalViewAttributes(attributes)
return true
}
/** Publishes the composed app-global attributes, once per actual change:
* repeat calls from per-pane appearance applies (and attribute-neutral
* tweaks like font size) are deduped against the last published snapshot. */
export function publishTerminalViewAttributes(
theme: ITheme | null,
mode: TerminalColorSchemeMode,
settings: Pick<GlobalSettings, 'terminalCursorStyle' | 'terminalCursorBlink'>,
send: (attributes: TerminalViewAttributes) => boolean = sendViaPreload
): boolean {
const attributes = composeTerminalViewAttributes(theme, mode, settings)
const serialized = JSON.stringify(attributes)
if (serialized === lastPublishedSnapshot) {
return false
}
if (!send(attributes)) {
// Not recorded: a later call with a working bridge must still publish.
return false
}
lastPublishedSnapshot = serialized
return true
}
/** Test seam: reset the dedupe state between tests. */
export function _resetTerminalViewAttributesPublisherForTest(): void {
lastPublishedSnapshot = null
}
@@ -95,6 +95,7 @@ import {
syncTerminalScrollIntentSoon
} from '@/lib/pane-manager/terminal-scroll-intent'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers'
import { e2eConfig } from '@/lib/e2e-config'
import {
PRIMARY_SELECTION_MAX_LENGTH,
@@ -745,6 +746,13 @@ export function useTerminalPaneLifecycle({
setCacheTimerStartedAt,
syncPanePtyLayoutBinding,
clearExitedPanePtyLayoutBinding,
// Why: a DECSET 2031 subscribe answered from main's fact channel must
// land in the same registries the xterm CSI handler writes — otherwise
// theme flips never push CSI 997 and the TUI keeps a stale theme.
recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => {
paneMode2031Ref.current.set(paneId, true)
paneLastThemeModeRef.current.set(paneId, repliedMode)
},
restoredPtyIdByLeafId: initialLayoutRef.current.ptyIdsByLeafId ?? {}
}
@@ -772,7 +780,19 @@ export function useTerminalPaneLifecycle({
const mode2031Disposables = installMode2031Handlers({
paneId: pane.id,
parser: pane.terminal.parser,
onSubscribe: () => pushMode2031ForPane(pane.id),
onSubscribe: () => {
// Why: for hidden-delivery-gate-managed PTYs main's
// '2031-subscribe' fact is the sole responder — bytes reaching
// xterm live (foreground, sidecar interest) must not produce a
// second reply. The CSI handler still records the subscription.
const binding = panePtyBindings.get(pane.id) as
| (IDisposable & { isHiddenDeliveryGateManagedPty?: () => boolean })
| undefined
if (binding?.isHiddenDeliveryGateManagedPty?.()) {
return
}
pushMode2031ForPane(pane.id)
},
isReplaying: () => isPaneReplaying(replayingPanesRef, pane.id),
paneMode2031: paneMode2031Ref.current,
paneLastThemeMode: paneLastThemeModeRef.current
@@ -1637,6 +1657,19 @@ export function useTerminalPaneLifecycle({
disposable.dispose()
}
imeNativeTextForwarderDisposables.clear()
// Why: hidden-view parking starts pane-less byte watchers right after
// this unmount; record pane identities before transports detach so the
// watchers write the same runtime-title slots the live panes used.
captureParkedTerminalPaneCandidates(
tabId,
worktreeId,
manager.getPanes().map((capturedPane) => ({
ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null,
paneId: capturedPane.id,
leafId: capturedPane.leafId,
drivesTabTitle: manager.getActivePane()?.id === capturedPane.id
}))
)
for (const transport of paneTransports.values()) {
const ptyId = transport.getPtyId()
if (
@@ -0,0 +1,243 @@
/**
* Per-tab hidden-view parking for TerminalPaneOverlayLayer.
*
* Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck
* timers, parked-set selection) and the parked byte-watcher reconciliation so
* the overlay layer only consumes the final parked tab set when deciding to
* render a slot as null. See docs/reference/terminal-hidden-view-parking.md.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { TerminalTab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import {
findActivityTerminalPortal,
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
import {
getTerminalTabColdParkRecheckDelayMs,
selectColdParkedTerminalTabs,
type TerminalTabColdParkCandidate
} from './terminal-hidden-view-parking'
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
import {
canWatcherCoverParkedTerminalTab,
disposeParkedTerminalWatchersForWorktree,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
type TerminalOverlayTabAssignment = {
groupId: string
isActiveInGroup: boolean
}
function haveSameTerminalTabIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const id of left) {
if (!right.has(id)) {
return false
}
}
return true
}
export function useTerminalTabColdParking(args: {
worktreeId: string
terminalTabs: readonly TerminalTab[]
assignments: ReadonlyMap<string, TerminalOverlayTabAssignment>
isWorktreeActive: boolean
/** Worktree-level park verdict from Terminal.tsx. */
coldParkTerminalPanes: boolean
/** Hidden-measuring startup probe from Terminal.tsx — the panes must stay
* mounted for their first xterm fit, mirroring the worktree-level guard. */
shouldMeasureHiddenWorktree: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
}): ReadonlySet<string> {
const {
worktreeId,
terminalTabs,
assignments,
isWorktreeActive,
coldParkTerminalPanes,
shouldMeasureHiddenWorktree,
activityTerminalPortals
} = args
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore(
(state) => state.settings?.terminalHiddenViewParking !== false
)
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0)
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState<ReadonlySet<string>>(
() => new Set()
)
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
return () => {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
}, [])
// Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set
// selection, and one recheck timer per still-pending deadline so React
// re-renders exactly when the hysteresis elapses instead of polling.
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
const nowMs = Date.now()
const overrides = getTerminalParkingPolicyOverrides()
const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id))
const portalTabIds = new Set(
activityTerminalPortals
.filter((portal) => portal.worktreeId === worktreeId)
.map((portal) => portal.tabId)
)
for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) {
if (!currentTerminalTabIds.has(tabId)) {
terminalTabHiddenSinceRef.current.delete(tabId)
}
}
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
// Why: hidden-measuring counts as visibility — the startup probe needs
// mounted panes, so the hidden clock must not run during it.
if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) {
terminalTabHiddenSinceRef.current.delete(terminalTab.id)
} else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) {
terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs)
}
return {
id: terminalTab.id,
ptyId: terminalTab.ptyId,
pendingActivationSpawn: terminalTab.pendingActivationSpawn,
isVisible,
hasActivityTerminalPortal,
hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null
}
})
const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({
worktreeId,
terminalTabs: candidates,
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
...overrides
})
// Why: a tab the byte watchers cannot cover (no capture, no layout
// snapshot, legacy leaf ids) must never park — it would go silent for
// bells/titles/completions, the failure that sank the first attempt.
for (const terminalTab of terminalTabs) {
if (
nextColdParkedTerminalTabIds.has(terminalTab.id) &&
!canWatcherCoverParkedTerminalTab(worktreeId, terminalTab)
) {
nextColdParkedTerminalTabIds.delete(terminalTab.id)
}
}
setColdParkedTerminalTabIds((current) =>
haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds)
? current
: nextColdParkedTerminalTabIds
)
for (const candidate of candidates) {
if (
candidate.isVisible ||
candidate.hasActivityTerminalPortal ||
nextColdParkedTerminalTabIds.has(candidate.id)
) {
continue
}
const delayMs = getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: terminalParkingEnabled,
hiddenSinceMs: candidate.hiddenSinceMs,
nowMs,
...overrides
})
if (delayMs !== null && delayMs > 0) {
const tabId = candidate.id
const timer = window.setTimeout(() => {
timers.delete(tabId)
setTerminalTabParkingRevision((revision) => revision + 1)
}, delayMs)
timers.set(tabId, timer)
}
}
}, [
activityTerminalPortals,
assignments,
isWorktreeActive,
pendingStartupByTabId,
shouldMeasureHiddenWorktree,
terminalParkingEnabled,
terminalTabParkingRevision,
terminalTabs,
worktreeId
])
// Why: the rendered park verdict — worktree-level park (prop from
// Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and
// the watcher-sync effect must share this exact set so watcher lifecycle
// tracks the committed unmounts.
const parkedTerminalTabIds = useMemo(() => {
const parked = new Set<string>()
for (const terminalTab of terminalTabs) {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal =
findActivityTerminalPortal(activityTerminalPortals, {
worktreeId,
tabId: terminalTab.id
}) !== null
if (
(coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) &&
!hasActivityTerminalPortal &&
// Why: the hidden-measuring startup probe needs mounted panes; gate
// here too so the reveal lands in the same render that starts it.
!shouldMeasureHiddenWorktree
) {
parked.add(terminalTab.id)
}
}
return parked
}, [
activityTerminalPortals,
assignments,
coldParkTerminalPanes,
coldParkedTerminalTabIds,
isWorktreeActive,
shouldMeasureHiddenWorktree,
terminalTabs,
worktreeId
])
// Why: runs in the same effect flush as the commit that parked/revealed the
// panes — watcher disposal therefore lands before any PTY data IPC can
// reach a freshly remounted pane, and watcher start lands after the parked
// pane's unmount capture.
useEffect(() => {
syncParkedTerminalTabWatchers({
worktreeId,
tabs: terminalTabs,
parkedTabIds: parkedTerminalTabIds
})
}, [parkedTerminalTabIds, terminalTabs, worktreeId])
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
return parkedTerminalTabIds
}
+4
View File
@@ -67,6 +67,10 @@ declare global {
interface Window {
__paneManagers?: Map<string, PaneManager>
__onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps
__terminalParkingDebug?: {
parkDelayMs: number
parkedTabIds: () => string[]
}
}
}
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockSubscribeToPtyData = vi.fn()
const mockSubscribeToPtyExit = vi.fn()
const mockSubscribeTerminal = vi.fn()
const mockCallRuntimeRpc = vi.fn()
const state = {
settings: {
activeRuntimeEnvironmentId: null as string | null,
terminalMainSideEffectAuthority: undefined as boolean | undefined
},
setAgentStatus: vi.fn()
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => state
}
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
subscribeToPtyExit: mockSubscribeToPtyExit
}))
vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({
subscribeToPtyData: mockSubscribeToPtyData
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: mockCallRuntimeRpc,
getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' }))
}))
vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({
getRemoteRuntimeTerminalMultiplexer: () => ({ subscribeTerminal: mockSubscribeTerminal })
}))
const DONE_STATUS_OSC = '\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07'
describe('observeExistingAutomationSession', () => {
beforeEach(() => {
vi.clearAllMocks()
state.settings = {
activeRuntimeEnvironmentId: null,
terminalMainSideEffectAuthority: undefined
}
mockSubscribeToPtyData.mockReturnValue(vi.fn())
mockSubscribeToPtyExit.mockReturnValue(vi.fn())
mockCallRuntimeRpc.mockReturnValue(new Promise(() => {}))
mockSubscribeTerminal.mockResolvedValue({ close: vi.fn() })
})
it('skips the duplicate OSC store write for local PTYs under main authority', async () => {
// Why: main already parses OSC 9999 for local/SSH PTYs and routes it to
// the store via agentStatus:set; writing here too would race that path.
const onAgentStatus = vi.fn()
const { observeExistingAutomationSession } = await import('./automation-session-observer')
await observeExistingAutomationSession({
ptyId: 'pty-local-1',
paneKey: 'tab-1:leaf-1',
runId: 'run-1',
onData: vi.fn(),
onAgentStatus,
onExit: vi.fn()
})
const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
handleData(DONE_STATUS_OSC)
expect(state.setAgentStatus).not.toHaveBeenCalled()
expect(onAgentStatus).toHaveBeenCalledWith(
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' })
)
})
it('keeps the legacy OSC store write when the kill switch is off', async () => {
state.settings.terminalMainSideEffectAuthority = false
const onAgentStatus = vi.fn()
const { observeExistingAutomationSession } = await import('./automation-session-observer')
await observeExistingAutomationSession({
ptyId: 'pty-local-1',
paneKey: 'tab-1:leaf-1',
runId: 'run-1',
onData: vi.fn(),
onAgentStatus,
onExit: vi.fn()
})
const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
handleData(DONE_STATUS_OSC)
expect(state.setAgentStatus).toHaveBeenCalledWith(
'tab-1:leaf-1',
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }),
undefined
)
expect(onAgentStatus).toHaveBeenCalledTimes(1)
})
it('keeps the OSC store write for remote-runtime PTYs (bytes never transit local main)', async () => {
const onAgentStatus = vi.fn()
const { observeExistingAutomationSession } = await import('./automation-session-observer')
await observeExistingAutomationSession({
ptyId: 'remote:env-1@@terminal-9',
paneKey: 'tab-1:leaf-1',
runId: 'run-1',
onData: vi.fn(),
onAgentStatus,
onExit: vi.fn()
})
expect(mockSubscribeTerminal).toHaveBeenCalledTimes(1)
const callbacks = mockSubscribeTerminal.mock.calls[0]?.[0]?.callbacks as {
onData: (data: string) => void
}
callbacks.onData(DONE_STATUS_OSC)
expect(state.setAgentStatus).toHaveBeenCalledWith(
'tab-1:leaf-1',
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }),
undefined
)
expect(onAgentStatus).toHaveBeenCalledTimes(1)
})
})
@@ -10,6 +10,7 @@ import {
import { useAppStore } from '@/store'
import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
export async function observeExistingAutomationSession(args: {
ptyId: string
@@ -20,12 +21,25 @@ export async function observeExistingAutomationSession(args: {
onExit: (code: number) => void
}): Promise<() => void> {
const { ptyId, paneKey, runId, onData, onExit } = args
// Why: for local/SSH PTYs main already parses OSC 9999 and routes it
// through the hook server (agentStatus:set → store); writing here too
// would race/duplicate that path. Remote-runtime bytes never transit local
// main, and the kill switch restores the legacy write. The onAgentStatus
// callback always fires — automation completion tracking stays here.
const mainOwnsAgentStatusWrites =
!isRemoteRuntimePtyId(ptyId) &&
isMainTerminalSideEffectAuthorityForPty({
settings: useAppStore.getState().settings,
runtimeEnvironmentId: null
})
const processAgentStatus = createAgentStatusOscProcessor()
const handleData = (data: string): void => {
onData(data)
const processed = processAgentStatus(data)
for (const payload of processed.payloads) {
useAppStore.getState().setAgentStatus(paneKey, payload, undefined)
if (!mainOwnsAgentStatusWrites) {
useAppStore.getState().setAgentStatus(paneKey, payload, undefined)
}
args.onAgentStatus(payload)
}
}
+8 -97
View File
@@ -1,11 +1,13 @@
// Why: the parsing core moved to shared so main's terminal side-effect
// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3).
// Re-exported here so renderer consumers keep their '@/lib' import path.
// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link-
// picker input policy, not parsing.
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '../../../shared/github-links'
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
export type RepoSlug = {
owner: string
repo: string
}
export * from '../../../shared/github-links'
export type GitHubLinkQuery = {
query: string
@@ -13,97 +15,6 @@ export type GitHubLinkQuery = {
tooLarge?: boolean
}
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
if (!slug?.owner || !slug.repo) {
return null
}
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
}
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
}
function parseGitHubItemNumber(value: string): number | null {
const parsed = Number.parseInt(value, 10)
return parsed > 0 ? parsed : null
}
/**
* Parses a GitHub issue/PR reference from plain input.
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
*/
export function parseGitHubIssueOrPRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) {
return parseGitHubItemNumber(numeric)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return parseGitHubItemNumber(match[4])
}
/**
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
*/
export function parseGitHubIssueOrPRLink(input: string): {
slug: RepoSlug
number: number
type: 'issue' | 'pr'
} | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
const number = parseGitHubItemNumber(match[4])
if (number === null) {
return null
}
return {
slug: { owner: match[1], repo: match[2] },
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
number
}
}
/**
* Normalizes link-picker input so both raw issue/PR numbers and full GitHub
* URLs resolve to a usable query + direct-number lookup.
@@ -37,7 +37,11 @@ function expectStablePaneSpawn(): string {
const state = {
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null },
settings: {
agentCmdOverrides: {},
activeRuntimeEnvironmentId: null as string | null,
terminalMainSideEffectAuthority: undefined as boolean | undefined
},
projects: [
{
id: 'repo-1',
@@ -108,7 +112,11 @@ describe('launchAgentBackgroundSession', () => {
)
state.activeRepoId = 'repo-1'
state.activeWorktreeId = 'wt-1'
state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null }
state.settings = {
agentCmdOverrides: {},
activeRuntimeEnvironmentId: null,
terminalMainSideEffectAuthority: undefined
}
state.projects = [
{
id: 'repo-1',
@@ -306,7 +314,10 @@ describe('launchAgentBackgroundSession', () => {
expect(mockSpawn).toHaveBeenCalled()
})
it('parses agent status from hidden PTY output', async () => {
it('parses agent status from hidden PTY output when the kill switch is off', async () => {
// Why: with main side-effect authority disabled, this sidecar is the only
// OSC 9999 → store path for hidden local sessions.
state.settings.terminalMainSideEffectAuthority = false
const onAgentStatus = vi.fn()
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
@@ -334,6 +345,29 @@ describe('launchAgentBackgroundSession', () => {
)
})
it('skips the duplicate OSC store write under main side-effect authority', async () => {
// Why: main already routes OSC 9999 through the hook server to the store
// (agentStatus:set); a second write here would race the authoritative
// path. The automation onAgentStatus callback must still fire.
const onAgentStatus = vi.fn()
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await launchAgentBackgroundSession({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'run the automation',
onAgentStatus
})
const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07')
expect(state.setAgentStatus).not.toHaveBeenCalled()
expect(onAgentStatus).toHaveBeenCalledWith(
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' })
)
})
it('seeds a working status for Command Code prompt launches', async () => {
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
@@ -497,7 +531,8 @@ describe('launchAgentBackgroundSession', () => {
state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
state.settings = {
agentCmdOverrides: { codex: "codex --prefill 'draft from override'" },
activeRuntimeEnvironmentId: null
activeRuntimeEnvironmentId: null,
terminalMainSideEffectAuthority: undefined
}
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
@@ -558,7 +593,11 @@ describe('launchAgentBackgroundSession', () => {
})
it('creates background sessions on the active runtime environment', async () => {
state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' }
state.settings = {
agentCmdOverrides: {},
activeRuntimeEnvironmentId: 'env-1',
terminalMainSideEffectAuthority: undefined
}
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
const result = await launchAgentBackgroundSession({
@@ -37,6 +37,7 @@ import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc'
import type { RuntimeTerminalCreate } from '../../../shared/runtime-types'
import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery'
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
export async function launchAgentBackgroundSession(
args: LaunchAgentBackgroundSessionArgs
@@ -257,6 +258,16 @@ export async function launchAgentBackgroundSession(
useAppStore.getState().clearAgentLaunchConfig(paneKey)
onExit?.(ptyId, code)
}
// Why: for local/SSH PTYs main already parses OSC 9999 and routes it through
// the hook server (agentStatus:set → store), so a second store write here
// would race/duplicate the authoritative path. Remote-runtime bytes never
// transit local main; the kill switch restores the legacy write. The
// onAgentStatus callback always fires — automation completion tracking is
// this sidecar's own responsibility, not a store side effect.
const mainOwnsAgentStatusWrites = isMainTerminalSideEffectAuthorityForPty({
settings: store.settings,
runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null
})
const processAgentStatus = createAgentStatusOscProcessor()
const handleData = (data: string): void => {
data = sshStartupDelivery.handleData(data)
@@ -264,9 +275,11 @@ export async function launchAgentBackgroundSession(
sshStartupDelivery.schedule(ptyId)
const processed = processAgentStatus(data)
for (const payload of processed.payloads) {
useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, {
launchToken
})
if (!mainOwnsAgentStatusWrites) {
useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, {
launchToken
})
}
onAgentStatus?.(payload)
}
}
@@ -807,6 +807,31 @@ describe('pane terminal output scheduler', () => {
expect(terminals[2].write).toHaveBeenCalledWith('pane-2')
})
it('drains active foreground backlog before older background terminal backlog', async () => {
vi.useFakeTimers()
const { writeTerminalOutput } = await loadScheduler()
const backgroundA = createTerminal()
const backgroundB = createTerminal()
const active = createTerminal()
writeTerminalOutput(backgroundA, 'background-a', { foreground: false })
writeTerminalOutput(backgroundB, 'background-b', { foreground: false })
writeTerminalOutput(active, 'active', {
foreground: true,
latencySensitive: false
})
vi.advanceTimersByTime(0)
expect(active.write).toHaveBeenCalledWith('active', expect.any(Function))
expect(active.write.mock.invocationCallOrder[0]).toBeLessThan(
backgroundA.write.mock.invocationCallOrder[0]
)
expect(active.write.mock.invocationCallOrder[0]).toBeLessThan(
backgroundB.write.mock.invocationCallOrder[0]
)
})
it('rotates terminals with remaining backlog behind untouched queued terminals', async () => {
vi.useFakeTimers()
const { writeTerminalOutput } = await loadScheduler()
@@ -735,6 +735,25 @@ function writeForegroundTerminalChunkWithIntent(
}
function takeNextDrainableEntry(): QueueEntry | null {
let largeBacklogEntry: QueueEntry | null = null
for (const entry of queuedByTerminal.values()) {
if (!isEntryDrainable(entry)) {
continue
}
// Why: active/foreground output should be chosen first, not just widen the
// drain budget while older background terminals keep their insertion order.
if (entry.highPriority) {
queuedByTerminal.delete(entry.terminal)
return entry
}
if (!largeBacklogEntry && entry.queuedChars > LARGE_BACKLOG_CHARS) {
largeBacklogEntry = entry
}
}
if (largeBacklogEntry) {
queuedByTerminal.delete(largeBacklogEntry.terminal)
return largeBacklogEntry
}
for (const entry of queuedByTerminal.values()) {
if (!isEntryDrainable(entry)) {
continue
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
import {
buildWindowsPtyCompatibilityOptions,
isLocalNativeWindowsConpty,
isLocalNativeWindowsPty
isLocalNativeWindowsPty,
resolveWindowsShellOverride
} from './windows-pty-compatibility'
function writeTerminal(terminal: Terminal, data: string): Promise<void> {
@@ -164,6 +165,39 @@ describe('buildWindowsPtyCompatibilityOptions', () => {
).toEqual({})
})
it('classifies a global-WSL default shell as non-native, matching main', () => {
// Why: main folds the global terminalWindowsShell into its spawn
// classification (isNativeWindowsLocalPtySpawn). Without the fold the
// renderer would call a tab with no override native-ConPTY while main
// never marks it.
const windowsUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
expect(
isLocalNativeWindowsPty({
userAgent: windowsUserAgent,
connectionId: null,
cwd: 'C:\\repo',
shellOverride: resolveWindowsShellOverride(undefined, 'wsl.exe')
})
).toBe(false)
// A tab-level override beats the global setting, both directions.
expect(
isLocalNativeWindowsPty({
userAgent: windowsUserAgent,
connectionId: null,
cwd: 'C:\\repo',
shellOverride: resolveWindowsShellOverride('powershell.exe', 'wsl.exe')
})
).toBe(true)
expect(
isLocalNativeWindowsPty({
userAgent: windowsUserAgent,
connectionId: null,
cwd: 'C:\\repo',
shellOverride: resolveWindowsShellOverride('wsl.exe', 'powershell.exe')
})
).toBe(false)
})
it('exposes the same local native Windows predicate for related renderer workarounds', () => {
expect(
isLocalNativeWindowsPty({
@@ -62,6 +62,17 @@ export function buildWindowsPtyCompatibilityOptions(
}
}
/** Mirror of main's effectiveShellOverride fold (pty.ts spawn handlers): a
* tab-level shell override wins, else the global Windows shell setting
* applies — so renderer and main classify a global-WSL default identically
* (the main-side twin is isNativeWindowsLocalPtySpawn). */
export function resolveWindowsShellOverride(
tabShellOverride: string | null | undefined,
globalWindowsShell: string | null | undefined
): string | undefined {
return tabShellOverride ?? globalWindowsShell ?? undefined
}
/**
* Raw client-side heuristic for a native-Windows ConPTY pane (Windows UA, no SSH
* connection, non-WSL cwd/shell). Necessary but not sufficient: it cannot tell a
@@ -9,6 +9,7 @@ import {
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
import { e2eConfig } from '@/lib/e2e-config'
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
type RuntimeEnvironmentSubscriptionHandle = {
@@ -70,10 +71,12 @@ type RemoteRuntimeMultiplexedTerminalState = {
streamId: number
terminal: string
callbacks: RemoteRuntimeMultiplexedTerminalCallbacks
acknowledgeOutput: boolean
heldAckBytes: number
snapshotChunks: Uint8Array<ArrayBufferLike>[]
snapshotBytes: number
snapshotOverflowed: boolean
snapshotTarget: 'initial' | 'request'
snapshotTarget: 'initial' | 'request' | 'recovery'
snapshotInfo: RemoteRuntimeSnapshotInfo | null
initialSnapshotReceived: boolean
pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null
@@ -109,6 +112,73 @@ const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000
const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE =
'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.'
type E2eRemoteTerminalMultiplexAckGateSnapshot = {
heldTerminalCount: number
heldStreamCount: number
heldAckChars: number
releasedAckChars: number
}
type E2eRemoteTerminalMultiplexAckGateApi = {
hold: (terminals: string[]) => void
release: () => void
snapshot: () => E2eRemoteTerminalMultiplexAckGateSnapshot
}
type E2eRemoteTerminalMultiplexAckGateWindow = Window & {
__remoteTerminalMultiplexAckGate?: E2eRemoteTerminalMultiplexAckGateApi
}
const e2eHeldRemoteAckTerminals = new Set<string>()
let e2eReleasedRemoteAckChars = 0
function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean {
return e2eConfig.exposeStore && e2eHeldRemoteAckTerminals.has(terminal)
}
function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot {
let heldStreamCount = 0
let heldAckChars = 0
for (const multiplexer of multiplexers.values()) {
for (const stream of multiplexer.getStreamsForE2e()) {
if (stream.heldAckBytes > 0) {
heldStreamCount += 1
heldAckChars += stream.heldAckBytes
}
}
}
return {
heldTerminalCount: e2eHeldRemoteAckTerminals.size,
heldStreamCount,
heldAckChars,
releasedAckChars: e2eReleasedRemoteAckChars
}
}
function releaseE2eRemoteTerminalAcks(): void {
for (const multiplexer of multiplexers.values()) {
e2eReleasedRemoteAckChars += multiplexer.releaseHeldAcksForE2e()
}
e2eHeldRemoteAckTerminals.clear()
}
function exposeE2eRemoteTerminalMultiplexAckGate(): void {
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
return
}
const target = window as E2eRemoteTerminalMultiplexAckGateWindow
target.__remoteTerminalMultiplexAckGate ??= {
hold: (terminals) => {
releaseE2eRemoteTerminalAcks()
for (const terminal of terminals) {
e2eHeldRemoteAckTerminals.add(terminal)
}
},
release: releaseE2eRemoteTerminalAcks,
snapshot: getE2eRemoteAckSnapshot
}
}
class RemoteRuntimeTerminalMultiplexer {
private readonly streams = new Map<number, RemoteRuntimeMultiplexedTerminalState>()
private subscription: RuntimeEnvironmentSubscriptionHandle | null = null
@@ -138,6 +208,8 @@ class RemoteRuntimeTerminalMultiplexer {
streamId,
terminal: args.terminal,
callbacks: args.callbacks,
acknowledgeOutput: args.client.type === 'desktop',
heldAckBytes: 0,
snapshotChunks: [],
snapshotBytes: 0,
snapshotOverflowed: false,
@@ -181,7 +253,8 @@ class RemoteRuntimeTerminalMultiplexer {
streamId,
terminal: args.terminal,
client: args.client,
viewport: args.viewport
viewport: args.viewport,
capabilities: args.client.type === 'desktop' ? { ackOutput: 1 } : undefined
})
)
if (!sent) {
@@ -329,10 +402,20 @@ class RemoteRuntimeTerminalMultiplexer {
}
if (frame.opcode === TerminalStreamOpcode.Output) {
const data = decodeTerminalStreamText(frame.payload)
stream.callbacks.onData(data, {
seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined,
rawLength: data.length
})
try {
stream.callbacks.onData(data, {
seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined,
rawLength: data.length
})
} finally {
if (stream.acknowledgeOutput) {
if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) {
stream.heldAckBytes += frame.payload.byteLength
} else {
this.acknowledgeOutput(stream, frame.payload.byteLength)
}
}
}
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
@@ -343,7 +426,9 @@ class RemoteRuntimeTerminalMultiplexer {
typeof requestId === 'number' ||
(stream.initialSnapshotReceived && stream.pendingSnapshotRequest)
? 'request'
: 'initial'
: stream.initialSnapshotReceived
? 'recovery'
: 'initial'
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
@@ -386,6 +471,12 @@ class RemoteRuntimeTerminalMultiplexer {
clearPendingSnapshotRequest(stream)
} else if (target === 'initial') {
stream.callbacks.onSnapshot(data ?? '')
} else if (target === 'recovery') {
// Why: a server-pushed recovery snapshot replaces terminal state
// mid-session; clear the screen and scrollback before applying it.
// An empty snapshot is still applied so stale dropped output does
// not linger on a terminal the model says is blank.
stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`)
}
} else if (matchesPendingRequest) {
pendingRequest.resolve(null)
@@ -458,6 +549,33 @@ class RemoteRuntimeTerminalMultiplexer {
return id
}
private acknowledgeOutput(stream: RemoteRuntimeMultiplexedTerminalState, bytes: number): boolean {
return this.sendFrame(
stream.streamId,
TerminalStreamOpcode.Ack,
encodeTerminalStreamJson({ bytes })
)
}
getStreamsForE2e(): Iterable<RemoteRuntimeMultiplexedTerminalState> {
return this.streams.values()
}
releaseHeldAcksForE2e(): number {
let released = 0
for (const stream of this.streams.values()) {
if (stream.heldAckBytes <= 0) {
continue
}
const bytes = stream.heldAckBytes
stream.heldAckBytes = 0
if (this.acknowledgeOutput(stream, bytes)) {
released += bytes
}
}
return released
}
private sendFrame(
streamId: number,
opcode: TerminalStreamOpcode,
@@ -540,6 +658,7 @@ function releaseRemoteRuntimeTerminalMultiplexer(
export function getRemoteRuntimeTerminalMultiplexer(
environmentId: string
): RemoteRuntimeTerminalMultiplexer {
exposeE2eRemoteTerminalMultiplexAckGate()
let multiplexer = multiplexers.get(environmentId)
if (!multiplexer) {
multiplexer = new RemoteRuntimeTerminalMultiplexer(
@@ -557,6 +676,8 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number {
export function resetRemoteRuntimeTerminalMultiplexersForTests(): void {
multiplexers.clear()
e2eHeldRemoteAckTerminals.clear()
e2eReleasedRemoteAckChars = 0
}
function concatBytes(chunks: Uint8Array<ArrayBufferLike>[]): Uint8Array<ArrayBufferLike> {
@@ -4,6 +4,7 @@ import {
decodeTerminalStreamFrame,
decodeTerminalStreamJson,
encodeTerminalStreamFrame,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
import {
@@ -98,8 +99,12 @@ describe('remote runtime terminal data subscriptions', () => {
const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0])
expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe)
const subscribePayload =
subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload)
subscribeFrame &&
decodeTerminalStreamJson<{ streamId: number; capabilities?: { ackOutput?: 1 } }>(
subscribeFrame.payload
)
expect(subscribePayload?.streamId).toEqual(expect.any(Number))
expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1 })
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
@@ -111,6 +116,12 @@ describe('remote runtime terminal data subscriptions', () => {
)
expect(watcher).toHaveBeenCalledWith('live')
const ackFrame = sendBinary.mock.calls
.slice(1)
.map((call) => decodeTerminalStreamFrame(call[0]))
.find((frame) => frame?.opcode === TerminalStreamOpcode.Ack)
expect(ackFrame?.streamId).toBe(subscribePayload!.streamId)
expect(ackFrame && decodeTerminalStreamJson(ackFrame.payload)).toEqual({ bytes: 4 })
expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1)
dispose()
expect(unsubscribe).toHaveBeenCalled()
@@ -189,3 +200,222 @@ describe('remote runtime terminal data subscriptions', () => {
expect(unsubscribe).toHaveBeenCalledOnce()
})
})
describe('remote runtime terminal multiplex ACK gate', () => {
const runtimeSubscribe = vi.fn()
const sendBinary = vi.fn()
const unsubscribe = vi.fn()
let callbacks: {
onResponse: (response: unknown) => void
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
onError?: (error: { message: string }) => void
onClose?: () => void
} | null = null
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
callbacks = null
runtimeSubscribe.mockImplementation(async (_args: unknown, nextCallbacks: typeof callbacks) => {
callbacks = nextCallbacks
queueMicrotask(() =>
callbacks?.onResponse({
ok: true,
result: { type: 'ready' }
})
)
return { unsubscribe, sendBinary }
})
vi.stubGlobal('window', {
api: {
e2e: {
getConfig: () => ({ exposeStore: true })
},
runtimeEnvironments: {
subscribe: runtimeSubscribe
}
}
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.resetModules()
})
it('holds and releases ACKs for selected remote terminal streams only', async () => {
const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } =
await import('./remote-runtime-terminal-multiplexer')
resetRemoteRuntimeTerminalMultiplexersForTests()
const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-ack-gate')
const heldTerminal = await multiplexer.subscribeTerminal({
terminal: 'terminal-held',
client: { id: 'desktop-held', type: 'desktop' },
callbacks: {
onData: vi.fn(),
onSnapshot: vi.fn()
}
})
const liveTerminal = await multiplexer.subscribeTerminal({
terminal: 'terminal-live',
client: { id: 'desktop-live', type: 'desktop' },
callbacks: {
onData: vi.fn(),
onSnapshot: vi.fn()
}
})
await vi.waitFor(() => expect(sendBinary).toHaveBeenCalledTimes(2))
const heldStreamId = heldTerminal.streamId
const liveStreamId = liveTerminal.streamId
const gate = (
window as typeof window & {
__remoteTerminalMultiplexAckGate?: {
hold: (terminals: string[]) => void
release: () => void
snapshot: () => {
heldTerminalCount: number
heldStreamCount: number
heldAckChars: number
releasedAckChars: number
}
}
}
).__remoteTerminalMultiplexAckGate
expect(gate).toBeDefined()
gate?.hold(['terminal-held'])
sendBinary.mockClear()
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId: heldStreamId,
seq: 1,
payload: encodeTerminalStreamText('held-output')
})
)
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId: liveStreamId,
seq: 2,
payload: encodeTerminalStreamText('live-output')
})
)
const immediateAckFrames = sendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack)
expect(immediateAckFrames).toHaveLength(1)
expect(immediateAckFrames[0]?.streamId).toBe(liveStreamId)
expect(gate?.snapshot()).toMatchObject({
heldTerminalCount: 1,
heldStreamCount: 1,
heldAckChars: 'held-output'.length
})
gate?.release()
const allAckFrames = sendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack)
const releasedAck = allAckFrames.find((frame) => frame?.streamId === heldStreamId)
expect(releasedAck && decodeTerminalStreamJson(releasedAck.payload)).toEqual({
bytes: 'held-output'.length
})
expect(gate?.snapshot()).toMatchObject({
heldTerminalCount: 0,
heldStreamCount: 0,
heldAckChars: 0,
releasedAckChars: 'held-output'.length
})
heldTerminal.close()
liveTerminal.close()
})
it('applies mid-session recovery snapshots without re-subscribing', async () => {
const { getRemoteRuntimeTerminalMultiplexer } =
await import('./remote-runtime-terminal-multiplexer')
resetRemoteRuntimeTerminalMultiplexersForTests()
const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-recovery')
const onSnapshot = vi.fn()
const onSubscribed = vi.fn()
const stream = await multiplexer.subscribeTerminal({
terminal: 'terminal-recovery',
client: { id: 'desktop-recovery', type: 'desktop' },
callbacks: {
onData: vi.fn(),
onSnapshot,
onSubscribed
}
})
await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled())
const streamId = stream.streamId
const injectSnapshot = (info: Record<string, unknown>, text: string): void => {
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotStart,
streamId,
seq: 1,
payload: encodeTerminalStreamJson(info)
})
)
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotChunk,
streamId,
seq: 2,
payload: encodeTerminalStreamText(text)
})
)
callbacks?.onBinary?.(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.SnapshotEnd,
streamId,
seq: 3,
payload: new Uint8Array(0)
})
)
}
injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state')
expect(onSnapshot).toHaveBeenCalledWith('initial state')
expect(onSubscribed).toHaveBeenCalledTimes(1)
injectSnapshot(
{
kind: 'scrollback',
cols: 120,
rows: 40,
reason: 'ack-pending-overflow',
truncated: false
},
'recovered state'
)
// Why: an unsolicited recovery snapshot replaces terminal state, so it
// clears screen and scrollback first and must not replay the subscribe
// lifecycle.
expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`)
expect(onSubscribed).toHaveBeenCalledTimes(1)
// Why: an empty recovery snapshot means the model terminal is blank, so
// the client must still clear stale dropped output.
injectSnapshot(
{
kind: 'scrollback',
cols: 120,
rows: 40,
reason: 'ack-pending-overflow',
truncated: false
},
''
)
expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H')
expect(onSubscribed).toHaveBeenCalledTimes(1)
stream.close()
})
})

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