diff --git a/src/main/ipc-handlers.test.ts b/src/main/ipc-handlers.test.ts index 1d1d7ccb..ba401fcd 100644 --- a/src/main/ipc-handlers.test.ts +++ b/src/main/ipc-handlers.test.ts @@ -26,6 +26,9 @@ const mock = vi.hoisted(() => { fromWebContents: vi.fn(() => mockWindow), getAllWindows: vi.fn(() => [mockWindow]) }, + termFidelityCorpusDumper: { + dump: vi.fn() + }, brokerManager: { start: vi.fn(), shutdown: vi.fn(async () => undefined), @@ -44,6 +47,7 @@ const mock = vi.hoisted(() => { sendMessageAndWaitForDelivery: vi.fn(), reconcileMessages: vi.fn(), listAgents: vi.fn(), + listBrokerDetails: vi.fn(), getAgentOutput: vi.fn(), generateCommitDraft: vi.fn(), getStatus: vi.fn(), @@ -87,7 +91,10 @@ const mock = vi.hoisted(() => { vi.mock('electron', () => ({ app: { - quit: vi.fn() + quit: vi.fn(), + getPath: vi.fn(() => '/tmp/pear-test-user-data'), + getAppPath: vi.fn(() => process.cwd()), + getVersion: vi.fn(() => '1.0.0-test') }, ipcMain: mock.ipcMain, dialog: { @@ -121,6 +128,14 @@ vi.mock('./broker', () => ({ isCommandAvailableWithAugmentedPath: mock.isCommandAvailableWithAugmentedPath })) +vi.mock('./term-fidelity-corpus', () => ({ + TermFidelityCorpusDumper: class { + dump(...args: unknown[]): unknown { + return mock.termFidelityCorpusDumper.dump(...args) + } + } +})) + vi.mock('./git', () => ({ getSelectedDiff: vi.fn() })) @@ -168,6 +183,56 @@ import type { FactoryConfigReadResult, FactoryStatus } from '../shared/types/ipc const asConfigResult = (value: unknown): FactoryConfigReadResult => value as FactoryConfigReadResult const asStatus = (value: unknown): FactoryStatus => value as FactoryStatus +describe('registerIpcHandlers term-fidelity:dump-corpus', () => { + beforeEach(() => { + mock.handlers.clear() + mock.ipcMain.handle.mockClear() + mock.ipcMain.on.mockClear() + mock.termFidelityCorpusDumper.dump.mockReset() + mock.brokerManager.listBrokerDetails.mockReset() + registerIpcHandlers() + }) + + it('captures the sender page and supplies production metadata to the corpus writer', async () => { + const input = { + projectId: 'project-1', + agentName: 'codex-1', + cli: 'codex', + renderer: { rows: 2, cols: 10, text: 'renderer' }, + broker: { rows: 2, cols: 10, text: 'broker' }, + telemetryLines: ['confirmed'] + } + const png = Buffer.from('png') + const capturePage = vi.fn(async () => ({ toPNG: () => png })) + mock.brokerManager.listBrokerDetails.mockResolvedValueOnce([ + { + projectId: 'project-1', + name: 'pear-project-1', + session: { brokerVersion: '10.6.3' } + } + ]) + mock.termFidelityCorpusDumper.dump.mockImplementationOnce( + async (receivedInput, options) => { + expect(receivedInput).toEqual(input) + expect(options.rootDir).toBe('/tmp/pear-test-user-data/term-fidelity-corpus') + expect(await options.capturePage()).toEqual(png) + expect(await options.relayVersions()).toMatchObject({ + pear: '1.0.0-test', + 'agent-relay': '^10.6.3', + 'broker:pear-project-1': '10.6.3' + }) + return { dumped: true, path: '/tmp/bundle' } + } + ) + + const handler = mock.handlers.get('term-fidelity:dump-corpus') + const result = await handler?.({ sender: { capturePage } }, input) + + expect(result).toEqual({ dumped: true, path: '/tmp/bundle' }) + expect(capturePage).toHaveBeenCalledTimes(1) + }) +}) + describe('registerIpcHandlers broker:start', () => { beforeEach(() => { mock.handlers.clear() diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 5d7a03d8..c34f61c3 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -41,9 +41,44 @@ import type { FactoryConfigReadResult, FactoryIssueStatus, FactoryNodeConfig, - FactoryStatus + FactoryStatus, + TermFidelityCorpusInput } from '../shared/types/ipc' import type { ProactiveAgentDraft } from './proactive-agent.types' +import { TermFidelityCorpusDumper } from './term-fidelity-corpus' + +const termFidelityCorpusDumper = new TermFidelityCorpusDumper() + +async function termFidelityRelayVersions(projectId?: string): Promise> { + let declaredRelayVersions: Record = {} + try { + const manifest = JSON.parse( + await readFile(join(app.getAppPath(), 'package.json'), 'utf8') + ) as { dependencies?: Record } | null + declaredRelayVersions = Object.fromEntries( + Object.entries(manifest?.dependencies || {}).filter( + ([name, version]) => + (name === 'agent-relay' || name.startsWith('@agent-relay/')) && + typeof version === 'string' + ) + ) as Record + } catch { + // Broker version metadata below still makes the bundle actionable when a + // packaged app manifest is unavailable. + } + const versions: Record = { + pear: app.getVersion(), + ...declaredRelayVersions + } + const details = await brokerManager.listBrokerDetails().catch(() => []) + for (const detail of details) { + if (projectId && detail.projectId !== projectId) continue + if (detail.session?.brokerVersion) { + versions[`broker:${detail.name}`] = detail.session.brokerVersion + } + } + return versions +} function toBrokerSpawnAgentResult(result: BrokerSpawnAgentResult): BrokerSpawnAgentResult { return { @@ -515,6 +550,17 @@ export function registerIpcHandlers(): void { return true }) + ipcMain.handle( + 'term-fidelity:dump-corpus', + async (event, input: TermFidelityCorpusInput) => { + return termFidelityCorpusDumper.dump(input, { + rootDir: join(app.getPath('userData'), 'term-fidelity-corpus'), + capturePage: async () => (await event.sender.capturePage()).toPNG(), + relayVersions: () => termFidelityRelayVersions(input.projectId) + }) + } + ) + // --- Project --- ipcMain.handle('project:list', () => { const data = loadStore() diff --git a/src/main/term-fidelity-corpus.test.ts b/src/main/term-fidelity-corpus.test.ts new file mode 100644 index 00000000..1b76a4a0 --- /dev/null +++ b/src/main/term-fidelity-corpus.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { + TERM_FIDELITY_CORPUS_DUMP_GAP_MS, + TERM_FIDELITY_CORPUS_WARN_GAP_MS, + TermFidelityCorpusDumper +} from './term-fidelity-corpus' +import type { TermFidelityCorpusInput } from '../shared/types/ipc' + +const temporaryRoots: string[] = [] + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'pear-term-fidelity-corpus-')) + temporaryRoots.push(root) + return root +} + +function corpusInput(agentName = 'codex-1'): TermFidelityCorpusInput { + return { + projectId: 'project-1', + agentName, + cli: 'codex', + renderer: { rows: 2, cols: 10, text: 'row one\nGARBAGE' }, + broker: { rows: 2, cols: 10, text: 'row one\nrow two' }, + telemetryLines: [ + '[terminal] viewport diverged from broker screen; confirmed after 2 quiet checks' + ] + } +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('TermFidelityCorpusDumper', () => { + it('rejects malformed IPC payloads before accessing nested fields', () => { + const dumper = new TermFidelityCorpusDumper() + const options = { + rootDir: '/tmp/not-used', + capturePage: async () => Buffer.from('unused'), + relayVersions: async () => ({}) + } + + expect(() => dumper.dump(null as unknown as TermFidelityCorpusInput, options)).toThrow( + 'input must be an object' + ) + expect(() => dumper.dump({ agentName: 'a', cli: 'codex' } as TermFidelityCorpusInput, options)).toThrow( + 'renderer grid must be an object' + ) + }) + + it('writes the production bundle layout and exactly one log line', async () => { + const rootDir = await temporaryRoot() + const screenshot = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + const capturePage = vi.fn(async () => screenshot) + const log = vi.fn() + const now = Date.parse('2026-07-17T12:34:56.789Z') + const dumper = new TermFidelityCorpusDumper({ now: () => now, log }) + + const result = await dumper.dump(corpusInput(), { + rootDir, + capturePage, + relayVersions: async () => ({ + broker: '10.6.3', + '@agent-relay/sdk': '^10.6.3' + }) + }) + + expect(result.dumped).toBe(true) + if (!result.dumped) throw new Error('Expected a corpus dump') + expect(basename(result.path)).toBe('2026-07-17T12-34-56-789Z') + expect(await readFile(join(result.path, 'renderer.txt'), 'utf8')).toBe('row one\nGARBAGE') + expect(await readFile(join(result.path, 'broker.txt'), 'utf8')).toBe('row one\nrow two') + expect(await readFile(join(result.path, 'screen.png'))).toEqual(screenshot) + expect(JSON.parse(await readFile(join(result.path, 'meta.json'), 'utf8'))).toEqual({ + capturedAt: '2026-07-17T12:34:56.789Z', + projectId: 'project-1', + agentName: 'codex-1', + cli: 'codex', + dims: { + renderer: { rows: 2, cols: 10 }, + broker: { rows: 2, cols: 10 } + }, + relayVersions: { + broker: '10.6.3', + '@agent-relay/sdk': '^10.6.3' + }, + reconcilerTelemetryLines: corpusInput().telemetryLines + }) + expect(capturePage).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith(expect.stringContaining(result.path)) + }) + + it('coalesces concurrent triggers and rate-limits each project + agent for five minutes', async () => { + const rootDir = await temporaryRoot() + let currentTime = 1_000_000 + let releaseCapture: ((png: Buffer) => void) | undefined + const capturePage = vi.fn( + () => new Promise((resolve) => { + releaseCapture = resolve + }) + ) + const log = vi.fn() + const dumper = new TermFidelityCorpusDumper({ now: () => currentTime, log }) + const options = { rootDir, capturePage, relayVersions: async () => ({}) } + + const first = dumper.dump(corpusInput(), options) + const duplicate = dumper.dump(corpusInput(), options) + expect(duplicate).toBe(first) + expect(capturePage).toHaveBeenCalledTimes(1) + releaseCapture?.(Buffer.from('png')) + expect((await first).dumped).toBe(true) + expect((await duplicate).dumped).toBe(true) + + const limited = await dumper.dump(corpusInput(), options) + expect(limited).toEqual({ dumped: false, reason: 'rate-limited' }) + expect(capturePage).toHaveBeenCalledTimes(1) + + // A distinct agent has an independent budget. + const otherAgent = dumper.dump(corpusInput('codex-2'), { + ...options, + capturePage: vi.fn(async () => Buffer.from('other')) + }) + expect((await otherAgent).dumped).toBe(true) + + currentTime += TERM_FIDELITY_CORPUS_DUMP_GAP_MS + const afterGap = dumper.dump(corpusInput(), { + ...options, + capturePage: vi.fn(async () => Buffer.from('after-gap')) + }) + expect((await afterGap).dumped).toBe(true) + expect(log).toHaveBeenCalledTimes(3) + }) + + it('degrades capture failures to a rate-limited warning and allows retry', async () => { + const rootDir = await temporaryRoot() + let currentTime = 2_000_000 + const warn = vi.fn() + const dumper = new TermFidelityCorpusDumper({ now: () => currentTime, warn }) + const failingOptions = { + rootDir, + capturePage: async (): Promise => { + throw new Error('capture\nfailed') + }, + relayVersions: async () => ({}) + } + + expect(await dumper.dump(corpusInput(), failingOptions)).toEqual({ + dumped: false, + reason: 'failed' + }) + expect(await dumper.dump(corpusInput(), failingOptions)).toEqual({ + dumped: false, + reason: 'failed' + }) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith(expect.not.stringContaining('\nfailed')) + + currentTime += TERM_FIDELITY_CORPUS_WARN_GAP_MS + expect(await dumper.dump(corpusInput(), failingOptions)).toEqual({ + dumped: false, + reason: 'failed' + }) + expect(warn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/term-fidelity-corpus.ts b/src/main/term-fidelity-corpus.ts new file mode 100644 index 00000000..c2c8f5dc --- /dev/null +++ b/src/main/term-fidelity-corpus.ts @@ -0,0 +1,176 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { TermFidelityCorpusInput, TermFidelityCorpusResult } from '../shared/types/ipc' + +export const TERM_FIDELITY_CORPUS_DUMP_GAP_MS = 5 * 60_000 +export const TERM_FIDELITY_CORPUS_WARN_GAP_MS = 60_000 + +export interface TermFidelityCorpusDumpOptions { + rootDir: string + capturePage(): Promise + relayVersions(): Promise> +} + +interface TermFidelityCorpusDumperDeps { + now?: () => number + log?: (message: string) => void + warn?: (message: string) => void +} + +function agentKey(input: TermFidelityCorpusInput): string { + return `${input.projectId || ''}\u0000${input.agentName}` +} + +function timestampDirectoryName(timestampMs: number): string { + return new Date(timestampMs).toISOString().replace(/[:.]/gu, '-') +} + +function validateInput(input: unknown): asserts input is TermFidelityCorpusInput { + if (!input || typeof input !== 'object') { + throw new Error('Term-fidelity corpus input must be an object') + } + const candidate = input as Partial + if (typeof candidate.projectId !== 'string' && candidate.projectId !== undefined) { + throw new Error('Term-fidelity corpus project ID must be a string') + } + if (typeof candidate.agentName !== 'string' || !candidate.agentName.trim()) { + throw new Error('Term-fidelity corpus agent name is required') + } + if (typeof candidate.cli !== 'string' || !candidate.cli.trim()) { + throw new Error('Term-fidelity corpus CLI is required') + } + + for (const [label, grid] of [ + ['renderer', candidate.renderer], + ['broker', candidate.broker] + ] as const) { + if (!grid || typeof grid !== 'object') { + throw new Error(`Term-fidelity corpus ${label} grid must be an object`) + } + if (!Number.isInteger(grid.rows) || grid.rows <= 0) { + throw new Error(`Term-fidelity corpus ${label} rows must be a positive integer`) + } + if (!Number.isInteger(grid.cols) || grid.cols <= 0) { + throw new Error(`Term-fidelity corpus ${label} cols must be a positive integer`) + } + if (typeof grid.text !== 'string') { + throw new Error(`Term-fidelity corpus ${label} text must be a string`) + } + } + + if ( + !Array.isArray(candidate.telemetryLines) || + candidate.telemetryLines.some((line) => typeof line !== 'string') + ) { + throw new Error('Term-fidelity corpus telemetry lines must be strings') + } +} + +/** + * Main-process writer for production terminal-fidelity bundles. + * + * Calls for the same project + agent share an in-flight promise. Once a dump + * starts, later confirmed divergences are suppressed for five minutes. A + * failed dump releases its reservation so a later confirmation can retry. + */ +export class TermFidelityCorpusDumper { + private readonly lastDumpAt = new Map() + private readonly inFlight = new Map>() + private readonly lastFailureWarnAt = new Map() + private lastBundleTimestampMs = 0 + private readonly now: () => number + private readonly log: (message: string) => void + private readonly warn: (message: string) => void + + constructor(deps: TermFidelityCorpusDumperDeps = {}) { + this.now = deps.now ?? Date.now + this.log = deps.log ?? ((message) => console.info(message)) + this.warn = deps.warn ?? ((message) => console.warn(message)) + } + + dump( + input: TermFidelityCorpusInput, + options: TermFidelityCorpusDumpOptions + ): Promise { + validateInput(input) + const key = agentKey(input) + const pending = this.inFlight.get(key) + if (pending) return pending + + const startedAt = this.now() + const previousDumpAt = this.lastDumpAt.get(key) + if ( + previousDumpAt !== undefined && + startedAt - previousDumpAt < TERM_FIDELITY_CORPUS_DUMP_GAP_MS + ) { + return Promise.resolve({ dumped: false, reason: 'rate-limited' }) + } + + // Reserve before the first await so duplicate IPC delivery cannot start a + // second capture. The in-flight promise coalesces truly concurrent calls. + this.lastDumpAt.set(key, startedAt) + const promise = this.writeBundle(input, options, startedAt) + .catch((error: unknown): TermFidelityCorpusResult => { + if (this.lastDumpAt.get(key) === startedAt) this.lastDumpAt.delete(key) + const failedAt = this.now() + const lastWarnAt = this.lastFailureWarnAt.get(key) + if (lastWarnAt === undefined || failedAt - lastWarnAt >= TERM_FIDELITY_CORPUS_WARN_GAP_MS) { + this.lastFailureWarnAt.set(key, failedAt) + const message = error instanceof Error ? error.message : String(error) + this.warn( + `[term-fidelity] corpus dump failed for ${JSON.stringify(input.agentName)}: ${message.replace(/[\r\n]+/gu, ' ')}` + ) + } + return { dumped: false, reason: 'failed' } + }) + .finally(() => { + if (this.inFlight.get(key) === promise) this.inFlight.delete(key) + }) + this.inFlight.set(key, promise) + return promise + } + + private async writeBundle( + input: TermFidelityCorpusInput, + options: TermFidelityCorpusDumpOptions, + startedAt: number + ): Promise { + // Two different agents can confirm within the same millisecond. Advance + // the directory timestamp monotonically so their bundles cannot collide. + const bundleTimestampMs = Math.max(startedAt, this.lastBundleTimestampMs + 1) + this.lastBundleTimestampMs = bundleTimestampMs + const capturedAt = new Date(bundleTimestampMs).toISOString() + const bundleDir = join(options.rootDir, timestampDirectoryName(bundleTimestampMs)) + const [screenshot, relayVersions] = await Promise.all([ + options.capturePage(), + options.relayVersions() + ]) + const meta = { + capturedAt, + projectId: input.projectId, + agentName: input.agentName, + cli: input.cli, + dims: { + renderer: { rows: input.renderer.rows, cols: input.renderer.cols }, + broker: { rows: input.broker.rows, cols: input.broker.cols } + }, + relayVersions, + reconcilerTelemetryLines: input.telemetryLines + } + + await mkdir(bundleDir, { recursive: true }) + await Promise.all([ + writeFile(join(bundleDir, 'renderer.txt'), input.renderer.text), + writeFile(join(bundleDir, 'broker.txt'), input.broker.text), + writeFile(join(bundleDir, 'screen.png'), screenshot), + writeFile(join(bundleDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`) + ]) + + // Exactly one main-process line is emitted for each completed bundle. + // JSON encoding keeps an unexpected newline in an agent name on one line. + this.log( + `[term-fidelity] dumped corpus bundle for ${JSON.stringify(input.agentName)} to ${JSON.stringify(bundleDir)}` + ) + return { dumped: true, path: bundleDir } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 20874d94..96eb1fd7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -74,6 +74,8 @@ import type { ProjectIntegrationResult, ProjectRootRecord, TerminalAttachMode, + TermFidelityCorpusInput, + TermFidelityCorpusResult, UpdaterState, WorkforcePersona } from '../shared/types/ipc' @@ -175,6 +177,9 @@ export type { ProjectRootConflict, ProjectRootRecord, TerminalAttachMode, + TermFidelityCorpusGrid, + TermFidelityCorpusInput, + TermFidelityCorpusResult, WorkforcePersona } from '../shared/types/ipc' @@ -195,7 +200,9 @@ const api = { confirmQuit: () => invoke('app:confirm-quit'), notifyCliReady: () => { ipcRenderer.send('cli:renderer-ready') - } + }, + dumpTermFidelityCorpus: (input: TermFidelityCorpusInput) => + invoke('term-fidelity:dump-corpus', input) }, project: { list: () => invoke('project:list'), diff --git a/src/renderer/src/components/terminal/TerminalInstance.tsx b/src/renderer/src/components/terminal/TerminalInstance.tsx index 461ed99a..430df7f2 100644 --- a/src/renderer/src/components/terminal/TerminalInstance.tsx +++ b/src/renderer/src/components/terminal/TerminalInstance.tsx @@ -5,6 +5,7 @@ import type { TerminalAttachMode } from '@/lib/ipc' interface Props { agentName: string + cli?: string projectId?: string visible: boolean active: boolean @@ -12,9 +13,9 @@ interface Props { onActivate?: () => void } -export function TerminalInstance({ agentName, projectId, visible, active, mode, onActivate }: Props): React.ReactNode { +export function TerminalInstance({ agentName, cli, projectId, visible, active, mode, onActivate }: Props): React.ReactNode { const containerRef = useRef(null) - useTerminal(containerRef, agentName, projectId, visible, active, mode) + useTerminal(containerRef, agentName, projectId, visible, active, mode, cli) return (
(null) const activeRef = useRef(active) @@ -175,6 +176,7 @@ export function useTerminal( const runtime = acquireTerminalRuntime({ projectId, agentName, + cli, terminalMode: terminalModeRef.current, theme: themeRef.current, getInputSrtt: () => inputSrttRef.current @@ -285,7 +287,7 @@ export function useTerminal( runtime.detach(mountToken) } } - }, [containerRef, agentName, projectId, sendInput]) + }, [containerRef, agentName, projectId, cli, sendInput]) // Dispose the runtime when its owning agent is no longer in the store. // Tab switches null-out agentName without removing the agent — we should diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index 825d4b03..c25d4303 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -809,7 +809,8 @@ const authStatus: AuthStatus = { export const pearMock: PearAPI = { app: { confirmQuit: async () => true, - notifyCliReady: () => undefined + notifyCliReady: () => undefined, + dumpTermFidelityCorpus: async () => ({ dumped: false, reason: 'rate-limited' }) }, project: { list: async (): Promise => ({ projects: clone(state.projects), activeId: state.activeId }), diff --git a/src/renderer/src/lib/terminal-reconciler.test.ts b/src/renderer/src/lib/terminal-reconciler.test.ts index 3a1321c5..2d9fd3b9 100644 --- a/src/renderer/src/lib/terminal-reconciler.test.ts +++ b/src/renderer/src/lib/terminal-reconciler.test.ts @@ -104,6 +104,56 @@ describe('createTerminalReconciler', () => { reconciler.dispose() }) + it('triggers the corpus hook only after divergence is confirmed', async () => { + const { deps, repairs, state } = makeHarness() + const order: string[] = [] + const originalWriteRepair = deps.writeRepair + deps.writeRepair = (ansi) => { + order.push('repair') + originalWriteRepair(ansi) + } + const onConfirmedDivergence = vi.fn(() => order.push('corpus')) + deps.onConfirmedDivergence = onConfirmedDivergence + const reconciler = createTerminalReconciler(deps) + diverge(state) + + await reconciler.checkNow() + expect(onConfirmedDivergence).not.toHaveBeenCalled() + await reconciler.checkNow() + + expect(onConfirmedDivergence).toHaveBeenCalledTimes(1) + expect(onConfirmedDivergence).toHaveBeenCalledWith({ + plain: state.plain, + viewport: state.viewport, + telemetryLines: [ + '[terminal] viewport diverged from broker screen; confirmed after 2 quiet checks at 2x10' + ] + }) + expect(repairs).toHaveLength(1) + expect(order).toEqual(['repair', 'corpus']) + + // A still-divergent viewport confirms again inside the repair gap. The + // diagnostic hook still fires, but the convergence repair remains gated. + await confirmCycles(reconciler) + expect(onConfirmedDivergence).toHaveBeenCalledTimes(2) + expect(repairs).toHaveLength(1) + reconciler.dispose() + }) + + it('keeps repairing when the corpus hook throws', async () => { + const { deps, repairs, state } = makeHarness() + deps.onConfirmedDivergence = () => { + throw new Error('corpus IPC unavailable') + } + const reconciler = createTerminalReconciler(deps) + diverge(state) + + await confirmCycles(reconciler) + + expect(repairs).toHaveLength(1) + reconciler.dispose() + }) + it('never fetches while not quiet', async () => { const { deps, state } = makeHarness() const fetches: string[] = [] diff --git a/src/renderer/src/lib/terminal-reconciler.ts b/src/renderer/src/lib/terminal-reconciler.ts index fbe512f9..348039fa 100644 --- a/src/renderer/src/lib/terminal-reconciler.ts +++ b/src/renderer/src/lib/terminal-reconciler.ts @@ -68,6 +68,12 @@ export interface ReconcileViewport { lines: string[] } +export interface ConfirmedTerminalDivergence { + plain: ReconcileSnapshot + viewport: ReconcileViewport + telemetryLines: string[] +} + export interface TerminalReconcilerDeps { fetchSnapshot(format: 'plain' | 'ansi'): Promise readViewport(): ReconcileViewport | null @@ -93,6 +99,12 @@ export interface TerminalReconcilerDeps { grid: { rows: number; cols: number }, snapshot: { rows: number; cols: number } ): void + /** + * Best-effort observation hook. Its snapshot pair is retained when the + * confirm-twice gate is met, then the hook is queued after the repair + * decision/write. It must never affect or delay repair. + */ + onConfirmedDivergence?(divergence: ConfirmedTerminalDivergence): void log?(message: string): void now?(): number } @@ -115,6 +127,7 @@ export function createTerminalReconciler(deps: TerminalReconcilerDeps): Terminal let lastErrorLogAt = 0 let lastRepairAt = 0 let repairCount = 0 + let pendingConfirmedDivergence: ConfirmedTerminalDivergence | null = null const timer = setInterval(() => { void check() @@ -135,6 +148,20 @@ export function createTerminalReconciler(deps: TerminalReconcilerDeps): Terminal } } finally { checking = false + const confirmedDivergence = pendingConfirmedDivergence + pendingConfirmedDivergence = null + if (confirmedDivergence) { + // Corpus persistence starts only after the repair decision/write and + // is never awaited. A slow capturePage or disk cannot delay + // convergence. + queueMicrotask(() => { + try { + deps.onConfirmedDivergence?.(confirmedDivergence) + } catch { + // Diagnostic observer only: never alter the repair path. + } + }) + } } } @@ -172,6 +199,14 @@ export function createTerminalReconciler(deps: TerminalReconcilerDeps): Terminal } mismatchStreak += 1 if (mismatchStreak < RECONCILE_CONFIRM_CHECKS) return + pendingConfirmedDivergence = { + plain: { ...plain }, + viewport: { ...viewport, lines: [...viewport.lines] }, + telemetryLines: [ + `[terminal] viewport diverged from broker screen; confirmed after ` + + `${mismatchStreak} quiet checks at ${viewport.rows}x${viewport.cols}` + ] + } if (now() - lastRepairAt < RECONCILE_MIN_REPAIR_GAP_MS) return const ansi = await deps.fetchSnapshot('ansi') diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 11adfdbb..3b95c860 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -178,6 +178,7 @@ export interface TerminalRuntime { interface AcquireOptions { projectId: string | undefined agentName: string + cli?: string terminalMode: TerminalAttachMode theme: Theme getInputSrtt: () => number | null @@ -375,6 +376,27 @@ function createRuntime( // writes and, on the engine route, also repairs the engine's model. echoRouter.onServerOutput(ansi) }, + onConfirmedDivergence: ({ plain, viewport, telemetryLines }) => { + void pear.app.dumpTermFidelityCorpus({ + projectId: opts.projectId, + agentName: opts.agentName, + cli: opts.cli?.trim() || 'unknown', + renderer: { + rows: viewport.rows, + cols: viewport.cols, + text: viewport.lines.join('\n') + }, + broker: { + rows: plain.rows, + cols: plain.cols, + text: plain.screen + }, + telemetryLines + }).catch(() => { + // Corpus persistence is best-effort diagnostics. The main process + // owns failure reporting; never perturb terminal reconciliation. + }) + }, isQuiet: () => { if (disposed || !attachSeeded || currentToken === null || !opened) return false // A hidden window stalls the rAF chunk flush, so "no recent output" diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 1cb43ef2..edb63909 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -359,6 +359,25 @@ export interface BrokerSpawnAgentResult { cli?: string } +export interface TermFidelityCorpusGrid { + rows: number + cols: number + text: string +} + +export interface TermFidelityCorpusInput { + projectId?: string + agentName: string + cli: string + renderer: TermFidelityCorpusGrid + broker: TermFidelityCorpusGrid + telemetryLines: string[] +} + +export type TermFidelityCorpusResult = + | { dumped: true; path: string } + | { dumped: false; reason: 'rate-limited' | 'failed' } + export interface WorkforcePersona { id: string description?: string @@ -910,6 +929,7 @@ export interface PearAPI { app: { confirmQuit: () => Promise notifyCliReady: () => void + dumpTermFidelityCorpus: (input: TermFidelityCorpusInput) => Promise } project: { list: () => Promise