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

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
DictationDebugJournalRegistry,
pruneOldDictationDebugLogs,
} from '@main/dictationJournal.js'
import {
PasteDebugJournalRegistry,
pruneOldPasteDebugLogs,
} from '@main/pasteDebugJournal.js'
import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js'
import { reconcile, type PersistedTerminalRef } from '@main/tmux/tmuxRecovery.js'

Expand Down Expand Up @@ -64,6 +68,12 @@ const ghostJournals = new GhostJournalRegistry()
// rationale for cloning the ghost-journal pattern instead of refactoring
// them into a single shared writer.
const dictationDebugJournals = new DictationDebugJournalRegistry()
// Per-paste debug-dump registry. Same lifecycle as dictationDebugJournals:
// constructed before IPC handlers register, flushed on before-quit,
// pruned on startup. Diagnostic for the "first Enter does nothing"
// paste-submit bug; see docs/superpowers/plans/2026-05-11-paste-submit-
// harness-findings-and-fix.md for context.
const pasteDebugJournals = new PasteDebugJournalRegistry()
const worktreeActivityIndex = new WorktreeActivityIndex()

// SessionManager is constructed inside whenReady so we can await
Expand Down Expand Up @@ -103,6 +113,9 @@ async function startApp(): Promise<void> {
// Dictation debug logs grow per-press. The pruner trims files older
// than 14 days at startup; fire-and-forget — a slow or failing
// prune must NOT delay window creation. See dictationJournal.ts.
void pruneOldPasteDebugLogs().catch(err => {
console.warn('[paste-debug] prune failed (non-fatal):', err)
})
void pruneOldDictationDebugLogs().catch(err => {
console.warn('[dictation] prune failed (non-fatal):', err)
})
Expand Down Expand Up @@ -187,7 +200,7 @@ async function startApp(): Promise<void> {
performanceService.mark('app.main.sessionManager.created')

wireSessionForwarder(manager, lspManager)
registerAllIpc({ manager, lspManager, ghostJournals, dictationDebugJournals, worktreeActivityIndex })
registerAllIpc({ manager, lspManager, ghostJournals, dictationDebugJournals, pasteDebugJournals, worktreeActivityIndex })
performanceService.mark('app.main.ipc.registered')
createMainWindow()
performanceService.mark('app.main.window.created')
Expand Down Expand Up @@ -219,5 +232,6 @@ app.on('before-quit', () => {
// dictation journal is idle at quit unless the user is pressing Fn
// at the exact moment of app shutdown.
void dictationDebugJournals.flushAll()
void pasteDebugJournals.flushAll()
performanceService.stop()
})
6 changes: 5 additions & 1 deletion src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { SessionManager } from '@main/sessionManager.js'
import type { LspManager } from '@main/lspManager.js'
import type { GhostJournalRegistry } from '@main/ghostJournal.js'
import type { DictationDebugJournalRegistry } from '@main/dictationJournal.js'
import type { PasteDebugJournalRegistry } from '@main/pasteDebugJournal.js'

import { registerSessionIpc } from '@main/ipc/session.js'
import { registerProviderIpc } from '@main/ipc/provider.js'
Expand All @@ -16,6 +17,7 @@ import { registerPerformanceIpc } from '@main/ipc/performance.js'
import { registerSetupIpc } from '@main/ipc/setup.js'
import { registerWorktreeActivityIpc } from '@main/ipc/worktreeActivity.js'
import { registerDictationIpc } from '@main/ipc/dictation.js'
import { registerPasteDebugIpc } from '@main/ipc/pasteDebug.js'
import { installPerformanceIpcInstrumentation } from '@main/performance/instrumentIpc.js'
import type { WorktreeActivityIndex } from '@main/worktreeActivity/WorktreeActivityIndex.js'

Expand All @@ -33,13 +35,14 @@ export type IpcDeps = {
lspManager: LspManager
ghostJournals: GhostJournalRegistry
dictationDebugJournals: DictationDebugJournalRegistry
pasteDebugJournals: PasteDebugJournalRegistry
worktreeActivityIndex: WorktreeActivityIndex
}

export function registerAllIpc(deps: IpcDeps): void {
registerPerformanceIpc(deps.manager)
installPerformanceIpcInstrumentation()
registerSessionIpc(deps.manager)
registerSessionIpc(deps.manager, deps.pasteDebugJournals)
registerProviderIpc()
registerLspIpc(deps.lspManager)
registerFsIpc()
Expand All @@ -51,4 +54,5 @@ export function registerAllIpc(deps: IpcDeps): void {
registerWorktreeActivityIpc(deps.worktreeActivityIndex)
registerSetupIpc()
registerDictationIpc({ dictationDebugJournals: deps.dictationDebugJournals })
registerPasteDebugIpc({ pasteDebugJournals: deps.pasteDebugJournals })
}
27 changes: 27 additions & 0 deletions src/main/ipc/pasteDebug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Per-paste debug-event IPC handler.
//
// Mirrors the dictation:debug-event channel handler in PR #68. Uses
// `ipcMain.on` (not `handle`) because the renderer side is
// fire-and-forget; we don't want every keydown to pay the
// promise round-trip cost. Bad payloads are dropped silently — the
// journal should never be a way to crash the main process.

import { ipcMain } from 'electron'

import type { PasteDebugJournalRegistry } from '@main/pasteDebugJournal.js'
import type { PasteDebugEventInput } from '@preload/api/types.js'

export function registerPasteDebugIpc(deps: {
pasteDebugJournals: PasteDebugJournalRegistry
}): void {
ipcMain.on(
'paste:debug-event',
(_evt, pasteId: unknown, input: unknown) => {
if (typeof pasteId !== 'string' || !pasteId) return
if (!input || typeof input !== 'object') return
const payload = input as PasteDebugEventInput
if (typeof payload.layer !== 'string' || typeof payload.event !== 'string') return
deps.pasteDebugJournals.get(pasteId).append(payload)
},
)
}
55 changes: 53 additions & 2 deletions src/main/ipc/session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ipcMain } from 'electron'
import { createHash } from 'node:crypto'

import type { SessionManager } from '@main/sessionManager.js'
import type { PasteDebugJournalRegistry } from '@main/pasteDebugJournal.js'
import { getMainProvider } from '@providers/registry.main.js'
import { listAllClaudeSessions } from '@providers/claude/runtime/sessionList.js'
import { listCodexSessions } from '@providers/codex/runtime/sessionList.js'
Expand All @@ -23,7 +25,10 @@ import { resolveTranscriptPaths } from '@main/sessions/transcriptPaths.js'
// The prompt-indexing handlers (sessions:*) live in ./sessions.ts
// because they're a separate concern with their own cache layer.

export function registerSessionIpc(manager: SessionManager): void {
export function registerSessionIpc(
manager: SessionManager,
pasteDebugJournals: PasteDebugJournalRegistry,
): void {
ipcMain.handle(
'session:spawn',
async (
Expand Down Expand Up @@ -71,14 +76,41 @@ export function registerSessionIpc(manager: SessionManager): void {

ipcMain.handle(
'session:input',
(_evt, sessionId: string, data: string) => {
(_evt, sessionId: string, data: string, pasteId?: string) => {
// Optional pasteId journals THIS write into the per-paste debug
// dump. Only set by the cc-shell paste flow (claudePaste.ts) —
// 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
// against main-received chunks (PR #68).
if (typeof pasteId === 'string' && pasteId.length > 0) {
const bytes = Buffer.byteLength(data, 'utf8')
const sha8 = createHash('sha256').update(data).digest('hex').slice(0, 8)
// Head preview is escape-safe: replace ESC with `\e` and CR
// with `\r` so the JSONL line is readable when you cat the
// file. The raw bytes are never logged — sha8 is the
// correlation primitive.
const head = data.slice(0, 40).replace(/\x1b/g, '\\e').replace(/\r/g, '\\r')
pasteDebugJournals.get(pasteId).append({
layer: 'PTY',
event: 'main:write',
data: { sessionId, bytes, sha8, head },
})
}
const ok = manager.write(sessionId, data)
if (!ok) {
// eslint-disable-next-line no-console
console.warn('[session:input] dropped write for missing session', {
sessionId,
dataLength: data.length,
})
if (typeof pasteId === 'string' && pasteId.length > 0) {
pasteDebugJournals.get(pasteId).append({
layer: 'ERROR',
event: 'main:write-dropped-no-session',
data: { sessionId },
})
}
}
return ok
},
Expand All @@ -91,6 +123,25 @@ export function registerSessionIpc(manager: SessionManager): void {
},
)

// 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)
},
)

// Session listing for the resume picker.
//
// Called by PathPickerModal when the user types a cwd — returns a
Expand Down
165 changes: 165 additions & 0 deletions src/main/pasteDebugJournal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Per-paste debug-event writer.
//
// Mirror of `src/main/dictationJournal.ts` from PR #68. Same 100 ms
// drain cadence, same mkdir-on-first-write trick, same "process owns
// the writer until quit, flushAll on before-quit" lifecycle. The two
// files are deliberately near-duplicates; a shared
// `BatchedJsonlWriter` is YAGNI until a third caller shows up.
//
// One file per paste-press:
// <userData>/paste-debug/<pasteId>.paste.jsonl
//
// `pasteId` is a renderer-minted UUID stamped at the moment Enter is
// observed in the composer keydown handler. Keying the file on it
// rather than on, say, the sessionId guarantees that every paste —
// including ones that never reach the PTY — has its own file. That
// matters for the "first Enter does nothing" bug we are chasing: a
// dropped paste won't share a file with the eventual successful one
// after the user presses Enter again.
//
// Privacy contract:
// * file mode 0o600, dir mode 0o700
// * never log raw PTY bytes — callers send sha8 + byte count
// * composer text head (truncated 240 chars) IS logged; the whole
// point is to see what reached Claude vs. what the user typed
// * file is local user-private; no network surface
//
// Disk-pressure contract:
// * `pruneOldPasteDebugLogs()` runs at startup; 14-day retention
// * a file per paste × hundreds of pastes/day adds up; without
// pruning we'd grow forever

import { appendFile, mkdir, readdir, stat, unlink } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { app } from 'electron'

import type { PasteDebugEvent, PasteDebugEventInput } from '@preload/api/types.js'

const FLUSH_INTERVAL_MS = 100

const PRUNE_AFTER_MS = 14 * 24 * 60 * 60 * 1000

export class PasteDebugJournal {
private queue: string[] = []
private timer: NodeJS.Timeout | null = null
private ensuredDir = false
private draining = false
private sessionStartedAtMs: number | null = null

constructor(private readonly filePath: string) {}

append(input: PasteDebugEventInput): void {
const now = Date.now()
if (this.sessionStartedAtMs === null) this.sessionStartedAtMs = now
const event: PasteDebugEvent = {
ts: now,
tMs: now - this.sessionStartedAtMs,
...input,
}
this.queue.push(JSON.stringify(event) + '\n')
this.scheduleDrain()
}

async flush(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
await this.drain()
}

private scheduleDrain(): void {
if (this.timer) return
this.timer = setTimeout(() => {
this.timer = null
void this.drain()
}, FLUSH_INTERVAL_MS)
}

private async drain(): Promise<void> {
if (this.draining) return
if (this.queue.length === 0) return
this.draining = true
try {
const batch = this.queue.splice(0).join('')
await this.appendRaw(batch)
} finally {
this.draining = false
}
if (this.queue.length > 0 && !this.timer) this.scheduleDrain()
}

private async appendRaw(content: string): Promise<void> {
try {
await appendFile(this.filePath, content, { mode: 0o600 })
} catch {
if (!this.ensuredDir) {
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 })
this.ensuredDir = true
await appendFile(this.filePath, content, { mode: 0o600 })
} else {
throw new Error(`paste-debug append failed for ${this.filePath}`)
}
}
}
}

export class PasteDebugJournalRegistry {
private journals = new Map<string, PasteDebugJournal>()

get(pasteId: string): PasteDebugJournal {
let j = this.journals.get(pasteId)
if (!j) {
j = new PasteDebugJournal(pasteDebugLogPath(pasteId))
this.journals.set(pasteId, j)
}
return j
}

async flushAll(): Promise<void> {
const drains = [...this.journals.values()].map(j =>
j.flush().catch(err => {
console.warn('[pasteDebugJournal] flush error:', err)
}),
)
await Promise.all(drains)
}

dispose(pasteId: string): void {
const j = this.journals.get(pasteId)
if (!j) return
void j.flush().catch(err => {
console.warn('[pasteDebugJournal] dispose flush error:', err)
})
this.journals.delete(pasteId)
}
}

export function pasteDebugLogPath(pasteId: string): string {
return join(
app.getPath('userData'),
'paste-debug',
`${pasteId}.paste.jsonl`,
)
}

export async function pruneOldPasteDebugLogs(): Promise<void> {
const dir = join(app.getPath('userData'), 'paste-debug')
let entries: string[]
try {
entries = await readdir(dir)
} catch {
return
}
const cutoff = Date.now() - PRUNE_AFTER_MS
for (const name of entries) {
if (!name.endsWith('.paste.jsonl')) continue
const full = join(dir, name)
try {
const s = await stat(full)
if (s.mtimeMs < cutoff) await unlink(full)
} catch {
// ignore
}
}
}
Loading