Complete terminal side-effect facts coverage

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-10 02:55:07 -07:00
co-authored by Orca
parent 9ee937b096
commit ecdd29793f
22 changed files with 974 additions and 254 deletions
+8 -3
View File
@@ -129,6 +129,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,
@@ -990,10 +991,14 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool
// 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. The legacy pty:data copy below stays until slice 3 so renderer
// byte parsers keep working while the kill switch is off.
// authority.
runtime?.ingestSyntheticTitleFrame(ptyId, data)
mainWindow.webContents.send('pty:data', { id: 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 {
+91
View File
@@ -3763,6 +3763,97 @@ describe('OrcaRuntimeService', () => {
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('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)
+43 -9
View File
@@ -17,6 +17,7 @@ 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 ParsedAgentStatusPayload,
@@ -3571,6 +3572,33 @@ export class OrcaRuntimeService {
}
}
/** 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. */
@@ -3646,12 +3674,19 @@ export class OrcaRuntimeService {
onAgentExited: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' })
},
// Why: bell facts exist only for the pty:sideEffect channel. Headless
// serve has no consumer, so skip the per-chunk bell walk entirely.
// Why: bell/command-finished/pr-link facts exist only for the
// pty:sideEffect channel. Headless serve has no consumer, so skip the
// per-chunk bell walk and 133/URL 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 })
}
}
: {})
@@ -4124,10 +4159,9 @@ export class OrcaRuntimeService {
// If renderer serialization races reload/unmount, the runtime snapshot
// below can still preserve colored terminal state.
}
if (rendererSnapshot && rendererSnapshot.data.length > 0) {
return { ...rendererSnapshot, source: 'renderer' }
}
return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null
return rendererSnapshot
? this.preferTrackedLastTitle(ptyId, { ...rendererSnapshot, source: 'renderer' as const })
: null
}
private async withVisibleSnapshotFallback(
@@ -4212,15 +4246,15 @@ 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
})
: null
}
@@ -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
}
@@ -468,8 +468,8 @@ describe('startParkedTerminalByteWatcher', () => {
//
// 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 and PR-link scan (they move to main in a later slice).
// 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 = {
@@ -664,15 +664,36 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
it('still answers DECSET 2031 and observes PR links from the byte sidecar', async () => {
it('keeps the byte sidecar only for the DECSET 2031 reply — no PR byte scan', async () => {
enableMainAuthority()
const { dispose, sendInput } = await startWatcher()
emit('\x1b[?2031h')
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('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()
})
@@ -19,7 +19,7 @@ import {
} from '../../../../shared/terminal-color-scheme-protocol'
import { useAppStore } from '@/store'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import {
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
isAgentTaskCompleteOsNotificationEnabledFromState,
@@ -93,7 +93,6 @@ export function startParkedTerminalByteWatcher(
let bellNotificationTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteTimer: ReturnType<typeof setTimeout> | null = null
let mode2031ScanTail = ''
const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector()
const clearBellNotificationTimer = (): void => {
if (bellNotificationTimer !== null) {
@@ -238,12 +237,21 @@ export function startParkedTerminalByteWatcher(
...(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
callbacks: {
...sideEffectCallbacks,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
}
})
: null
@@ -260,16 +268,20 @@ export function startParkedTerminalByteWatcher(
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
}
// Why: the byte sidecar stays in BOTH modes for the 2031 reply and PR-link
// scan — those move to main in a later slice. Only the title/bell/agent
// parsing is gated: processor is null when main holds authority.
// Why: under main authority the byte sidecar stays ONLY for the DECSET 2031
// reply — query authority belongs to the view/watcher (model/view contract
// invariant 6), so it can never move to main. Title/bell/agent parsing and
// the PR-link scan are byte-parser-mode only (null when main holds
// authority; their facts arrive on pty:sideEffect instead).
const unsubscribe = 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, {})
respondToMode2031Subscribe(data)
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
if (observeTerminalGitHubPRLink) {
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
}
}
})
@@ -5281,6 +5281,100 @@ describe('connectPanePty', () => {
expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled()
})
it('drops the agent status from a command-finished fact like the byte path did', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
const handler = await import('./terminal-side-effect-facts-handler')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.agentStatusByPaneKey = {
[paneKey]: {
paneKey,
state: 'done',
prompt: 'hi',
updatedAt: 1000,
stateStartedAt: 1000,
agentType: 'codex',
stateHistory: []
}
}
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
onPtySpawn('pty-fact-133')
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-fact-133',
seq: 1,
facts: [{ kind: 'command-finished', exitCode: 130 }]
})
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
})
it('routes pr-link facts to the worktree PR observer', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
const handler = await import('./terminal-side-effect-facts-handler')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
onPtySpawn('pty-fact-pr')
const link = {
url: 'https://github.com/acme/orca/pull/42',
slug: { owner: 'acme', repo: 'orca' },
number: 42
}
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-fact-pr',
seq: 1,
facts: [{ kind: 'pr-link', link }]
})
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith('wt-1', link)
})
it('does not byte-scan PR links or OSC 133 — facts are the only consumer', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-authority-bytes'
}
)
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.agentStatusByPaneKey = {
[paneKey]: {
paneKey,
state: 'done',
prompt: 'hi',
updatedAt: 1000,
stateStartedAt: 1000,
agentType: 'codex',
stateHistory: []
}
}
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
await flushAsyncTicks()
expect(capturedDataCallback.current).not.toBeNull()
capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n')
capturedDataCallback.current?.('\x1b]133;D;130\x07prompt $ ')
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
})
it('honors the persisted kill switch for panes bound before settings hydrate', async () => {
// Pre-hydration: the store has no settings yet, but the user persisted
// the kill switch off. The pane must register byte parsers, not a fact
@@ -75,7 +75,7 @@ import {
} from './terminal-bracketed-paste'
import { createCommandCodeOutputStatusDetector } from './command-code-output-status'
import type { PtyDataMeta } from './pty-dispatcher'
import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import { installConptyDeviceAttributesHandler } from './terminal-conpty-device-attributes'
import {
cancelScheduledHiddenOutputRestore,
@@ -889,33 +889,40 @@ export function connectPanePty(
}
return pendingWrite.then(() => interruptInference.flushPending())
}
const commandLifecycle = createTerminalCommandLifecycle({
onCommandFinished: () => {
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 => {
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)) {
@@ -993,7 +1000,10 @@ export function connectPanePty(
onBell,
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited
onAgentExited,
onCommandFinished: handleCommandFinished,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link)
},
restoreTitleOnRegister: true
})
@@ -2660,11 +2670,17 @@ export function connectPanePty(
const dataCallback = (data: string, meta?: PtyDataMeta): void => {
resetHiddenOutputRestoreIfPtyChanged()
observeTerminalBracketedPasteModeOutput(pane.terminal, data)
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link)
// Why: with main side-effect authority, command-finished and pr-link
// 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)
// 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.
@@ -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()
}
@@ -173,6 +173,72 @@ describe('registerTerminalSideEffectFactConsumer', () => {
])
})
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('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({
@@ -12,6 +12,7 @@
* 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
@@ -73,6 +74,10 @@ export type TerminalSideEffectFactConsumerCallbacks = {
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
}
type ConsumerEntry = {
@@ -109,6 +114,12 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq:
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)
}
}
@@ -8,8 +8,10 @@
// event sequences match.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc'
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'
@@ -69,7 +71,9 @@ function createMainPath(): TitleFactPath {
}
}
function feedBoth(paths: { renderer: TitleFactPath; main: TitleFactPath }, chunk: string): void {
type ChunkFeed = { feed: (chunk: string) => void }
function feedBoth(paths: { renderer: ChunkFeed; main: ChunkFeed }, chunk: string): void {
paths.renderer.feed(chunk)
paths.main.feed(chunk)
}
@@ -187,3 +191,95 @@ describe('main title tracker parity with the renderer transport processor', () =
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([])
})
})
+4 -122
View File
@@ -1,122 +1,4 @@
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
export type RepoSlug = {
owner: string
repo: string
}
export type GitHubLinkQuery = {
query: string
directNumber: number | null
}
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(/\/+$/, ''))
}
/**
* 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 Number.parseInt(numeric, 10)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return Number.parseInt(match[4], 10)
}
/**
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
* null for anything that isn't a recognizable github.com 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 (!/^(?:www\.)?github\.com$/i.test(url.hostname)) {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return {
slug: { owner: match[1], repo: match[2] },
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
number: Number.parseInt(match[4], 10)
}
}
/**
* Normalizes link-picker input so both raw issue/PR numbers and full GitHub
* URLs resolve to a usable query + direct-number lookup.
*/
export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery {
const trimmed = raw.trim()
if (!trimmed) {
return { query: '', directNumber: null }
}
const direct = parseGitHubIssueOrPRNumber(trimmed)
if (direct !== null && !trimmed.startsWith('http')) {
return { query: trimmed, directNumber: direct }
}
const link = parseGitHubIssueOrPRLink(trimmed)
if (!link) {
return { query: trimmed, directNumber: null }
}
// Why: any github.com issue/pull URL is accepted by number regardless of
// slug, since fork checkouts can legitimately target upstream issues whose
// slug differs from the origin remote.
return {
query: trimmed,
directNumber: link.number
}
}
// 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.
export * from '../../../shared/github-links'
@@ -17,7 +17,7 @@ import type {
WorktreeRemoteBranchConflictEvent,
WorktreeMeta
} from '../../../../shared/types'
import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type {
PendingWorktreeCreation,
WorktreeCreationPhase
+122
View File
@@ -0,0 +1,122 @@
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
export type RepoSlug = {
owner: string
repo: string
}
export type GitHubLinkQuery = {
query: string
directNumber: number | null
}
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(/\/+$/, ''))
}
/**
* 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 Number.parseInt(numeric, 10)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (!/^(?:www\.)?github\.com$/i.test(url.hostname)) {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return Number.parseInt(match[4], 10)
}
/**
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
* null for anything that isn't a recognizable github.com 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 (!/^(?:www\.)?github\.com$/i.test(url.hostname)) {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return {
slug: { owner: match[1], repo: match[2] },
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
number: Number.parseInt(match[4], 10)
}
}
/**
* Normalizes link-picker input so both raw issue/PR numbers and full GitHub
* URLs resolve to a usable query + direct-number lookup.
*/
export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery {
const trimmed = raw.trim()
if (!trimmed) {
return { query: '', directNumber: null }
}
const direct = parseGitHubIssueOrPRNumber(trimmed)
if (direct !== null && !trimmed.startsWith('http')) {
return { query: trimmed, directNumber: direct }
}
const link = parseGitHubIssueOrPRLink(trimmed)
if (!link) {
return { query: trimmed, directNumber: null }
}
// Why: any github.com issue/pull URL is accepted by number regardless of
// slug, since fork checkouts can legitimately target upstream issues whose
// slug differs from the origin remote.
return {
query: trimmed,
directNumber: link.number
}
}
@@ -1,3 +1,12 @@
/**
* Chunk-boundary-safe GitHub PR URL scan over PTY output.
*
* Why shared: terminal-side-effect-authority.md (slice 3) makes main emit
* `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the
* renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off
* path. Both paths must share the carry/dedupe semantics or links split across
* chunks would resolve differently per authority mode.
*/
import type { RepoSlug } from './github-links'
import { parseGitHubIssueOrPRLink } from './github-links'
@@ -0,0 +1,102 @@
/**
* Chunk-boundary-safe OSC 133;D (command finished) scanner.
*
* Why shared: terminal-side-effect-authority.md (slice 3) makes main emit
* `command-finished` facts from its per-PTY tracker for local/SSH PTYs, while
* the renderer keeps byte-parsing for remote-runtime PTYs and the
* kill-switch-off path. The carry semantics (split prefixes, BEL/ST
* terminators, best-effort exit codes) must be identical in both.
*/
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 type Osc133CommandFinishedScanner = {
/** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */
scan: (data: string) => void
/** Drop the cross-chunk carry (transport teardown / parser reset). */
reset: () => void
}
export function createOsc133CommandFinishedScanner(
onCommandFinished: (bestEffortExitCode: number | null) => void
): Osc133CommandFinishedScanner {
let carry = ''
const handleOsc133 = (payload: string): void => {
const [sequence, exitCode] = payload.split(';')
if (sequence === 'D') {
onCommandFinished(parseBestEffortExitCode(exitCode))
}
}
const scan = (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 {
scan,
reset() {
carry = ''
}
}
}
@@ -0,0 +1,141 @@
// Why: slice 3 of terminal-side-effect-authority.md adds OSC 133;D
// command-finished and GitHub pr-link scanning to the shared tracker so main
// emits those facts for local/SSH PTYs. These tests pin the chunk-boundary
// carry, exit-code best-effort, dedupe, and synthetic-frame isolation rules.
import { describe, expect, it } from 'vitest'
import {
createTerminalTitleTracker,
type TerminalTitleTrackerCallbacks
} from './terminal-output-side-effects'
const ESC = '\x1b'
const BEL = '\x07'
const ST = `${ESC}\\`
type RecordedEvent =
| ['title', string]
| ['bell']
| ['finished', number | null]
| ['pr', string, number]
function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): {
events: RecordedEvent[]
tracker: ReturnType<typeof createTerminalTitleTracker>
} {
const events: RecordedEvent[] = []
const tracker = createTerminalTitleTracker({
onTitle: (normalized) => events.push(['title', normalized]),
onBell: () => events.push(['bell']),
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
onPrLink: (link) => events.push(['pr', link.url, link.number]),
...overrides
})
return { events, tracker }
}
describe('createTerminalTitleTracker command-finished facts', () => {
it('emits command-finished with best-effort exit codes', () => {
const { events, tracker } = createRecordingTracker()
tracker.handleChunk(`before${ESC}]133;A${BEL}prompt${ESC}]133;B${BEL}`)
tracker.handleChunk(`${ESC}]133;C${BEL}running${ESC}]133;D;0${BEL}`)
tracker.handleChunk(`${ESC}]133;D;130${BEL}`)
tracker.handleChunk(`${ESC}]133;D;not-a-number${BEL}`)
tracker.handleChunk(`${ESC}]133;D${BEL}`)
expect(events).toEqual([
['finished', 0],
['finished', 130],
['finished', null],
['finished', null]
])
})
it('detects OSC 133;D split across chunk boundaries (BEL and ST terminated)', () => {
const { events, tracker } = createRecordingTracker()
tracker.handleChunk(`chunk${ESC}]133`)
tracker.handleChunk(';D;1')
expect(events).toEqual([])
tracker.handleChunk(`30${BEL}rest`)
tracker.handleChunk(`${ESC}]133;D;7${ST}`)
expect(events).toEqual([
['finished', 130],
['finished', 7]
])
})
it('orders chunk facts titles → command-finished → bell', () => {
const { events, tracker } = createRecordingTracker()
tracker.handleChunk(`${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}done${BEL}`)
expect(events).toEqual([['title', 'zsh'], ['finished', 0], ['bell']])
})
})
describe('createTerminalTitleTracker pr-link facts', () => {
it('emits one fact per PR URL including multiple links in one chunk', () => {
const { events, tracker } = createRecordingTracker()
tracker.handleChunk(
'see https://github.com/acme/orca/pull/42 and https://github.com/acme/orca/pull/43 \r\n'
)
expect(events).toEqual([
['pr', 'https://github.com/acme/orca/pull/42', 42],
['pr', 'https://github.com/acme/orca/pull/43', 43]
])
})
it('waits for a boundary when a URL splits across chunks and dedupes repeats', () => {
const { events, tracker } = createRecordingTracker()
tracker.handleChunk('PR: https://github.com/acme/orca/pull/4')
expect(events).toEqual([])
tracker.handleChunk('2\r\n')
tracker.handleChunk('again https://github.com/acme/orca/pull/42\r\n')
expect(events).toEqual([['pr', 'https://github.com/acme/orca/pull/42', 42]])
})
it('skips the 133/URL scans entirely when no consumer is registered', () => {
// Mirrors headless serve: no pty:sideEffect consumer means no callbacks,
// so the scanners must not be created (no carry state, no scan cost).
const titles: string[] = []
const tracker = createTerminalTitleTracker({
onTitle: (normalized) => titles.push(normalized)
})
tracker.handleChunk(`${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n`)
expect(titles).toEqual([])
})
})
describe('createTerminalTitleTracker synthetic-frame isolation', () => {
it('never feeds synthetic frames to the 133/PR scanners', () => {
const { events, tracker } = createRecordingTracker()
tracker.applySyntheticTitleFrame(
`${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n`
)
expect(events).toEqual([['title', '⠋ Cursor Agent']])
})
it('keeps a split 133 carry intact across an interleaved synthetic frame', () => {
const { events, tracker } = createRecordingTracker()
tracker.handleChunk(`out${ESC}]133;D;`)
// An 80ms spinner tick lands between the two halves of the real OSC.
tracker.applySyntheticTitleFrame(`${ESC}]0;⠋ Cursor Agent${BEL}`)
tracker.handleChunk(`130${BEL}`)
expect(events).toEqual([
['title', '⠋ Cursor Agent'],
['finished', 130]
])
})
})
+41 -1
View File
@@ -18,6 +18,11 @@ import {
normalizeTerminalTitle
} from './agent-detection'
import { createBellDetector } from './terminal-bell-detector'
import {
createTerminalGitHubPRLinkDetector,
type TerminalGitHubPRLink
} from './terminal-github-pr-link-detector'
import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished'
/** Ms of title-less output after a working title before it is cleared. */
export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000
@@ -59,6 +64,15 @@ export type TerminalTitleTrackerCallbacks = {
* across chunks), after the chunk's title facts — the renderer drain order.
*/
onBell?: () => void
/**
* Fired per complete OSC 133;D (chunk-boundary-safe) with the sequence's
* best-effort exit code — mirrors the renderer terminal-command-lifecycle
* semantics so the fact path drops stale agent rows exactly like byte mode.
*/
onCommandFinished?: (bestEffortExitCode: number | null) => void
/** Fired once per newly observed GitHub PR URL (chunk-boundary-safe,
* deduplicated per tracker like the renderer detector). */
onPrLink?: (link: TerminalGitHubPRLink) => void
}
export type TerminalTitleTracker = {
@@ -87,8 +101,22 @@ export function createTerminalTitleTracker(
callbacks: TerminalTitleTrackerCallbacks,
options: { initialTitle?: string } = {}
): TerminalTitleTracker {
const { onTitle, onAgentBecameIdle, onAgentBecameWorking, onAgentExited, onBell } = callbacks
const {
onTitle,
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited,
onBell,
onCommandFinished,
onPrLink
} = callbacks
const bellDetector = onBell ? createBellDetector() : null
// Why: created only when a consumer exists (like the bell detector) so
// headless serve never pays the per-chunk 133/URL scans.
const commandFinishedScanner = onCommandFinished
? createOsc133CommandFinishedScanner(onCommandFinished)
: null
const prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null
// Why: seed both the emitted-title memory (stale-title probe) and the agent
// tracker so a mid-session tracker behaves as if it had observed the pane's
// last live title — parity with the renderer processor's seeding.
@@ -183,6 +211,15 @@ export function createTerminalTitleTracker(
}
}, STALE_WORKING_TITLE_TIMEOUT_MS)
}
// Per-chunk fact order: titles → command-finished → pr-link → bell. The
// bell stays last (the renderer drain's order); the byte scanners keep
// their own cross-chunk carry so split sequences/URLs still resolve.
commandFinishedScanner?.scan(data)
if (prLinkDetector) {
for (const link of prLinkDetector(data)) {
onPrLink?.(link)
}
}
if (containsBell) {
onBell?.()
}
@@ -204,6 +241,8 @@ export function createTerminalTitleTracker(
// The deliberate permission BEL rides outside the OSC title sequence. A
// FRESH detector instance keeps the OSC-terminator-vs-bell semantics
// while guaranteeing zero interaction with the chunk detector's state.
// Synthetic frames never reach the 133/PR-link scanners: fabricated bytes
// contain neither and must not perturb their cross-chunk carry state.
if (onBell && createBellDetector().chunkContainsBell(frame)) {
onBell()
}
@@ -227,6 +266,7 @@ export function createTerminalTitleTracker(
clearStaleTitleTimer()
agentTracker?.reset()
bellDetector?.reset()
commandFinishedScanner?.reset()
}
}
}
+7
View File
@@ -6,6 +6,8 @@
* See docs/reference/terminal-side-effect-authority.md.
*/
import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector'
/** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not
* observed bytes. Renderer policy clears title/cache state from them but
* must not schedule task-complete notifications or unread attention — a
@@ -16,6 +18,11 @@ export type TerminalSideEffectFact =
| { kind: 'agent-working' }
| { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean }
| { kind: 'agent-exited' }
/** OSC 133;D — foreground shell command exited (exit code best-effort). */
| { kind: 'command-finished'; exitCode: number | null }
/** Carries the parsed link so the renderer store consumer never re-parses
* the URL (parse drift would break the per-PTY dedupe contract). */
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
export type TerminalSideEffectBatch = {
ptyId: string