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
46 changes: 46 additions & 0 deletions docs/rendering/session-recording-plan-2026-07.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,52 @@ regress.** No more "save a bundle and hope we can reconstruct the tick."

---

## 0b. As-built reconciliation (2026-07-07, post-audit)

The sections below are the ORIGINAL design. Two intentional deviations and
one honest limitation were found by a plan-vs-code audit after the feature
merged (#471, #472) and the on-demand fix (#473). Recorded here so the plan
never silently drifts from the code:

- **Record shape is a FOLDER per recording, not one flat `<sid>/<ts>.jsonl`
(supersedes §3's file layout).** `session-recordings/<recordingId>/` holds
`meta.json` + `events.jsonl`. This was a deliberate change at the user's
request: one `rm -rf` deletes exactly one recording. `meta.json` carries the
full header (`kind:'session-recording'`, `redaction:'none'`, provider,
providerSessionId, cwd, appVersion, start/end, counters) — so §3's "header
line" requirement is met by `meta.json`, not a line 0 in the event stream.
provider/cwd are filled from `session:started`; providerSessionId stays
best-effort (it is provisional and upgraded later, §2).

- **Recording is COMMAND-DRIVEN, not auto (this is §7 as written; the first
build wrongly shipped auto-only).** Nothing records until the **Toggle
Session Recording** command starts a specific pane. `AGENT_CODE_SESSION_
RECORD=1` is the OPTIONAL auto-start-everything power flag for unattended
soak, OFF by default. Fixed in #473.

- **Replay is REDUCER-FAITHFUL, not full-React-fold (a real limitation of
§1/§6, not yet closed).** `replayRecording` drives the real leaf reducers
(`foldSemanticEvent`, `reduceStreamPhase`, ghost reducers, provider
mappers) via `reconstructSlices`, but NOT the React fold hook
(`useIpcSubscriptions`) — that hook only runs under the happy-dom `renderer`
vitest project, and the replay/invariant tests run under the node `unit`
project. So the harness catches leaf-reducer + adapter + ledger bugs, but
NOT the fold-glue bug class (queue-op reconstruction #469, provider-id
quarantine, optimistic reconciliation, orphan sweep) that §1 cited as the
reason to tap the SessionFeed boundary. The RECORDING captures everything
needed to replay those (the 9 channels are the fold's inputs); only the
REPLAY is currently blind to them. Closing this needs the fold extracted
from the React hook into a pure reducer the replay can drive — tracked as a
follow-up, naturally converging with the #394 fold rewrite. Until then the
harness is honestly labeled reducer-only in `recordedSession.ts`.

Smaller open follow-ups (non-blocking): a dedicated keybinding for the note
command (§7b); finalize on `SessionManager 'removed'` in addition to the
`session:exit` observer (§2); `shapePathsOf` reuse in redaction envelope
metadata (§5).

---

## 1. The core decision — record at the SessionFeed boundary, replay through the real fold

The single most important design choice, and the one the four sweeps
Expand Down
9 changes: 7 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { createMainWindow, focusMainWindow, sendToMainWindow } from '@main/windo
import { wireSessionForwarder } from '@main/sessions/forwarder.js'
import { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js'
import { setOutboundObserver } from '@main/window/mainWindow.js'
import { isSessionRecordingEnabled } from '@main/ipc/devDebug.js'
import { isSessionRecordingEnabled, isSessionRecordingAutoStart } from '@main/ipc/devDebug.js'
import { registerAllIpc } from '@main/ipc/index.js'
import { cleanupDictationIpcResources } from '@main/ipc/dictation.js'
import { performanceService } from '@main/performance/PerformanceService.js'
Expand Down Expand Up @@ -88,7 +88,12 @@ const ghostJournals = new GhostJournalRegistry()
// AGENT_CODE_DEV_DEBUG=1 AND AGENT_CODE_SESSION_RECORD=1, so a normal build
// pays nothing (no observer installed, sendToMainWindow's hook stays null).
// plan: docs/rendering/session-recording-plan-2026-07.md (#467).
const sessionRecorders = isSessionRecordingEnabled() ? new SessionRecorderManager() : null
// Construct the recorder manager whenever the CAPABILITY is on (dev-debug),
// so the Start Recording command works. autoRecord (the env flag) stays OFF
// by default — nothing records until the command starts a session.
const sessionRecorders = isSessionRecordingEnabled()
? new SessionRecorderManager(undefined, undefined, isSessionRecordingAutoStart())
: null
if (sessionRecorders) setOutboundObserver(sessionRecorders.observe)
// Per-dictation-session debug-dump registry. Mirrors `ghostJournals`:
// constructed before IPC handlers register, flushed on before-quit. See
Expand Down
36 changes: 36 additions & 0 deletions src/main/ipc/devDebug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,20 @@ function isDevDebugEnabled(): boolean {

/** Session recording is doubly gated: it is a diagnostic (dev-debug) AND
* opt-in (its own flag), because it records full session input. */
// Recording CAPABILITY: the Start/Stop/Attach-Note commands and IPC are
// available whenever dev-debug is on. This does NOT record anything by
// itself — recording is command-driven per session (plan §7). Kept named
// isSessionRecordingEnabled for the DevDebugConfig field the renderer reads
// to decide whether to SHOW the commands.
export function isSessionRecordingEnabled(): boolean {
return isDevDebugEnabled()
}

// AUTO-START power path (plan §7, OPTIONAL, OFF by default): only with the
// explicit env flag does the manager auto-record every session from launch —
// for unattended soak. The normal path records nothing until the Start
// Recording command fires, so a day of work never silently fills disk.
export function isSessionRecordingAutoStart(): boolean {
return isDevDebugEnabled() && envFlag('AGENT_CODE_SESSION_RECORD')
}

Expand Down Expand Up @@ -111,4 +124,27 @@ export function registerDevDebugIpc(sessionRecorders: SessionRecorderManager | n
sessionRecorders?.fillNote(sessionId, noteId, text)
},
)
// Start/stop a single session's recording on demand (plan §7). This is the
// PRIMARY control — recording is command-driven, not auto. Returns whether
// the session is recording after the call so the renderer can label the
// toggle correctly.
ipcMain.handle(
'record-session:start',
(_evt, sessionId: string, provider?: string): boolean => {
if (!isSessionRecordingEnabled() || !sessionRecorders) return false
// The command knows the pane's provider (workspace meta.kind); pass it
// so a mid-session start still records the right provider (the
// session:started event that carries it has usually already fired).
sessionRecorders.startRecording(sessionId, provider ? { kind: provider } : undefined)
return true
},
)
ipcMain.handle('record-session:stop', (_evt, sessionId: string): Promise<boolean> | boolean => {
if (!isSessionRecordingEnabled() || !sessionRecorders) return false
return sessionRecorders.stopRecording(sessionId).then(() => false)
})
ipcMain.handle('record-session:is-recording', (_evt, sessionId: string): boolean => {
if (!isSessionRecordingEnabled() || !sessionRecorders) return false
return sessionRecorders.isRecording(sessionId)
})
}
45 changes: 43 additions & 2 deletions src/main/recording/SessionRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const MAX_BYTES = 128 * 1024 * 1024

export type RecordingMeta = {
v: 1
/** Discriminator so a bare meta.json is self-identifying (plan §3). */
kind: 'session-recording'
/** Redaction state of THIS on-disk recording. Live local recordings are
* unredacted ('none'); the extraction script writes 'full-text-capped' /
* 'structure-only' into the derived fixture's meta (plan §3/§5). */
redaction: 'none'
recordingId: string
sessionId: string
provider: string
Expand Down Expand Up @@ -133,11 +139,22 @@ export class SessionRecorder {
this.queue.push(line)
if (this.queue.length > MAX_QUEUE) {
// Drop-oldest: shed the front so the heap can't grow unbounded when
// the disk can't keep up. Count it; the count lands in meta.json and
// as a tombstone so a replay knows the recording has a gap.
// the disk can't keep up. Count it (→ meta.json droppedCount) AND drop
// a __truncated marker at the gap position so a replay sees exactly
// where the stream has a hole (plan §4 — the counter must be visible in
// the file, not only in meta).
const shed = this.queue.length - MAX_QUEUE
this.queue.splice(0, shed)
this.dropped += shed
this.queue.unshift(
JSON.stringify({
t: Math.round(this.nowMono() - this.startMono),
wall: this.nowWall(),
ch: '__truncated',
reason: 'queue-drop',
dropped: shed,
}) + '\n',
)
}
this.scheduleDrain()
}
Expand Down Expand Up @@ -210,6 +227,30 @@ export class SessionRecorder {
}
}

/**
* Fill identity fields from a later event (plan §3/§2: providerSessionId /
* cwd / provider belong in the header, but a command-started recording
* begins before session:started is seen). Only fills EMPTY/unknown fields —
* never overwrites a known value — and rewrites meta.json when something
* changed. Cheap and idempotent.
*/
refreshIdentity(id: { provider?: string; providerSessionId?: string; cwd?: string }): void {
let changed = false
if (id.provider && (this.meta.provider === 'unknown' || !this.meta.provider)) {
this.meta = { ...this.meta, provider: id.provider }
changed = true
}
if (id.providerSessionId && !this.meta.providerSessionId) {
this.meta = { ...this.meta, providerSessionId: id.providerSessionId }
changed = true
}
if (id.cwd && !this.meta.cwd) {
this.meta = { ...this.meta, cwd: id.cwd }
changed = true
}
if (changed) void this.writeMeta()
}

/** Force a drain and wait — used on shutdown and by tests. */
async flush(): Promise<void> {
if (this.timer) {
Expand Down
44 changes: 38 additions & 6 deletions src/main/recording/SessionRecorderManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ afterEach(() => {
describe('SessionRecorderManager', () => {
it('records only allowlisted channels, keyed by sessionId, into a per-recording folder', async () => {
const m = mgr()
m.startRecording('s1') // command-driven: record only after explicit start
// Non-allowlisted channels and PTY channels are ignored.
m.observe('lsp:diagnostics', [{ sessionId: 's1' }])
m.observe('session:terminal-data', [{ sessionId: 's1', data: 'raw pty' }])
Expand All @@ -73,10 +74,10 @@ describe('SessionRecorderManager', () => {

const lines = readFileSync(join(dir, 'events.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l))
expect(lines.map(l => l.ch)).toEqual(['session:semantic-event', 'session:jsonl-entries'])
// t is relative to the FIRST recorded event (recording start): the
// first line is t=0, the second is 25-10=15ms later.
expect(lines[0].t).toBe(0)
expect(lines[1].t).toBe(15)
// t is relative to recording START (explicit startRecording at mono=0);
// the two events at mono 10 and 25 are t=10 and t=25.
expect(lines[0].t).toBe(10)
expect(lines[1].t).toBe(25)
expect(lines[0].payload.event.kind).toBe('turn_started')
})

Expand All @@ -88,6 +89,7 @@ describe('SessionRecorderManager', () => {

it('finalizes a recording on session:exit', async () => {
const m = mgr()
m.startRecording('s2', { kind: 'claude' }) // command passes provider hint
m.observe('session:started', [{ sessionId: 's2', kind: 'claude' }])
expect(m.isRecording('s2')).toBe(true)
m.observe('session:exit', [{ sessionId: 's2', code: 0 }])
Expand All @@ -104,6 +106,7 @@ describe('SessionRecorderManager', () => {

it('captures reserve-then-fill notes as __note lines', async () => {
const m = mgr()
m.startRecording('s3')
m.observe('session:started', [{ sessionId: 's3', kind: 'codex' }])
mono = 5
const noteId = m.reserveNote('s3')
Expand Down Expand Up @@ -133,10 +136,39 @@ describe('SessionRecorderManager', () => {

it('flushAll finalizes every open recording', async () => {
const m = mgr()
m.observe('session:screen', [{ sessionId: 'a' }])
m.observe('session:screen', [{ sessionId: 'b' }])
m.startRecording('a')
m.startRecording('b')
await m.flushAll()
expect(m.isRecording('a')).toBe(false)
expect(m.isRecording('b')).toBe(false)
})

it('does NOT record until a session is explicitly started (default: autoRecord off)', async () => {
const m = mgr() // autoRecord defaults false
m.observe('session:started', [{ sessionId: 'nope', kind: 'claude' }])
m.observe('session:semantic-event', [{ sessionId: 'nope', event: {} }])
// No recorder was created, nothing on disk for this session.
expect(m.isRecording('nope')).toBe(false)
})

it('records a session once startRecording is called, and stops on stopRecording', async () => {
const m = mgr()
m.startRecording('go')
expect(m.isRecording('go')).toBe(true)
mono = 10
m.observe('session:semantic-event', [{ sessionId: 'go', event: { kind: 'turn_started' } }])
await m.stopRecording('go')
expect(m.isRecording('go')).toBe(false)
const dir = await readRecordingDir('go')
const lines = readFileSync(join(dir, 'events.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l))
expect(lines.some(l => l.ch === 'session:semantic-event')).toBe(true)
})

it('auto-records every session ONLY when autoRecord is on (the power flag)', async () => {
const m = new SessionRecorderManager(nowWall, nowMono, true)
m.observe('session:started', [{ sessionId: 'auto', kind: 'codex' }])
expect(m.isRecording('auto')).toBe(true)
await m.stopRecording('auto')
})

})
55 changes: 45 additions & 10 deletions src/main/recording/SessionRecorderManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,33 +54,62 @@ export class SessionRecorderManager {
// wall-clock-honest (Date.now for `wall`, a monotonic source for `t`).
private readonly nowWall: () => number = () => Date.now(),
private readonly nowMono: () => number = () => performance.now(),
// AUTO-RECORD is opt-in and OFF by default. Plan §7: recording is
// command-driven per session; a user does NOT want every session of a
// long day silently written to disk (that is how you bury yourself in
// tens of GB). Only when AGENT_CODE_SESSION_RECORD=1 does the manager
// auto-start every session — the unattended-soak power path, never the
// default. Without it, NOTHING records until `startRecording` is called
// by the Start Recording command.
private readonly autoRecord: boolean = false,
) {}

/** The outbound observer registered on sendToMainWindow. Cheap on every
* non-recorded channel (a Set lookup), so it is safe to leave installed;
* the manager is only CONSTRUCTED when recording is gated on. */
/** The outbound observer registered on sendToMainWindow. The critical
* gate: an event is only written if its session has been EXPLICITLY
* started (or auto-record is on). A session the user never chose to
* record produces nothing on disk — no burial. */
observe = (channel: string, args: readonly unknown[]): void => {
if (!RECORDED_CHANNELS.has(channel)) return
const payload = args[0]
const sessionId = extractSessionId(payload)
if (!sessionId) return
const recorder = this.ensure(sessionId, payload)

let recorder = this.recorders.get(sessionId)
if (!recorder) {
// Not recording this session. Auto-start it ONLY under the opt-in
// power flag; otherwise ignore the event entirely (the default —
// recording waits for the Start Recording command).
if (!this.autoRecord) return
recorder = this.startRecording(sessionId, payload)
}
recorder.record(channel, payload)
// session:exit is the renderer-facing end; finalize the recording so its
// meta.json gets end stats. (SessionManager 'removed' is the authoritative
// cleanup but is not on the outbound funnel; exit is close enough for the
// recording boundary, and flushAll on quit backstops any session that
// never emitted exit.)
// session:started carries the header identity (kind→provider, projectDir
// →cwd) a command-started recording didn't have yet — fill it in (plan
// §2/§3). providerSessionId is provisional (upgraded later) so it stays
// best-effort/null.
if (channel === 'session:started') {
const p = payload as { kind?: string; projectDir?: string }
recorder.refreshIdentity({ provider: p.kind, cwd: p.projectDir })
}
// session:exit finalizes the recording (end stats in meta.json).
if (channel === 'session:exit') void this.stop(sessionId)
}

private ensure(sessionId: string, firstPayload: unknown): SessionRecorder {
/**
* Begin recording ONE session on demand (the Start Recording command,
* plan §7). Idempotent — starting an already-recording session returns
* the live recorder. `firstPayload` is optional provider-hint metadata
* when the start is triggered by an incoming event (auto-record).
*/
startRecording(sessionId: string, firstPayload?: unknown): SessionRecorder {
let recorder = this.recorders.get(sessionId)
if (!recorder) {
const startedAtWall = this.nowWall()
recorder = new SessionRecorder(
{
v: 1,
kind: 'session-recording',
redaction: 'none',
// recordingId sorts chronologically and is filesystem-safe; the
// sessionId suffix keeps concurrent tiled sessions distinct and
// greppable. Colons stripped for Windows/paths.
Expand Down Expand Up @@ -126,6 +155,12 @@ export class SessionRecorderManager {
await recorder.close()
}

/** Public stop for the Stop Recording command (plan §7). Alias of stop;
* named to pair with startRecording at the call sites. */
stopRecording(sessionId: string): Promise<void> {
return this.stop(sessionId)
}

/** Drain + finalize every recording. Called on before-quit (mirrors
* ghostJournals.flushAll). */
async flushAll(): Promise<void> {
Expand Down
11 changes: 11 additions & 0 deletions src/preload/api/devDebug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,15 @@ export const devDebugApi = {
// Fill the previously reserved marker with the typed text on submit.
fillRecordingNote: (sessionId: string, noteId: string, text: string): Promise<void> =>
ipcRenderer.invoke('record-session:fill-note', sessionId, noteId, text),

// Start/stop recording ONE session on demand (plan §7 — the primary
// control; recording is command-driven, not auto). start returns true
// (recording), stop resolves false. isSessionRecording lets the command
// label itself Start vs Stop for the focused pane.
startSessionRecording: (sessionId: string, provider?: string): Promise<boolean> =>
ipcRenderer.invoke('record-session:start', sessionId, provider),
stopSessionRecording: (sessionId: string): Promise<boolean> =>
ipcRenderer.invoke('record-session:stop', sessionId),
isSessionRecording: (sessionId: string): Promise<boolean> =>
ipcRenderer.invoke('record-session:is-recording', sessionId),
}
Loading