Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/plans/2026-09-25-screen-ipc-interest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Screen IPC by interest (#762)

## Problem
`session:screen` is the largest IPC stream by far:
- **Share of traffic:** 93% of recorded IPC bytes in recording g00000190 (16,728 frames of about 8.8 KB over 123 minutes). The committed fixture `testing/fixtures/worktree-live-attribution/codex-live-channel-gap.json` shows the same shape: 2,358 of about 12,600 recorded events.
- **Rate:** 6–10 frames a second for each busy session. Every frame is structured-cloned, dispatched, and committed into the renderer's runtime map.

## Evidence that nothing live needs it
All consumers were mapped (file:line in PR #1236's description):
- **Main never needs the renderer's copy.** Prompt delivery, paste confirmation and readiness read the screen in main through `session.snapshotScreen()`. The raw snapshot cache (`SessionManager.lastScreenSnapshot`) and the spinner gate are untouched.
- **The composer picker is owned by conditions.** It comes from the `claude.slash-picker` condition via `applyConditionSnapshot`. The screen handler's picker write was a second writer (split authority).
- **Dead code:** `latestScreenRef` (the Enter baseline) and the renderer `claudePaste` helpers have no callers.
- **ReaderView** has no screen fallback since #855.
- **What remains is debug-only:** DebugPanel, three dev modules, debug bundles (including the 60 s autosave), and the screen-tail trace.

## Change
1. **Main, forwarder gate.** `session:screen` is forwarded only for a session that some renderer holds a lease on (`sessions/screenInterest.ts`). Every frame still feeds a bounded screen-tail history in main (`shared/debug/screenTail.ts`), because debug bundles must not depend on a panel having been open.
2. **Lease contract:**
- `session:screen-lease` acquires a lease and seeds the current screen down the ordinary `session:screen` path, the same seed `session:recover` sends. An idle backend therefore still shows a correct panel.
- `session:screen-release` releases a lease.
- Leases are owned by the calling webContents and the document that took them (a per-load id minted in preload). The preload announces its document on every load, leasing or not; a new document (a reload), or destruction, drops all of that owner's earlier leases, because a dying renderer never runs its cleanup. Review amendment: this replaced dropping on `did-start-navigation`, which Chromium fires before the throttle where this app blocks every `will-navigate`, so a blocked link click used to drop live leases. A release the owner does not hold is ignored.
3. **Debug bundles** read `session:get-screen-debug`: main's latest raw snapshot plus the tail history.
4. **Renderer:**
- The screen handler applies only the screen strings.
- DebugPanel and the dev modules that read the screen hold a lease while open (`useScreenLease`).
- Dead code is deleted: `latestScreenRef`, `claudePaste.ts`, and `ComposerSubmitIo.getScreen`.

## Unchanged, deliberately
- Main's raw screen processing: the headless snapshot, condition parsers, prompt gate, the `lastScreenSnapshot` cache, and `ScreenFrameGate`.
- The phone feed (it taps main directly).
- Recover and resync seeds.

## Tests (fail-first where the behaviour changes)
- **Forwarder:** the recorded Claude 2.1.278 screen frame is not forwarded without a lease, is forwarded with one, and is recorded in the tail history either way. The first assertion is red on origin/main.
- **Leases:** the IPC seed on acquire; dropping when a new document leases and on destroy, but never on a navigation event; counting across owners.
- **Picker:** a screen frame carrying a picker no longer sets `runtime.picker`.
- **Mutations:** removing the gate, recording only leased frames, dropping on sub-frames, removing the seed, and restoring the picker write each fail a test.

## Risks
- **A missed release** costs bytes until that renderer reloads, never correctness.
- **Debug surfaces** show nothing new until their lease is acquired; the acquire seed covers the first frame.
- **The DevDebug copy payload** reads the renderer copy, which can be stale unless a leasing module is open. This is accepted for a copy-to-clipboard debug aid.
84 changes: 83 additions & 1 deletion src/main/ipc/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ vi.mock('electron', () => ({
},
},
ipcRenderer: {
invoke: (channel: string, ...args: unknown[]) => harness.handlers.get(channel)!({}, ...args),
// Asynchronous like the real one: the preload's load-time document
// announcement runs at import, before any handler is registered.
invoke: async (channel: string, ...args: unknown[]) => harness.handlers.get(channel)?.({}, ...args),
},
}))

Expand Down Expand Up @@ -149,3 +151,83 @@ describe('session input transcript observations', () => {
expect(append).toHaveBeenCalledTimes(3)
})
})

describe('screen leases (#762)', () => {
it('seeds the current screen on acquire, and drops a renderer\'s leases only when its document is replaced or it dies', async () => {
const { screenInterest } = await import('@main/sessions/screenInterest.js')
const manager = new EventEmitter()
Object.assign(manager, {
getScreenSnapshot: () => ({ plain: 'now', markdown: 'now', recent: 'now', recentMarkdown: 'now' }),
})
registerSessionIpc(manager as never, {} as never, new SessionFeedTap(manager as never))
harness.routed.mockClear()
const sender = Object.assign(new EventEmitter(), { id: 4242 })
const lease = harness.handlers.get('session:screen-lease')!

// An opening debug panel is right at once, even for an idle backend:
// the current screen goes down the ordinary session:screen path.
lease({ sender }, 'pane', 'doc-1')
expect(screenInterest.wants('pane')).toBe(true)
// The same aliased wire payload the recover seed sends (recent/markdown
// equal to plain/markdown are dropped on the wire, #746).
expect(harness.routed).toHaveBeenCalledWith('pane', 'session:screen', { sessionId: 'pane', plain: 'now', markdown: 'now' })

// Any navigation, including one the window blocks, leaves the page and
// its leases alone: only a new document's lease or death drops them.
sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
expect(screenInterest.wants('pane')).toBe(true)
// The release is the caller's own (another webContents cannot end it).
const release = harness.handlers.get('session:screen-release')!
release({ sender: { id: 1 } }, 'pane', 'doc-1')
expect(screenInterest.wants('pane')).toBe(true)
release({ sender }, 'pane', 'doc-1')
expect(screenInterest.wants('pane')).toBe(false)

// A real reload whose new page never opens a debug panel: its preload's
// load-time announcement alone retires the old page's leases, or the
// heaviest IPC stream would forward forever (steering q15).
const announce = harness.handlers.get('session:screen-document')!
lease({ sender }, 'old-page', 'doc-1')
announce({ sender }, 'doc-2')
expect(screenInterest.wants('old-page')).toBe(false)
// Re-announcing the live document (nothing reloaded) keeps its leases,
// and the dead page's late release cannot touch them.
lease({ sender }, 'pane', 'doc-2')
announce({ sender }, 'doc-2')
release({ sender }, 'pane', 'doc-1')
expect(screenInterest.wants('pane')).toBe(true)
sender.emit('destroyed')
expect(screenInterest.wants('pane')).toBe(false)
})

it('leases a session with no screen yet without sending an empty frame', async () => {
const manager = new EventEmitter()
Object.assign(manager, { getScreenSnapshot: () => null })
registerSessionIpc(manager as never, {} as never, new SessionFeedTap(manager as never))
harness.routed.mockClear()
harness.handlers.get('session:screen-lease')!({ sender: Object.assign(new EventEmitter(), { id: 77 }) }, 'fresh', 'doc')
expect(harness.routed).not.toHaveBeenCalledWith('fresh', 'session:screen', expect.anything())
})
})

describe('session:get-screen-debug (#762)', () => {
it('answers with main\'s latest raw screen and the recorded tail history', async () => {
const { screenTailHistory } = await import('@main/sessions/screenInterest.js')
const manager = new EventEmitter()
Object.assign(manager, {
getScreenSnapshot: () => ({ plain: 'latest', markdown: 'latest', recent: 'latest\nmore', recentMarkdown: 'latest\nmore' }),
})
registerSessionIpc(manager as never, {} as never, new SessionFeedTap(manager as never))
screenTailHistory.record('debug-pane', 'first frame')
screenTailHistory.record('debug-pane', 'second frame')
try {
const answer = await harness.handlers.get('session:get-screen-debug')!({}, 'debug-pane') as {
screen: { recent: string } | null; samples: Array<{ content: string }>
}
expect(answer.screen?.recent).toBe('latest\nmore')
expect(answer.samples.map(sample => sample.content)).toEqual(['first frame', 'second frame'])
} finally {
screenTailHistory.forget('debug-pane')
}
})
})
71 changes: 50 additions & 21 deletions src/main/ipc/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import {
windowIdFor,
} from '@main/window/windowRegistry.js'
import type { SessionWindowLease } from '@main/window/sessionWindowRouter.js'
import { screenInterest, screenTailHistory } from '@main/sessions/screenInterest.js'
import type { AgentScreenSnapshot } from '@shared/types/session.js'
import type { ScreenTailSample } from '@shared/debug/screenTail.js'

// One secret per app process is enough for correlation inside that run's
// incident journal, and unlike a bare SHA-256 it prevents an exported bundle
Expand Down Expand Up @@ -249,11 +252,56 @@ export function registerSessionIpc(
manager.detachAgentPty(sessionId)
})

// #762. Live `session:screen` frames are forwarded only while a renderer
// holds a lease for that session (see sessions/screenInterest.ts for why
// nothing else needs them). The acquire also pushes the current screen down
// the ordinary `session:screen` path, the same seed `session:recover` sends,
// so an opening debug panel is correct at once even for an idle backend that
// emits no further frame, and the renderer needs no second way to apply a
// screen. Leases are owned by the calling webContents and document (see
// sessions/screenInterest.ts for why not a navigation event), and dropped
// when the webContents is destroyed, because a renderer that dies never
// runs its cleanup.
const leaseOwnersWatched = new Set<number>()
const watchLeaseOwner = (sender: Electron.WebContents): number => {
const owner = sender.id
if (!leaseOwnersWatched.has(owner)) {
leaseOwnersWatched.add(owner)
sender.once('destroyed', () => {
screenInterest.dropOwner(owner)
leaseOwnersWatched.delete(owner)
})
}
return owner
}
// Sent by the preload on every page load, whether or not the page ever
// leases: it is what retires a reloaded page's leases (steering q15).
ipcMain.handle('session:screen-document', (evt, document: string): void => {
screenInterest.enterDocument(watchLeaseOwner(evt.sender), document)
})
ipcMain.handle('session:screen-lease', (evt, sessionId: string, document: string): void => {
screenInterest.acquire(watchLeaseOwner(evt.sender), sessionId, document)
const screen = manager.getScreenSnapshot(sessionId)
if (screen) sendToSessionWindow(sessionId, 'session:screen', aliasScreenSnapshotForWire({ sessionId, ...screen }))
})
ipcMain.handle('session:screen-release', (evt, sessionId: string, document: string) => {
screenInterest.release(evt.sender.id, sessionId, document)
})
// Debug bundles read the screen on demand instead of holding a lease: the
// latest raw snapshot plus the tail history main records from every frame.
ipcMain.handle('session:get-screen-debug', (_evt, sessionId: string): {
screen: AgentScreenSnapshot | null
samples: ScreenTailSample[]
} => ({
screen: manager.getScreenSnapshot(sessionId),
samples: screenTailHistory.samples(sessionId),
}))

ipcMain.handle(
'session:input',
(_evt, sessionId: string, data: string, pasteId?: string) => {
// Optional pasteId journals THIS write into the per-paste debug
// dump. Only set by the Agent Code paste flow (claudePaste.ts) —
// dump. Only set by the composer's paste flow (useComposerKeybinds) —
// never set on keystrokes, agent-pty bridging, or other normal
// I/O. Pairs against the renderer's IPC:write:* events by sha8
// + byte count, same way dictation pairs renderer-produced
Expand All @@ -277,7 +325,7 @@ export function registerSessionIpc(
// between and reports the wrong cause — the same misdiagnosis this
// replaces, just narrower.
const deliveryInFlight = manager.isDeliveryInFlight(sessionId)
// `pasteId` is set only by the Agent Code paste flow (claudePaste.ts) and
// `pasteId` is set only by the composer's paste flow (useComposerKeybinds) and
// never by keystrokes, so it is also the exact renderer-side attribution
// signal required by SessionManager's prompt-delivery ownership fence.
const attributedPasteId = typeof pasteId === 'string' && pasteId.length > 0
Expand Down Expand Up @@ -398,25 +446,6 @@ export function registerSessionIpc(
},
)

// Event-driven paste-submit (Track C of the paste-submit harness PR).
// Renderer's claudePaste.ts invokes this AFTER writing the bracketed
// paste payload but BEFORE writing `\r`. We resolve as soon as
// Claude's TUI renders `[Pasted text #N]`, falling back to a 2 s
// timeout if the placeholder never appears (future Claude UI rename
// insurance). See `claudePaste.ts` and
// `packages/claude-code-headless/src/ClaudeCodeHeadless.ts:awaitPastePlaceholder`
// for the full rationale chain.
ipcMain.handle(
'claude:await-paste-placeholder',
async (
_evt,
sessionId: string,
opts?: { timeoutMs?: number; pollIntervalMs?: number },
) => {
return manager.awaitClaudePastePlaceholder(sessionId, opts)
},
)

ipcMain.handle(
'session:load-older-history',
async (
Expand Down
42 changes: 1 addition & 41 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,7 @@ export interface SessionManager {
// for the migration rationale.
//
// Local aliases used below just narrow AgentSession for the
// duck-typed-optional-method call sites (awaitPastePlaceholder,
// awaitReadyForPrompt), so the callers keep reading naturally.
// duck-typed-optional-method call sites (awaitReadyForPrompt), so the callers keep reading naturally.
type AgentSessionLike = AgentSession

type RegistryLifecycle = {
Expand Down Expand Up @@ -4390,45 +4389,6 @@ export class SessionManager extends EventEmitter {
}
}

/**
* Claude-specific accessor for the paste-submit event-driven path
* in `src/renderer/.../claudePaste.ts`. Returns the live ClaudeSession
* cast through `unknown` because AgentSessionLike doesn't (and
* shouldn't) expose `awaitPastePlaceholder` — that's a Claude-only
* affordance and adding it to the cross-provider interface would
* force every other runtime to ship a no-op stub.
*
* Returns `null` for missing sessions or non-Claude kinds. Callers
* MUST treat null as a benign "couldn't reach this session" and
* fall through to whatever non-event-driven path they were using
* before; the absence of a Claude session is not an error worth
* crashing over.
*/
async awaitClaudePastePlaceholder(
sessionId: string,
opts?: { timeoutMs?: number; pollIntervalMs?: number },
): Promise<
| { kind: 'appeared'; waitedMs: number }
| { kind: 'timeout' }
| { kind: 'no-headless' }
| { kind: 'no-session' }
> {
const entry = this.sessions.get(sessionId)
if (!entry || entry.kind !== 'claude') return { kind: 'no-session' }
// The cross-provider AgentSessionLike interface doesn't carry
// `awaitPastePlaceholder`; ClaudeSession does. We assert via a
// structural duck-type so misconfigured Claude provider builds
// (a future ClaudeSession that loses the method) surface as
// 'no-session' rather than a TypeError.
// No cast: awaitPastePlaceholder is now a typed optional on
// AgentSession (see @shared/types/session.ts).
const session = entry.session
if (typeof session.awaitPastePlaceholder !== 'function') {
return { kind: 'no-session' }
}
return session.awaitPastePlaceholder(opts)
}

/**
* Ask the provider to scroll its own transcript view to the newest message
* (#843). Only providers whose TUI pages its transcript on the alternate
Expand Down
53 changes: 53 additions & 0 deletions src/main/sessions/forwarder.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events'
import { readFileSync } from 'node:fs'
import { expect, it, vi } from 'vitest'
import type { SessionManager } from '@main/sessionManager.js'
import type { LspManager } from '@main/lspManager.js'
Expand All @@ -11,6 +12,7 @@ vi.mock('@main/window/windowRegistry.js', () => ({
vi.mock('@main/subagents/index.js', () => ({ SubAgentWatcherManager: class { observeParentEntry() {} stop() {} stopAll() {} } }))

import { wireSessionForwarder } from './forwarder.js'
import { screenInterest, screenTailHistory } from './screenInterest.js'
import { SessionFeedSource } from '@main/remote/SessionFeedSource.js'
import { SessionFeedTap } from './sessionFeedTap.js'

Expand Down Expand Up @@ -101,3 +103,54 @@ it('broadcasts a managed-skill warning to every window, carrying domain names on
manager.removeAllListeners()
}
})

it('forwards screen frames only to a leased session, and records every frame for debug bundles (#762)', () => {
// session:screen was 93% of recorded IPC bytes and nothing live reads it.
// An unleased session's frames stay in main; the tail history still sees
// them, because a debug bundle must not depend on a panel having been open.
const manager = new EventEmitter() as SessionManager & EventEmitter
manager.list = () => []
const sent: string[] = []
wire.receive = (channel, payload) => {
if (channel === 'session:screen') sent.push((payload as { plain: string }).plain)
}
const forwarder = wireSessionForwarder(manager, new EventEmitter() as LspManager)
// A real frame: the recorded Claude Code 2.1.278 screen from the #1113
// capture (idle, then with a composer draft). What the gate forwards is
// exactly what the provider painted.
const recorded = JSON.parse(readFileSync(
new URL('../../../testing/fixtures/image-absorption/wrapped-image-pill-2026-09-21.json', import.meta.url), 'utf8',
)) as { deliveries: { wrapped: { baseline: { screen: string }; after: { screen: string } } } }
const idle = recorded.deliveries.wrapped.baseline.screen
const drafted = recorded.deliveries.wrapped.after.screen
// `recent` is the wider window (Codex scrollback) and is what the history
// must record, so it differs from `plain` here to pin that (#1236 review C).
const frame = (screen: string) => ({ sessionId: 'pane', plain: screen.slice(-200), markdown: screen, recent: screen, recentMarkdown: screen })
try {
manager.emit('screen', frame(idle))
forwarder.flush()
expect(sent).toEqual([])

screenInterest.acquire(7, 'pane', 'doc')
manager.emit('screen', frame(drafted))
forwarder.flush()
expect(sent).toEqual([drafted.slice(-200)])

screenInterest.release(7, 'pane', 'doc')
manager.emit('screen', frame(idle))
forwarder.flush()
expect(sent).toEqual([drafted.slice(-200)])

// Every frame reached the tail history, forwarded or not (idle, drafted,
// idle again: the dedupe only collapses consecutive repeats).
expect(screenTailHistory.samples('pane')).toHaveLength(3)
expect(screenTailHistory.samples('pane')[1]!.content.length).toBeGreaterThan(drafted.slice(-200).trimEnd().length)
manager.emit('removed', { sessionId: 'pane' })
forwarder.flush()
expect(screenTailHistory.samples('pane')).toEqual([])
} finally {
screenInterest.dropOwner(7)
wire.receive = () => {}
manager.removeAllListeners()
}
})
Loading
Loading