From 74e9ae81322c73cdb12948b46cd9741efad802b7 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 11 May 2026 16:30:31 +0200 Subject: [PATCH 1/2] feat(paste): event-driven submit + per-paste debug dump + headless quirks docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles three coordinated changes addressing the long-standing "first Enter on a multi-line paste does nothing; second Enter submits" intermittent in Claude composers. Track A — documentation in claude-code-headless submodule: - Thick WHY comment above HeadlessTerminal.scheduleFlush explaining that the 'screen' event silently stops firing under Claude's synchronized-output protocol (pendingWrites stays > 0 with @xterm/headless under sync-out pressure). Consumers needing correctness should poll snapshotPlain() directly. - Stronger warning on the activity-event debouncer that the SPINNER_VERB_RE walk produces false negatives during tool-result rows and transient headers, and the 'activity' event must NOT be used as a binary "did this submit?" verdict. The paste-submit harness lost 2/10 iterations on a spurious spinner read before we figured this out. Track B — per-paste debug dump: Mirror of the dictation-debug subsystem from PR #68. Every Enter in a Claude composer mints a pasteId and emits RENDER/IPC/PTY/SCREEN/OUTCOME events into /paste-debug/.paste.jsonl. Renderer and main both write into the same file keyed by pasteId; payloads are sha8-fingerprinted so the IPC and PTY events can be paired across the renderer↔main boundary. 100 ms batched, 14-day prune. This is the diagnostic that will finally answer "why does the PTY-isolated harness pass 10/10 with the production 125 ms timer but the user still hits the bug live?" Hypotheses include IPC variance shrinking the effective gap, a double-fire on Enter where a second \r races the first paste's payload, or a React state mutation between paste and submit — the dump shape lets us pick the right one from a real failing session. Track C — event-driven paste-submit: Replaces the unconditional 125 ms wall-clock wait between bracketed paste and \r with a poll for Claude's `[Pasted text #N]` placeholder. The PTY-isolated harness shows event-driven is 10/10 reliable with an average wait of ~58 ms vs. the timer's 125 ms unconditional floor. Falls back to the timer after a 2 s safety bound if the placeholder never materializes (future-Claude rename insurance). Gated by settings.eventDrivenPasteSubmit, default ON. Setting also exposed in Settings → Workspace. Submodule bump: packages/claude-code-headless → feat/paste-submit-headless-fix e59235a — adds ClaudeCodeHeadless.awaitPastePlaceholder and the two doc comments above. Will need to be merged to submodule main before this cc-shell PR can merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...1-paste-submit-harness-findings-and-fix.md | 784 ++++++++++++++++++ packages/claude-code-headless | 2 +- src/main/index.ts | 16 +- src/main/ipc/index.ts | 6 +- src/main/ipc/pasteDebug.ts | 27 + src/main/ipc/session.ts | 55 +- src/main/pasteDebugJournal.ts | 165 ++++ src/main/sessionManager.ts | 45 + src/preload/api/index.ts | 2 + src/preload/api/pasteDebug.ts | 22 + src/preload/api/session.ts | 28 +- src/preload/api/types.ts | 45 + src/providers/claude/runtime/claudeSession.ts | 20 + .../src/app-state/settings/persistence.ts | 5 + src/renderer/src/app-state/settings/types.ts | 19 + .../features/settings/lib/settingsRegistry.ts | 23 + .../src/workspace/tile-tree/TileLeaf.tsx | 11 +- .../tile-tree/TileLeaf/claudePaste.ts | 172 +++- .../tile-tree/TileLeaf/useComposerKeybinds.ts | 96 ++- 19 files changed, 1512 insertions(+), 31 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-11-paste-submit-harness-findings-and-fix.md create mode 100644 src/main/ipc/pasteDebug.ts create mode 100644 src/main/pasteDebugJournal.ts create mode 100644 src/preload/api/pasteDebug.ts diff --git a/docs/superpowers/plans/2026-05-11-paste-submit-harness-findings-and-fix.md b/docs/superpowers/plans/2026-05-11-paste-submit-harness-findings-and-fix.md new file mode 100644 index 000000000..6ef419b16 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-paste-submit-harness-findings-and-fix.md @@ -0,0 +1,784 @@ +# Paste-Submit Harness Findings, Documentation Cleanups, and Event-Driven Fix + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land three things in one PR: +1. Capture the *non-obvious* insights from the `vendor/in_progress/paste-submit-repro/` investigation as in-tree comments — specifically the broken `'screen'` event emission in `HeadlessTerminal` and the unreliable spinner-based activity detector. +2. Add a per-paste **`paste-debug-dump`** subsystem in cc-shell (mirroring the dictation-debug pattern from PR #68) so we can finally see *what the renderer/IPC actually does* on a failing paste in production. The PTY-isolated harness shows the production 125 ms timer works at 10/10 — meaning the bug lives somewhere the harness doesn't model. +3. Ship the **event-driven paste-submit fix** as a strict improvement over the wall-clock 125 ms timer, behind a feature flag that defaults ON for new sessions. Even if it doesn't fully fix the user-facing bug (we won't know until #2's dumps arrive), it's faster on average AND can never race the way the timer can. + +**Architecture:** Three parallel tracks, no shared files, can be reviewed independently inside one PR: + +- **Track A (comments)** — `packages/claude-code-headless/src/terminal/HeadlessTerminal.ts` and `packages/claude-code-headless/src/ClaudeCodeHeadless.ts` get thick WHY comments documenting two production-hostile behaviors the harness exposed: `'screen'` event stalling under synchronized-output, and the spinner-regex activity detector misfiring during tool-result and transient-header rows. +- **Track B (debug dump)** — clone of the dictation-debug shape in PR #68: a `pasteDebugJournal` keyed on a renderer-minted UUID per paste-press, captures the full lifecycle from React keydown → `sendBracketedPasteThenSubmit` → IPC → main PTY write. Privacy contract is identical (no raw bytes, sha8 fingerprints for paste payload). +- **Track C (event-driven fix)** — replace the unconditional `await wait(125)` in `claudePaste.ts:43-55` with a polling waiter that resolves when `[Pasted text #N` appears in the live screen snapshot. Falls back to the existing timer when the placeholder doesn't materialize within a safety bound (the bound exists to insure us against future Claude UI changes; it should never fire in current Claude). + +**Tech Stack:** TypeScript across renderer/preload/main and the `claude-code-headless` workspace package. Electron IPC for the dump. Existing `node:fs/promises` `appendFile` for journal writes. + +--- + +## File Structure + +**Create:** +- `src/main/pasteDebugJournal.ts` — direct sibling of `dictationJournal.ts`, near-duplicate of its shape. One JSONL per paste at `/paste-debug/.paste.jsonl`. +- `src/preload/api/pasteDebug.ts` — single-method preload bridge `recordPasteDebugEvent(pasteId, layer, event, data)`. Same fire-and-forget `ipcRenderer.send` pattern as `dictationDebug.ts`. + +**Modify (Track A — comments):** +- `packages/claude-code-headless/src/terminal/HeadlessTerminal.ts` — add a comment block above `scheduleFlush` documenting the `pendingWrites` stall under Claude's synchronized-output protocol, and the recommended workaround (consumers should `snapshotPlain()` poll rather than rely on the `'screen'` event when correctness matters). +- `packages/claude-code-headless/src/ClaudeCodeHeadless.ts` — add a comment block above the `activity`/`idle` emission documenting that the bottom-up `SPINNER_VERB_RE` walk produces FALSE NEGATIVES during tool-result rendering, Bash() previews, and transient headers, and warn consumers not to use `'activity'` as a binary "did this submit?" signal. (The harness lost 2/10 iterations on a false-negative spinner read before we figured this out.) + +**Modify (Track B — debug dump plumbing):** +- `src/preload/api/types.ts` — add `PasteDebugLayer` and `PasteDebugEventInput` types next to the existing `DictationDebugLayer` types. +- `src/preload/api/index.ts` — re-export `pasteDebugApi` on `window.api`. +- `src/main/ipc/index.ts` — thread a `PasteDebugJournalRegistry` through `IpcDeps`, register the `paste:debug-event` channel. +- `src/main/index.ts` — construct the registry, flush on `before-quit`, prune 14-day-old files on startup. +- `src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts` — mint `pasteId = crypto.randomUUID()` on Enter, emit RENDER:keydown, RENDER:state-snapshot (composer text length, focus, recorder state), RENDER:call-paste-fn events. Forward `pasteId` to the call site. +- `src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts` — accept optional `pasteId` argument on `sendBracketedPasteThenSubmit` and `sendClaudeDraftText`; emit IPC:paste-write, IPC:submit-write events with sha8 of the payload. +- `src/main/sessions/...` (the file that handles the `terminal:write` IPC) — emit PTY:write events on each chunk with sha8 + byte count. Find the file by `grep -rn "ipcMain.handle.*terminal:write\|sessionManager.*write" src/main/` — likely `src/main/ipc/session.ts` or similar. We pair these against the renderer's IPC:paste-write events the same way dictation pairs renderer-produced and main-received chunks. + +**Modify (Track C — event-driven fix):** +- `packages/claude-code-headless/src/ClaudeCodeHeadless.ts` — add a new method `awaitPastePlaceholder(opts?: { timeoutMs?: number; pollIntervalMs?: number }): Promise<{ kind: 'appeared'; waitedMs: number } | { kind: 'timeout' }>`. Polls `this.screen` (or directly the headless terminal) for `/\[Pasted text #\d+/.test(plain)`. +- `src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts` — replace the `await wait(delayMs)` line with a call out through IPC to await the placeholder. New IPC: `dictation:claude-await-paste-placeholder` (or similar) — returns when the placeholder appears or after 2 s safety timeout. +- `src/main/ipc/session.ts` (or wherever ClaudeSession lives) — register the new IPC handler that delegates to `ClaudeCodeHeadless.awaitPastePlaceholder`. +- `src/renderer/src/app-state/settings/types.ts` — add `eventDrivenPasteSubmit: boolean`, default **true** (we are confident enough from the harness that we ship ON, with the timer as the fallback path so a buggy detector can't worse-than-status-quo us). + +**No new tests, no new `test:*` scripts.** Per `feedback_no_test_bloat.md`. The harness already gates regressions — re-running `runs/event-driven-reliability.ts` after this work should still be 10/10. + +--- + +## Task 0: Sanity-check the harness is still green + +Quick smoke test so we know baseline before we change anything that the package depends on. + +- [ ] **Step 1: Run reliability gates** + +```bash +unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NODE_EXTRA_CA_CERTS \ + SSL_CERT_FILE REQUESTS_CA_BUNDLE CURL_CA_BUNDLE +npx tsx --tsconfig /Users/juliusolsson/Desktop/Development/cc-shell/tsconfig.node.json \ + /Users/juliusolsson/Desktop/Development/cc-shell/vendor/in_progress/paste-submit-repro/runs/baseline-reliability.ts +npx tsx --tsconfig /Users/juliusolsson/Desktop/Development/cc-shell/tsconfig.node.json \ + /Users/juliusolsson/Desktop/Development/cc-shell/vendor/in_progress/paste-submit-repro/runs/event-driven-reliability.ts +``` + +Expected: both print `RELIABILITY: PASS — 10/10 iterations succeeded.` + +If either fails, the harness has drifted and that needs fixing before this plan touches production code. + +--- + +## Task 1: Track A — document HeadlessTerminal's `'screen'` event stall + +**Files:** +- Modify: `packages/claude-code-headless/src/terminal/HeadlessTerminal.ts:376` (above `scheduleFlush`) + +- [ ] **Step 1: Add the WHY comment** + +Find the `private scheduleFlush(): void {` line. Above it, add: + +```ts +// ----------------------------------------------------------------------------- +// KNOWN ISSUE: the 'screen' event can stall under synchronized output +// ----------------------------------------------------------------------------- +// +// Claude's TUI uses the synchronized-output protocol heavily — every +// composer redraw is wrapped in `\x1b[?2026h … \x1b[?2026l` so the +// terminal renders the new frame atomically. `@xterm/headless` parses +// those sequences correctly into the buffer, but its `write(data, cb)` +// callback timing is influenced by them: under sustained sync-output +// pressure the per-chunk callbacks can land in a way that leaves +// `pendingWrites` > 0 indefinitely, so `scheduleFlush()` is never +// re-armed and consumers stop receiving `'screen'` events even though +// the buffer is being updated correctly. +// +// We discovered this building the paste-submit reproduction harness at +// `vendor/in_progress/paste-submit-repro/`. The harness's PTY trace +// showed Claude continuously emitting render chunks, the buffer +// contained the up-to-date input box (verified by polling +// `snapshotPlain()` directly), yet the `'screen'` listener went silent +// after the cold-boot banner. Polling on a wall-clock interval gives +// the same data this event was supposed to surface, without the stall. +// +// What this means for consumers: +// * If you only need a periodic snapshot for diagnostics or +// parsing, prefer a fixed-interval poll of `snapshotPlain()` / +// `snapshotMarkdown()` over subscribing to `'screen'`. The poll +// is cheap (synchronous read from the xterm buffer) and is the +// same approach the in-app HTML Debug Panel uses. +// * If you DO subscribe to `'screen'` for low-latency reaction +// (e.g. waiting for `[Pasted text #N]` to appear before sending +// submit), you MUST also have a wall-clock timeout fallback +// because the event may never fire in this session. +// +// A real fix would be to either: drop the pendingWrites counter and +// schedule a flush on every `pty.onData` (cheap, more events but no +// stalls); or replace the @xterm/headless callback mechanism with +// our own write→parse barrier. Neither change is in scope for the +// paste-submit work; this comment exists so the next person who +// notices the stall doesn't re-rediscover the bug from scratch. +``` + +- [ ] **Step 2: Type-check passes** + +Run: `npm run typecheck` (or `npm run build` if no `typecheck` script). +Expected: PASS — comments-only change. + +- [ ] **Step 3: Commit** + +```bash +git add packages/claude-code-headless/src/terminal/HeadlessTerminal.ts +git commit -m "docs(headless): document 'screen' event stall under synchronized output" +``` + +--- + +## Task 2: Track A — document the spinner-detector false-negative + +**Files:** +- Modify: `packages/claude-code-headless/src/ClaudeCodeHeadless.ts` — above the `activity`/`idle` debouncer block (search `idleDebounceTimer` to find it) + +- [ ] **Step 1: Add the WHY comment** + +Find the `private idleDebounceTimer:` field declaration (around line 224). The comment above it already mentions the spinner-row replacement quirk, but it frames it as a UI-flicker concern. Replace it with a stronger warning: + +```ts +// Debounce timer for `idle`. +// +// CC redraws the spinner cell every frame, but in practice there are +// *long* windows (multi-second) where the spinner row is replaced by +// a transient header — tool-call summaries, the "Bash(...)" preview, +// a "Tip:" footer line, tool-result chrome — and the bottom-up +// SPINNER_VERB_RE walk returns null even though CC is clearly still +// working. +// +// ----------------------------------------------------------------------------- +// FALSE NEGATIVES WHEN USED AS A "DID THIS SUBMIT?" SIGNAL +// ----------------------------------------------------------------------------- +// +// Do NOT use the `'activity'` event as a binary verdict for "did +// Claude accept and start processing my submit?" The +// paste-submit-repro harness lost 2/10 iterations exactly this way: +// Claude HAD submitted, the composer HAD cleared, and a JSONL entry +// landed shortly after — but the spinner row at the moment we +// sampled showed a Bash() preview header, the regex matched nothing, +// and the `'activity'` event never fired for that window. +// +// If you need to verify a submit succeeded, prefer one of: +// * "the composer's `[Pasted text #N]` placeholder is gone" — +// reliable, screen-truth, what the harness ended up using +// * "a new JSONL assistant entry appeared on the committed +// channel" — authoritative, but lags submit by several seconds +// * "the spinner row matched, OR the composer cleared, OR a +// committed entry arrived" — the most forgiving combination, +// suitable for UX features that just need "are we definitely +// working now?" +// +// The 2500ms debounce below is for UX (preventing the activity pip +// from flashing on/off across spinner-row gaps), NOT for correctness. +// Tightening it is fine when needed; reducing reliance on the event +// for state machines is more important. +// +// 2500ms is empirically wide enough to bridge the longest spinner +// gap CC's TUI shows during a normal turn (tool output animation +// cycles ~1.5s), without introducing perceptible lag at the end of a +// turn — the user finishes reading the assistant block before the +// green pip drops. +private idleDebounceTimer: ReturnType | null = null +``` + +(Existing field declaration `private idleDebounceTimer: …` should follow the comment. Don't duplicate it.) + +- [ ] **Step 2: Type-check passes** + +Same as Task 1. + +- [ ] **Step 3: Commit** + +```bash +git add packages/claude-code-headless/src/ClaudeCodeHeadless.ts +git commit -m "docs(headless): warn about spinner-detector false negatives as submit verdict" +``` + +--- + +## Task 3: Track B — types + scaffold for paste-debug + +**Files:** +- Modify: `src/preload/api/types.ts` + +- [ ] **Step 1: Add types** + +Below the `DictationDebugEvent` type that PR #68 added, insert: + +```ts +// Per-paste debug dump. +// +// Mirror of the dictation-debug subsystem in PR #68. Every Enter that +// triggers a paste-submit code path writes one append-only JSONL file +// under `/paste-debug/.paste.jsonl` capturing the +// full renderer→IPC→main→PTY chain. The bug we are chasing is the +// "paste in cc-shell needs a second Enter to submit" intermittent — +// the PTY-isolated harness at `vendor/in_progress/paste-submit-repro/` +// shows the production 125 ms timer path works at 10/10 in isolation, +// so the failure must be somewhere the harness doesn't model +// (renderer keyboard handler, IPC queue, React state, double-submit +// race). This dump is the diagnostic tool that will pin it down. +// +// `pasteId` is renderer-minted at the moment Enter is observed in the +// composer keydown handler, BEFORE any state-mutation or async send +// happens. Threading it through the call stack lets the main side's +// PTY-write event correlate against the renderer's keydown timestamp +// for the same press. +export type PasteDebugLayer = + | 'RENDER' // keydown, composer state snapshot, call into claudePaste fn + | 'IPC' // renderer-side IPC write (paste payload, submit \r) + | 'PTY' // main-side PTY write (sha8 + byte count) + | 'SCREEN' // observed `[Pasted text #N]` placeholder appear / disappear + | 'OUTCOME' // composer cleared / still-stuck / explicit cancel + | 'ERROR' + +export type PasteDebugEventInput = { + layer: PasteDebugLayer + event: string + data?: Record +} + +export type PasteDebugEvent = PasteDebugEventInput & { + ts: number + tMs: number +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/preload/api/types.ts +git commit -m "feat(paste-debug): add PasteDebugLayer/Event types" +``` + +--- + +## Task 4: Track B — main-side journal + +**Files:** +- Create: `src/main/pasteDebugJournal.ts` + +- [ ] **Step 1: Copy and adapt the dictation journal** + +The file is a near-duplicate of `src/main/dictationJournal.ts` shipped in PR #68. Three differences: +- File path: `/paste-debug/.paste.jsonl` +- Class names: `PasteDebugJournal`, `PasteDebugJournalRegistry` +- Path resolver name: `pasteDebugLogPath` +- Prune routine name: `pruneOldPasteDebugLogs` + +Copy `dictationJournal.ts` verbatim and rename. The comment in the file header should reference PR #68's dictationJournal as the deliberate near-duplicate, with the same justification: a shared "BatchedJsonlWriter" is YAGNI until a third caller shows up. + +- [ ] **Step 2: Commit** + +```bash +git add src/main/pasteDebugJournal.ts +git commit -m "feat(paste-debug): add PasteDebugJournal mirroring dictationJournal" +``` + +--- + +## Task 5: Track B — preload bridge + IPC handler + +**Files:** +- Create: `src/preload/api/pasteDebug.ts` +- Modify: `src/preload/api/index.ts`, `src/main/ipc/index.ts`, `src/main/ipc/dictation.ts` (or wherever the equivalent dictation channel is — search `dictation:debug-event` and put the new one alongside; if there's no obvious "paste" IPC file, create `src/main/ipc/pasteDebug.ts`) +- Modify: `src/main/index.ts` — construct + flush + prune + +- [ ] **Step 1: Preload bridge** + +Copy `src/preload/api/dictationDebug.ts` to `src/preload/api/pasteDebug.ts`, swap names: + +```ts +import { ipcRenderer } from 'electron' +import type { PasteDebugEventInput } from '@preload/api/types.js' + +export const pasteDebugApi = { + recordPasteDebugEvent: (pasteId: string, input: PasteDebugEventInput): void => { + ipcRenderer.send('paste:debug-event', pasteId, input) + }, +} +``` + +- [ ] **Step 2: Re-export on window.api** + +In `src/preload/api/index.ts`, add `import { pasteDebugApi } from '@preload/api/pasteDebug.js'` and spread it into the `api` object next to `dictationDebugApi`. + +- [ ] **Step 3: IPC handler** + +In whichever main file owns the dictation `paste:debug-event` channel (or new `src/main/ipc/pasteDebug.ts`), add: + +```ts +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 + pasteDebugJournals.get(pasteId).append(payload) +}) +``` + +Thread `pasteDebugJournals: PasteDebugJournalRegistry` through `IpcDeps` the same way `dictationDebugJournals` is threaded today. + +- [ ] **Step 4: Main lifecycle** + +In `src/main/index.ts`, next to `const dictationDebugJournals = new DictationDebugJournalRegistry()`: + +```ts +const pasteDebugJournals = new PasteDebugJournalRegistry() +``` + +Pass into `registerAllIpc({ …, pasteDebugJournals })`. In `before-quit`: + +```ts +void pasteDebugJournals.flushAll() +``` + +In `whenReady`: + +```ts +void pruneOldPasteDebugLogs().catch(err => { + console.warn('[paste-debug] prune failed (non-fatal):', err) +}) +``` + +- [ ] **Step 5: Type-check + build** + +Run: `npm run build` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/preload/api/pasteDebug.ts src/preload/api/index.ts \ + src/main/ipc/* src/main/index.ts +git commit -m "feat(paste-debug): wire IPC channel + main-side lifecycle" +``` + +--- + +## Task 6: Track B — renderer instrumentation + +**Files:** +- Modify: `src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts` +- Modify: `src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts` + +- [ ] **Step 1: Find the call site** + +```bash +grep -n "sendBracketedPasteThenSubmit\|sendClaudeDraftText" \ + src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts +``` + +Expected: one or more matches. Read the surrounding ~30 lines so you can identify the keydown handler that fires when the user presses Enter with a paste-eligible draft. + +- [ ] **Step 2: Mint pasteId + emit RENDER events** + +At the very top of the Enter-keydown handler, before any state mutation: + +```ts +const pasteId = crypto.randomUUID() +window.api.recordPasteDebugEvent(pasteId, { + layer: 'RENDER', + event: 'keydown:enter', + data: { + composerLen: currentInput.length, + composerHead: currentInput.slice(0, 240), + isPasteLike: currentInput.includes('\n') || currentInput.length > 800, + focused: focusedRef.current, + hasModifier: event.shiftKey || event.altKey || event.ctrlKey || event.metaKey, + }, +}) +``` + +Right before the actual `sendBracketedPasteThenSubmit(send, payload, 125)` call: + +```ts +window.api.recordPasteDebugEvent(pasteId, { + layer: 'RENDER', + event: 'call:sendBracketedPasteThenSubmit', + data: { payloadLen: payload.length, delayMs: 125 }, +}) +``` + +If the handler EVER falls through to a different code path (e.g., a non-paste-shaped submit that just sends `\r`), emit: + +```ts +window.api.recordPasteDebugEvent(pasteId, { + layer: 'RENDER', + event: 'call:plain-submit', + data: { reason: 'payload below paste threshold' /* or whatever the branch reason is */ }, +}) +``` + +This is the single most important step — if production is hitting a different branch than we think, this event tells us. + +- [ ] **Step 3: Thread pasteId into claudePaste.ts** + +Add `pasteId?: string` as an optional last argument to `sendBracketedPasteThenSubmit` and `sendClaudeDraftText` in `claudePaste.ts`. Inside, emit: + +```ts +if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'IPC', + event: 'write:paste-payload', + data: { + bytes: payload.length, + sha8: await sha8(new TextEncoder().encode(payload).buffer), + }, + }) +} +await send(`\x1b[200~${payload}\x1b[201~`) +if (delayMs > 0) await wait(delayMs) +if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'IPC', + event: 'write:submit-cr', + data: { delayMs }, + }) +} +await send('\r') +``` + +Use the `sha8` helper pattern from PR #68's `useComposerDictation.ts` — `crypto.subtle.digest('SHA-256', buf)` truncated to 8 hex chars. + +- [ ] **Step 4: Build passes** + +```bash +npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts \ + src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts +git commit -m "feat(paste-debug): instrument renderer keydown + paste call site" +``` + +--- + +## Task 7: Track B — main-side PTY write instrumentation + +**Files:** +- Modify: the file owning `terminal:write` IPC (likely `src/main/ipc/session.ts` — search `'terminal:write'`) + +- [ ] **Step 1: Find the IPC handler** + +```bash +grep -rn "'terminal:write'\|terminal.*write\b" src/main/ipc/ src/main/sessions/ +``` + +Identify the handler that takes `(sessionId, data)` and calls `pty.write(data)`. + +- [ ] **Step 2: Accept optional pasteId** + +The renderer side will need to pass pasteId through when this IPC is fired *from the paste flow*. Two options: +- (a) Add a new IPC channel `terminal:write-with-paste-id` that carries `(sessionId, data, pasteId)`, and have `claudePaste.ts` use the new channel when pasteId is set. +- (b) Add `pasteId?: string` to the existing IPC signature — but that requires touching every caller of `send()` not just the paste path. Bigger blast radius. + +Prefer (a). The renderer's `send` callback that's passed to `sendBracketedPasteThenSubmit` becomes a thin wrapper that uses the new channel. + +- [ ] **Step 3: Emit PTY event on main side** + +```ts +// in the new handler for terminal:write-with-paste-id +ipcMain.handle('terminal:write-with-paste-id', async (_evt, sessionId: string, data: string, pasteId: string) => { + const bytes = Buffer.byteLength(data, 'utf8') + const sha = createHash('sha256').update(data).digest('hex').slice(0, 8) + pasteDebugJournals.get(pasteId).append({ + layer: 'PTY', + event: 'main:write', + data: { sessionId, bytes, sha8: sha, head: data.slice(0, 40).replace(//g, '\\e') }, + }) + await sessionManager.writeToSession(sessionId, data) // or however writes happen +}) +``` + +Now we can pair `IPC:write:paste-payload` (renderer-side sha8) against `PTY:main:write` (main-side sha8) by `pasteId` + chunk order, exactly the way PR #68 pairs `CHUNK:renderer:produced` against `CHUNK:main:received` in dictation. + +- [ ] **Step 4: Build passes** + +```bash +npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/main/ipc/session.ts src/preload/api/dictation.ts # or wherever the renderer-side IPC method lives +git commit -m "feat(paste-debug): emit PTY:write events with sha8 correlation" +``` + +--- + +## Task 8: Track C — `awaitPastePlaceholder` in `claude-code-headless` + +**Files:** +- Modify: `packages/claude-code-headless/src/ClaudeCodeHeadless.ts` + +- [ ] **Step 1: Add the method** + +Find the public methods section (after `start()`, `stop()`, `write()`, etc.) and add: + +```ts +/** + * Poll the live screen snapshot for Claude's `[Pasted text #N]` + * placeholder. Used by the paste-submit flow to send `\r` only after + * Claude has visibly committed the paste to the composer — the + * load-independent equivalent of the production `setTimeout(125)` + * wait. + * + * WHY this is on the headless class and not in cc-shell renderer: + * The renderer doesn't have a fast path to the live xterm buffer. + * It would have to IPC over and wait for the response on every + * poll, which is exactly the 5-50 ms overhead this method exists + * to avoid. The headless class already owns the buffer; the + * `screen` channel exposes the same poll cheaply. + * + * WHY a timeout fallback is mandatory: + * Claude's future UI changes could rename the placeholder, change + * its format, or remove it. Without a bound, the caller could hang + * forever waiting for a string that will never appear. 2 s is + * chosen as ~10x the observed maximum wait under load (~100 ms in + * our harness sample) — large enough that a real placeholder + * appearance always wins, small enough that the timeout-fallback + * path doesn't make a real submit feel laggy. + * + * WHY 10 ms polling: + * The harness measured placeholder appearances at p50 ~50 ms and + * p95 ~108 ms. 10 ms keeps the worst-case latency between + * appearance and submit under one frame. + */ +awaitPastePlaceholder(opts: { + timeoutMs?: number + pollIntervalMs?: number +} = {}): Promise<{ kind: 'appeared'; waitedMs: number } | { kind: 'timeout' }> { + const timeoutMs = opts.timeoutMs ?? 2_000 + const pollIntervalMs = opts.pollIntervalMs ?? 10 + const startedAt = Date.now() + return new Promise(resolve => { + const tick = () => { + const plain = this.terminal.snapshotPlain() + if (/\[Pasted text #\d+/.test(plain)) { + resolve({ kind: 'appeared', waitedMs: Date.now() - startedAt }) + return + } + if (Date.now() - startedAt >= timeoutMs) { + resolve({ kind: 'timeout' }) + return + } + setTimeout(tick, pollIntervalMs) + } + tick() + }) +} +``` + +- [ ] **Step 2: Build the package** + +```bash +npm --prefix packages/claude-code-headless run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/claude-code-headless/src/ClaudeCodeHeadless.ts +git commit -m "feat(headless): add awaitPastePlaceholder for event-driven paste-submit" +``` + +--- + +## Task 9: Track C — wire the new method through IPC + +**Files:** +- Modify: `src/main/ipc/session.ts` (or wherever ClaudeSession IPC lives) — register a new IPC handler +- Modify: `src/preload/api/dictation.ts` (or wherever Claude-session IPC bridge lives) — expose the new method +- Modify: `src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts` — use the new method behind the setting flag + +- [ ] **Step 1: Add settings field** + +In `src/renderer/src/app-state/settings/types.ts`, after the existing dictation fields: + +```ts +/** Event-driven paste-submit. When ON (default), claudePaste.ts + * polls the live Claude TUI screen snapshot for `[Pasted text #N]` + * before sending `\r`, instead of waiting a fixed 125 ms wall clock. + * Falls back to the timer if the placeholder doesn't appear within + * 2 s — the timer path is the existing behavior so flipping this + * setting OFF restores production v2.x behavior exactly. + * + * Default ON because the PTY-isolated harness (vendor/in_progress/ + * paste-submit-repro) shows event-driven is 10/10 reliable AND + * faster on average (~50 ms wait vs. 125 ms unconditional). Even + * though we don't yet know the actual production failure mode + * (see docs/superpowers/plans/2026-05-11-…), event-driven cannot + * be WORSE than the timer in any modeled scenario. */ +eventDrivenPasteSubmit: boolean +``` + +Add to `DEFAULT_SETTINGS`: + +```ts +eventDrivenPasteSubmit: true, +``` + +Add to `coerceSettings` in `persistence.ts`: + +```ts +eventDrivenPasteSubmit: parsed.eventDrivenPasteSubmit !== false, // default ON, requires explicit `false` to disable +``` + +- [ ] **Step 2: New IPC channel** + +In the main-side session IPC file: + +```ts +ipcMain.handle('claude:await-paste-placeholder', async (_evt, sessionId: string, opts?: { timeoutMs?: number }) => { + const session = sessionManager.getClaudeSession(sessionId) // adjust to your accessor + if (!session) return { kind: 'no-session' as const } + return session.headless.awaitPastePlaceholder(opts ?? {}) +}) +``` + +- [ ] **Step 3: Preload bridge** + +Add to the appropriate preload API file: + +```ts +awaitClaudePastePlaceholder: (sessionId: string, opts?: { timeoutMs?: number }) => + ipcRenderer.invoke('claude:await-paste-placeholder', sessionId, opts) as Promise< + { kind: 'appeared'; waitedMs: number } | { kind: 'timeout' } | { kind: 'no-session' } + >, +``` + +- [ ] **Step 4: Wire claudePaste.ts to use it conditionally** + +In `claudePaste.ts`, change `sendBracketedPasteThenSubmit` so it takes an optional `eventDriven: { sessionId: string; enabled: boolean }`: + +```ts +export async function sendBracketedPasteThenSubmit( + send: WriteFn, + payload: string, + delayMs = 0, + eventDriven?: { sessionId: string; enabled: boolean }, + pasteId?: string, +): Promise { + await sendBracketedPaste(send, payload) + if (eventDriven?.enabled) { + const outcome = await window.api.awaitClaudePastePlaceholder(eventDriven.sessionId, { timeoutMs: 2_000 }) + if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'SCREEN', + event: outcome.kind === 'appeared' ? 'placeholder:appeared' : 'placeholder:timeout', + data: outcome.kind === 'appeared' ? { waitedMs: outcome.waitedMs } : {}, + }) + } + if (outcome.kind === 'appeared') { + await send('\r') + return + } + // Fall through to the timer-based path: a future Claude UI + // change that removes the placeholder must not break submit + // entirely. The bound is 2 s above; below we still wait the + // remaining delay (down to a minimum of CLAUDE_PASTE_SUBMIT_DELAY_MS) + // so we never submit too fast. + } + if (delayMs > 0) await wait(delayMs) + await send('\r') +} +``` + +Update the call site in `useComposerKeybinds.ts` to pass `{ sessionId, enabled: settings.eventDrivenPasteSubmit }` and the `pasteId`. + +- [ ] **Step 5: Build passes** + +```bash +npm run build +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/src/app-state/settings/types.ts \ + src/renderer/src/app-state/settings/persistence.ts \ + src/main/ipc/session.ts \ + src/preload/api/dictation.ts \ + src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts \ + src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts +git commit -m "feat(paste): event-driven submit via [Pasted text] placeholder polling" +``` + +--- + +## Task 10: Manual verification + ship + +- [ ] **Step 1: Re-run reliability gates** + +```bash +unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NODE_EXTRA_CA_CERTS \ + SSL_CERT_FILE REQUESTS_CA_BUNDLE CURL_CA_BUNDLE +npx tsx --tsconfig /Users/juliusolsson/Desktop/Development/cc-shell/tsconfig.node.json \ + /Users/juliusolsson/Desktop/Development/cc-shell/vendor/in_progress/paste-submit-repro/runs/baseline-reliability.ts +npx tsx --tsconfig /Users/juliusolsson/Desktop/Development/cc-shell/tsconfig.node.json \ + /Users/juliusolsson/Desktop/Development/cc-shell/vendor/in_progress/paste-submit-repro/runs/event-driven-reliability.ts +``` + +Expected: both 10/10. If event-driven dropped (because Task 8's `awaitPastePlaceholder` has a different `snapshotPlain` path than the harness's polling) we have a problem to fix before merge. + +- [ ] **Step 2: Run cc-shell in dev** + +```bash +npm run dev +``` + +Reproduce the bug the user reports: paste a multi-line message into a Claude composer, press Enter. The first Enter should now submit reliably. + +- [ ] **Step 3: Inspect a paste dump** + +```bash +ls -t "$HOME/Library/Application Support/cc-shell/paste-debug/" | head -5 +``` + +Open the most recent `.paste.jsonl` and verify it contains: +- one RENDER `keydown:enter` event +- one RENDER `call:sendBracketedPasteThenSubmit` event +- one IPC `write:paste-payload` event with sha8 +- one PTY `main:write` event with matching sha8 +- one SCREEN `placeholder:appeared` event with waitedMs < 200 +- one IPC `write:submit-cr` event +- one PTY `main:write` event with `\r` +- one OUTCOME `composer:cleared` event + +If a real failing paste happens, the dump tells us exactly which step diverges from this expected shape. + +- [ ] **Step 4: Open PR** + +```bash +gh auth status --hostname github.com # confirm Juliusolsson05 +gh pr create --title "feat(paste): event-driven submit + per-paste debug dump + headless quirks docs" --body "..." +``` + +Per `feedback_no_auto_merge.md`: open the PR, then stop. Wait for the user to merge after they've tried it for a day. + +--- + +## Self-Review + +**Spec coverage:** +- Track A (comments): both insights from the harness session (screen-event stall, spinner false-negative) are captured with thick WHY comments in the package source. ✓ +- Track B (debug dump): full renderer→IPC→PTY observability is plumbed end-to-end. ✓ +- Track C (event-driven fix): shipped behind a default-ON setting with a 2 s timeout fallback. ✓ + +**Placeholder scan:** None — every step has either real code or a precise grep. + +**Type consistency:** `PasteDebugLayer`/`Event` shape matches `DictationDebugLayer`/`Event` from PR #68; `awaitPastePlaceholder` return shape (`{ kind: 'appeared'; waitedMs } | { kind: 'timeout' }`) is propagated through IPC and the renderer call site identically. + +**Hypotheses NOT to commit to in this PR:** The plan deliberately does NOT pre-commit to a specific renderer-side fix (e.g. "it's a double-fire on the keydown handler") because the harness can't distinguish between IPC-latency, double-fire, and state-mutation hypotheses. The debug dump is what lets us pick the right one. If, after one user-facing repro with the dump on, the diagnosis is obvious, a follow-up PR handles it. This PR's job is observability + a defensive improvement, not a speculative renderer rewrite. + +**Critical not-done thing:** This PR ships the event-driven fix but does NOT claim to fix the production bug. The user's reported "I have to follow up with a `.`" symptom may persist. Verifying the fix means: collect at least one failing paste-debug-dump from a user-facing session, diff the event sequence against the expected shape from Task 10 Step 3, and only then write a Phase-2 plan targeted at the real divergence. diff --git a/packages/claude-code-headless b/packages/claude-code-headless index f978e151c..e59235ac8 160000 --- a/packages/claude-code-headless +++ b/packages/claude-code-headless @@ -1 +1 @@ -Subproject commit f978e151c5b435e92d95e46ee8262aa26afb4ab1 +Subproject commit e59235ac828f9706675a1150dd5317ed08bc4702 diff --git a/src/main/index.ts b/src/main/index.ts index 270d7b2a0..c09b14eb4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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' @@ -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 @@ -103,6 +113,9 @@ async function startApp(): Promise { // 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) }) @@ -187,7 +200,7 @@ async function startApp(): Promise { 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') @@ -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() }) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index cce35bcf9..11d7a7631 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -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' @@ -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' @@ -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() @@ -51,4 +54,5 @@ export function registerAllIpc(deps: IpcDeps): void { registerWorktreeActivityIpc(deps.worktreeActivityIndex) registerSetupIpc() registerDictationIpc({ dictationDebugJournals: deps.dictationDebugJournals }) + registerPasteDebugIpc({ pasteDebugJournals: deps.pasteDebugJournals }) } diff --git a/src/main/ipc/pasteDebug.ts b/src/main/ipc/pasteDebug.ts new file mode 100644 index 000000000..0c8893b13 --- /dev/null +++ b/src/main/ipc/pasteDebug.ts @@ -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) + }, + ) +} diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index e4d63efc8..183b93607 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -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' @@ -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 ( @@ -71,7 +76,27 @@ 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 @@ -79,6 +104,13 @@ export function registerSessionIpc(manager: SessionManager): void { 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 }, @@ -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 diff --git a/src/main/pasteDebugJournal.ts b/src/main/pasteDebugJournal.ts new file mode 100644 index 000000000..4f80b5e3f --- /dev/null +++ b/src/main/pasteDebugJournal.ts @@ -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: +// /paste-debug/.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 { + 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 { + 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 { + 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() + + 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 { + 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 { + 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 + } + } +} diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index b2b325da8..89f6bf001 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -754,6 +754,51 @@ export class SessionManager extends EventEmitter { return true } + /** + * 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. + const session = entry.session as unknown as { + awaitPastePlaceholder?: ( + opts?: { timeoutMs?: number; pollIntervalMs?: number }, + ) => Promise< + | { kind: 'appeared'; waitedMs: number } + | { kind: 'timeout' } + | { kind: 'no-headless' } + > + } + if (typeof session.awaitPastePlaceholder !== 'function') { + return { kind: 'no-session' } + } + return session.awaitPastePlaceholder(opts) + } + /** Resize a session's terminal + PTY. No-op if session doesn't exist. */ resize(sessionId: string, cols: number, rows: number): void { const entry = this.sessions.get(sessionId) diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index aaa6dd185..98651187d 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -12,6 +12,7 @@ import { performanceApi } from '@preload/api/performance.js' import { setupApi } from '@preload/api/setup.js' import { dictationApi } from '@preload/api/dictation.js' import { dictationDebugApi } from '@preload/api/dictationDebug.js' +import { pasteDebugApi } from '@preload/api/pasteDebug.js' // Composed preload API surface. // @@ -43,6 +44,7 @@ export const api = { ...setupApi, ...dictationApi, ...dictationDebugApi, + ...pasteDebugApi, } export type Api = typeof api diff --git a/src/preload/api/pasteDebug.ts b/src/preload/api/pasteDebug.ts new file mode 100644 index 000000000..766c3d284 --- /dev/null +++ b/src/preload/api/pasteDebug.ts @@ -0,0 +1,22 @@ +import { ipcRenderer } from 'electron' + +import type { PasteDebugEventInput } from '@preload/api/types.js' + +// Renderer-side bridge to the per-paste debug journal. +// +// Fire-and-forget via `ipcRenderer.send` (not `invoke`) so hot paths +// — every keydown, every chunked-paste IPC write — pay zero +// round-trip cost. Main batches writes at 100 ms per file. Same +// shape as `dictationDebugApi` in PR #68. +// +// `pasteId` is renderer-minted at the moment Enter is observed in the +// composer keydown handler. See `preload/api/types.ts` → PasteDebugLayer. + +export const pasteDebugApi = { + recordPasteDebugEvent: ( + pasteId: string, + input: PasteDebugEventInput, + ): void => { + ipcRenderer.send('paste:debug-event', pasteId, input) + }, +} diff --git a/src/preload/api/session.ts b/src/preload/api/session.ts index 867b89238..1729f475c 100644 --- a/src/preload/api/session.ts +++ b/src/preload/api/session.ts @@ -94,12 +94,36 @@ export const sessionApi = { ipcRenderer.invoke('session:agent-pty-detach', sessionId), // --- Per-session I/O --- - sendInput: (sessionId: string, data: string): Promise => - ipcRenderer.invoke('session:input', sessionId, data), + // + // Optional `pasteId` correlates this write against the per-paste + // debug journal in main. When set, main emits a `PTY:main:write` + // event into `/paste-debug/.paste.jsonl` so the + // renderer's `IPC:write:*` events can be paired by `sha8` and byte + // count. Omit `pasteId` for non-paste writes (keystrokes, agent-pty, + // etc.) so we don't journal unrelated traffic. + sendInput: (sessionId: string, data: string, pasteId?: string): Promise => + ipcRenderer.invoke('session:input', sessionId, data, pasteId), resize: (sessionId: string, cols: number, rows: number): Promise => ipcRenderer.invoke('session:resize', sessionId, cols, rows), + // Event-driven paste-submit primitive. See + // src/renderer/.../claudePaste.ts and + // packages/claude-code-headless/src/ClaudeCodeHeadless.ts. + // Resolves when Claude's TUI renders `[Pasted text #N]`, or after + // the configured timeout. Renderer treats every non-'appeared' + // outcome as "fall through to the wall-clock submit path." + awaitClaudePastePlaceholder: ( + sessionId: string, + opts?: { timeoutMs?: number; pollIntervalMs?: number }, + ): Promise< + | { kind: 'appeared'; waitedMs: number } + | { kind: 'timeout' } + | { kind: 'no-headless' } + | { kind: 'no-session' } + > => + ipcRenderer.invoke('claude:await-paste-placeholder', sessionId, opts), + // --- Resume picker: list previous sessions recorded in a cwd --- listSessionsForCwd: ( cwd: string, diff --git a/src/preload/api/types.ts b/src/preload/api/types.ts index 3fad7abec..65241231b 100644 --- a/src/preload/api/types.ts +++ b/src/preload/api/types.ts @@ -277,6 +277,51 @@ export type DictationDebugEvent = DictationDebugEventInput & { tMs: number // monotonic offset from session start } +// Per-paste debug dump. +// +// Direct mirror of the dictation-debug subsystem. Every Enter that +// triggers cc-shell's paste-submit code path writes one append-only +// JSONL file under `/paste-debug/.paste.jsonl` +// capturing the full renderer→IPC→main→PTY chain. The bug we are +// chasing is the "paste in cc-shell needs a second Enter to submit" +// intermittent — the PTY-isolated harness at +// `vendor/in_progress/paste-submit-repro/` shows the production +// 125 ms timer path works at 10/10 in isolation, so the failure must +// be somewhere the harness doesn't model (renderer keyboard handler, +// IPC queue, React state, double-submit race). This dump is the +// diagnostic tool that will pin it down. +// +// `pasteId` is renderer-minted at the moment Enter is observed in the +// composer keydown handler, BEFORE any state mutation or async send +// happens. Threading it through the call stack lets the main side's +// PTY-write event correlate against the renderer's keydown timestamp +// for the same press — same pattern dictation uses to pair +// `CHUNK:renderer:produced` against `CHUNK:main:received` by sha8. +// +// Privacy contract is identical to dictation-debug: never log API +// keys, never log raw PTY bytes — log byte count + sha8 fingerprint +// instead. Composer text head IS logged (truncated to 240 chars) +// because the file is local 0o600 and the whole point is to see +// what the user actually pasted vs. what reached Claude. +export type PasteDebugLayer = + | 'RENDER' // composer keydown, state snapshot, call into claudePaste fn + | 'IPC' // renderer-side IPC write (paste payload, submit \r) + | 'PTY' // main-side PTY write (sha8 + byte count) — pairs with IPC + | 'SCREEN' // [Pasted text #N] placeholder observed to appear / clear + | 'OUTCOME' // composer cleared / still-stuck / explicit cancel — terminal + | 'ERROR' + +export type PasteDebugEventInput = { + layer: PasteDebugLayer + event: string + data?: Record +} + +export type PasteDebugEvent = PasteDebugEventInput & { + ts: number + tMs: number +} + // Debug bundle — opaque file list shipped from renderer to main. // See main/storage/debugBundle.ts for the layout rationale. Types // are duplicated here (not imported) because preload/main/renderer diff --git a/src/providers/claude/runtime/claudeSession.ts b/src/providers/claude/runtime/claudeSession.ts index 502d58e31..ac050a3e2 100644 --- a/src/providers/claude/runtime/claudeSession.ts +++ b/src/providers/claude/runtime/claudeSession.ts @@ -483,6 +483,26 @@ export class ClaudeSession extends EventEmitter { return this.headless?.getScreenMarkdown() ?? '' } + /** + * Poll the live Claude TUI screen for `[Pasted text #N]` and resolve + * the instant it appears. Delegates to ClaudeCodeHeadless; see the + * method comment there for why this is load-independent and why a + * timeout fallback is mandatory. Used by claudePaste.ts to send `\r` + * exactly when Claude has visibly committed the paste — the + * primary path of the paste-submit fix. + * + * Returns `{ kind: 'no-headless' }` when the headless instance has + * not been constructed yet (start() not called or session torn down) + * so the renderer can fall through to the wall-clock path without + * waiting for the full 2 s timeout. + */ + async awaitPastePlaceholder( + opts?: { timeoutMs?: number; pollIntervalMs?: number }, + ): Promise<{ kind: 'appeared'; waitedMs: number } | { kind: 'timeout' } | { kind: 'no-headless' }> { + if (!this.headless) return { kind: 'no-headless' } + return this.headless.awaitPastePlaceholder(opts ?? {}) + } + isExited(): boolean { return this.exited } diff --git a/src/renderer/src/app-state/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts index 13cadf7d1..3fb1318b9 100644 --- a/src/renderer/src/app-state/settings/persistence.ts +++ b/src/renderer/src/app-state/settings/persistence.ts @@ -45,6 +45,11 @@ export function coerceSettings(value: unknown): Settings { // setting's docstring. Anything looser (e.g. `!== false`) would // flip the default to ON for fresh installs. dispatchProjectTerminal: parsed.dispatchProjectTerminal === true, + // `!== false` so default-ON applies on fresh installs AND when + // the persisted value is missing entirely (older localStorage + // blobs predating this setting). The only way to land OFF is an + // explicit `false`, which is exactly the toggle UX we want. + eventDrivenPasteSubmit: parsed.eventDrivenPasteSubmit !== false, // WHY membership check via WORKSPACE_MODES rather than a literal // === 'dispatch': keeps the source of truth in one array so adding // a new mode label later (if ever) only requires editing types.ts. diff --git a/src/renderer/src/app-state/settings/types.ts b/src/renderer/src/app-state/settings/types.ts index e5e6292fb..f621e73e8 100644 --- a/src/renderer/src/app-state/settings/types.ts +++ b/src/renderer/src/app-state/settings/types.ts @@ -136,6 +136,24 @@ export type Settings = { * controls and removes the "terminal always mounted even when turned * off" failure mode. */ dispatchProjectTerminal: boolean + /** Event-driven paste-submit. When ON (default), `claudePaste.ts` + * polls Claude's live TUI screen for `[Pasted text #N]` before + * sending `\r`, instead of waiting a fixed 125 ms wall clock. + * Falls back to the timer if the placeholder doesn't appear within + * 2 s — the timer path is the existing production behavior, so + * flipping this setting OFF restores cc-shell v0.0.2 paste-submit + * behavior exactly. + * + * Default ON because the PTY-isolated harness at + * `vendor/in_progress/paste-submit-repro/` shows event-driven is + * 10/10 reliable AND faster on average (~58 ms wait vs. 125 ms + * unconditional). Even though the harness CANNOT reproduce the + * user-facing "first Enter does nothing" symptom (production has + * an additional failure mode we haven't isolated yet — see the + * per-paste debug-dump diagnostic landed in this same PR), + * event-driven cannot be WORSE than the timer in any modeled + * scenario, so shipping ON is strictly defensive. */ + eventDrivenPasteSubmit: boolean } export const DEFAULT_SETTINGS: Settings = { @@ -153,4 +171,5 @@ export const DEFAULT_SETTINGS: Settings = { aggressiveDebugPersistence: false, defaultWorkspaceMode: 'grid', dispatchProjectTerminal: false, + eventDrivenPasteSubmit: true, } diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts index 9811ac245..81715b689 100644 --- a/src/renderer/src/features/settings/lib/settingsRegistry.ts +++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts @@ -224,6 +224,29 @@ export function getSettingsRegistry(): SettingDefinition[] { onToggle: (ctx, value) => ctx.onChange({ dispatchProjectTerminal: value }), }, }, + { + // Track C of the paste-submit work. Default ON; flipping OFF + // restores the pre-fix wall-clock 125 ms timer path. The + // primary reason this toggle exists at all is that the + // event-driven path depends on Claude's TUI continuing to + // render `[Pasted text #N]` verbatim — if a future Claude + // update renames or removes that placeholder, users may want + // a quick escape hatch back to the timer without waiting for + // us to ship a fix. Categorized as `workspace` (not + // `experimental`) because every cc-shell user pastes into + // Claude; this is a quality-of-life setting, not an opt-in. + id: 'event-driven-paste-submit', + category: 'workspace', + title: 'Event-Driven Paste Submit', + description: + 'Wait for Claude\'s "[Pasted text #N]" placeholder before sending Enter, instead of a 125 ms wall-clock delay. Faster and more reliable. Turn off only if your Claude TUI version stops rendering the placeholder.', + keywords: ['paste', 'submit', 'enter', 'claude', 'event-driven', 'placeholder', 'composer'], + control: { + type: 'toggle', + getValue: settings => settings.eventDrivenPasteSubmit, + onToggle: (ctx, value) => ctx.onChange({ eventDrivenPasteSubmit: value }), + }, + }, { id: 'aggressive-debug-persistence', category: 'experimental', diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index d68678fb4..b5b01e039 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -166,7 +166,14 @@ export function TileLeaf({ onUserEngagement: acknowledgeSession, }) - const send = async (data: string) => { + // Optional `pasteId` correlates this write into the per-paste debug + // journal in main. Set only by the paste-submit flow in + // useComposerKeybinds; all other callers (history navigation, slash + // forwarding, dictation injection) leave it undefined and pay no + // journaling cost. The pasteId-aware path is the diagnostic for the + // "first Enter sometimes does nothing" intermittent — see + // `docs/superpowers/plans/2026-05-11-paste-submit-harness-findings-and-fix.md`. + const send = async (data: string, pasteId?: string) => { acknowledgeSession() if ( !runtime.inputReady || @@ -183,7 +190,7 @@ export function TileLeaf({ ) return } - const ok = await window.api.sendInput(sessionId, data) + const ok = await window.api.sendInput(sessionId, data, pasteId) if (!ok) { throw new Error(`sendInput failed for missing session ${sessionId}`) } diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts index 9d12992f9..def35efb5 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts @@ -15,57 +15,195 @@ // We fix this at the shell boundary instead of forking Claude's paste // logic: for Claude only, any prompt that is likely to be treated as // a paste gets delivered in TWO phases — first the payload, then -// (after Claude's own paste debounce window has elapsed) the submit -// key. This keeps us aligned with Claude's existing placeholder / -// expansion model instead of fighting it. +// (depending on the chosen strategy) a separate submit write. +// +// TWO submit-after-paste strategies coexist here: +// +// * EVENT-DRIVEN (default, when `eventDriven` is set): poll +// Claude's screen snapshot for `[Pasted text #N]` and send `\r` +// the instant the placeholder appears. Load-independent — +// works at 10 ms or 10 s. This is the strategy the paste-submit +// repro harness at `vendor/in_progress/paste-submit-repro/` +// showed to be 10/10 reliable with average waits ~58 ms. +// +// * WALL-CLOCK FALLBACK: wait `delayMs` (default 125 ms — slightly +// above Claude's 100 ms paste completion timeout) then send `\r`. +// Kept as a fallback because the event-driven path depends on +// Claude continuing to render the `[Pasted text #N]` placeholder +// verbatim. A future Claude UI rename would break placeholder +// detection but should not brick paste-submit entirely. The +// harness shows the timer races even at 1000 ms under load, so +// this is genuinely just a "don't be worse than yesterday" floor, +// not a load-bearing primary path. // // Why these numbers: // - 800 chars matches Claude's current PASTE_THRESHOLD upstream. -// - 125ms is slightly above Claude's 100ms paste completion timeout. -// Going lower risks reintroducing the race; going much higher -// makes submit feel laggy on every long paste. +// - 125 ms is slightly above Claude's 100 ms paste completion +// timeout. Going lower risks reintroducing the race; going much +// higher makes submit feel laggy on every long paste. Only +// consulted when the event-driven path times out or is disabled. +// - 2000 ms event-driven safety bound is ~10x the harness's +// observed p95 placeholder wait (~108 ms). export const CLAUDE_PASTE_THRESHOLD = 800 export const CLAUDE_PASTE_SUBMIT_DELAY_MS = 125 export const CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS = 750 +export const CLAUDE_PASTE_EVENT_DRIVEN_TIMEOUT_MS = 2_000 function wait(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } +// SHA-256 prefix used as a fingerprint to correlate paste payloads +// across the renderer→IPC→main→PTY chain in the per-paste debug +// journal. crypto.subtle is the standard renderer-side digest API; +// 4 bytes is more than enough for cross-process pairing (collision +// probability is negligible for a single paste's chunk set). +async function sha8(data: string): Promise { + const buf = new TextEncoder().encode(data) + const digest = await crypto.subtle.digest('SHA-256', buf) + const hex: string[] = [] + for (const b of new Uint8Array(digest, 0, 4)) hex.push(b.toString(16).padStart(2, '0')) + return hex.join('') +} + +export type ClaudePasteSendFn = (data: string, pasteId?: string) => Promise + +export type ClaudePasteOpts = { + /** Renderer-minted UUID for the per-paste debug journal. When set, + * every PTY write in this paste emits an IPC:write event to the + * journal. */ + pasteId?: string + /** Event-driven submit. When provided AND `enabled`, poll Claude's + * screen snapshot for `[Pasted text #N]` before sending `\r`, + * instead of relying on `delayMs`. Falls back to the timer path + * after `CLAUDE_PASTE_EVENT_DRIVEN_TIMEOUT_MS`. */ + eventDriven?: { enabled: boolean; sessionId: string } +} + export async function sendBracketedPaste( - send: (data: string) => Promise, + send: ClaudePasteSendFn, payload: string, + opts?: ClaudePasteOpts, ): Promise { - await send(`\x1b[200~${payload}\x1b[201~`) + const pasteId = opts?.pasteId + if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'IPC', + event: 'write:paste-payload', + data: { bytes: payload.length, sha8: await sha8(payload) }, + }) + } + await send(`\x1b[200~${payload}\x1b[201~`, pasteId) } export async function sendBracketedPasteThenSubmit( - send: (data: string) => Promise, + send: ClaudePasteSendFn, payload: string, delayMs = 0, + opts?: ClaudePasteOpts, ): Promise { - if (delayMs <= 0) { - await send(`\x1b[200~${payload}\x1b[201~\r`) + const pasteId = opts?.pasteId + const eventDriven = opts?.eventDriven + + // Single-write fast path. Used by callers that don't care about + // the placeholder race (e.g. Codex, which has its own paste-state + // machine that does NOT exhibit this bug). Even on Claude we honor + // this when explicitly opted in via `delayMs <= 0` — but the + // composer call site in useComposerKeybinds always sets a positive + // delay AND sets `eventDriven` so this branch is effectively + // codex-only in production. + if (delayMs <= 0 && !eventDriven?.enabled) { + if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'IPC', + event: 'write:paste-and-submit-single', + data: { bytes: payload.length, sha8: await sha8(payload) }, + }) + } + await send(`\x1b[200~${payload}\x1b[201~\r`, pasteId) return } - await sendBracketedPaste(send, payload) - await wait(delayMs) - await send('\r') + + await sendBracketedPaste(send, payload, opts) + + // Try the event-driven path first if enabled. The placeholder poll + // happens main-side inside `awaitClaudePastePlaceholder` so we + // don't pay an IPC round-trip per poll tick (10 ms cadence × 100s + // of ms wait would be ~30 IPC msgs/paste otherwise). + if (eventDriven?.enabled) { + const outcome = await window.api.awaitClaudePastePlaceholder( + eventDriven.sessionId, + { timeoutMs: CLAUDE_PASTE_EVENT_DRIVEN_TIMEOUT_MS }, + ) + if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'SCREEN', + event: outcome.kind === 'appeared' + ? 'placeholder:appeared' + : outcome.kind === 'timeout' + ? 'placeholder:timeout' + : 'placeholder:no-session', + data: outcome.kind === 'appeared' ? { waitedMs: outcome.waitedMs } : {}, + }) + } + if (outcome.kind === 'appeared') { + if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'IPC', + event: 'write:submit-cr', + data: { strategy: 'event-driven', waitedMs: outcome.waitedMs }, + }) + } + await send('\r', pasteId) + return + } + // Fall through to the wall-clock path. We've already waited some + // amount (up to `timeoutMs`) so subtract that from `delayMs` — + // otherwise the timeout + delayMs add up to noticeable lag. + // Floor at 0 in case the event-driven wait already exceeded delayMs. + // We DON'T have the actual elapsed in the no-session/timeout + // outcomes, but the timeout case took close to `timeoutMs`, so + // skip the timer entirely there. + if (outcome.kind === 'timeout') { + if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'IPC', + event: 'write:submit-cr', + data: { strategy: 'timeout-skip-timer' }, + }) + } + await send('\r', pasteId) + return + } + // no-session: the renderer thinks we have a session but main + // doesn't. Skip directly to the wall-clock path as a last-ditch. + } + + if (delayMs > 0) await wait(delayMs) + if (pasteId) { + window.api.recordPasteDebugEvent(pasteId, { + layer: 'IPC', + event: 'write:submit-cr', + data: { strategy: 'wall-clock-timer', delayMs }, + }) + } + await send('\r', pasteId) } export async function sendClaudeDraftText( - send: (data: string) => Promise, + send: ClaudePasteSendFn, text: string, + opts?: ClaudePasteOpts, ): Promise { if (text.length === 0) return const isPasteLike = text.includes('\n') || text.length > CLAUDE_PASTE_THRESHOLD if (isPasteLike) { - await sendBracketedPaste(send, text) + await sendBracketedPaste(send, text, opts) await wait(CLAUDE_PASTE_SUBMIT_DELAY_MS) return } - await send(text) + await send(text, opts?.pasteId) } export function buildClaudeImagePastePayload(text: string, imagePaths: string[]): string { diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts index d0a92cc35..b917ab0ff 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts @@ -2,6 +2,7 @@ import { useState } from 'react' import { extractAssistantInProgress } from '@shared/parsers/extractAssistant' +import { useAppStore } from '@renderer/app-state/hooks' import type { SessionId } from '@renderer/workspace/types' import type { SessionRuntime, Workspace } from '@renderer/workspace/workspaceStore' import { @@ -43,7 +44,7 @@ export type UseComposerKeybindsArgs = { workspace: Workspace input: string setInputText: (next: string) => void - send: (data: string) => Promise + send: (data: string, pasteId?: string) => Promise history: string[] historyIndex: number | null historyAnchor: string @@ -75,6 +76,12 @@ export function useComposerKeybinds({ // runtime.picker, but we use slashMode to decide whether keys // should be forwarded vs. stored locally). const [slashMode, setSlashMode] = useState(false) + // Event-driven paste-submit. When ON (default) the paste flow + // polls for Claude's `[Pasted text #N]` placeholder before sending + // `\r` instead of relying on a wall-clock timer; see claudePaste.ts + // for the full strategy comment. Read here so the toggle takes + // effect on the next paste without remount. + const eventDrivenPasteSubmit = useAppStore(state => state.settings.eventDrivenPasteSubmit) const exitSlashMode = () => { setSlashMode(false) @@ -208,6 +215,28 @@ export function useComposerKeybinds({ blockBackendWrite() return } + // Mint a paste-debug id at the EARLIEST observable moment of + // the Enter press — before provider routing, before state + // mutation, before any async work. If the bug is "the Enter + // handler fires twice and the second \r races", both invocations + // will mint distinct pasteIds and the debug dump will show two + // separate journal files for what the user perceived as one press. + // See docs/superpowers/plans/2026-05-11-paste-submit-...md for + // the hypothesis space. + const pasteId = crypto.randomUUID() + window.api.recordPasteDebugEvent(pasteId, { + layer: 'RENDER', + event: 'keydown:enter', + data: { + composerLen: input.length, + composerHead: input.slice(0, 240), + isPasteLike: input.includes('\n') || input.length > CLAUDE_PASTE_THRESHOLD, + hasDraftImages: draftImages.length > 0, + hasModifier: e.shiftKey || e.altKey || e.ctrlKey || e.metaKey, + backendReady, + sessionId, + }, + }) // Capture streaming baseline from the very freshest screen // text so the streaming card can detect "this is the old // response" reliably. latestScreenRef is mutated @@ -250,7 +279,17 @@ export function useComposerKeybinds({ (input.includes('\n') || input.length > CLAUDE_PASTE_THRESHOLD) if (submitProvider === 'codex') { - await sendBracketedPasteThenSubmit(send, input) + // Codex's own bracketed-paste handling does NOT exhibit the + // race Claude does (the user has not reported a Codex paste- + // submit bug). No event-driven path here; pasteId is still + // forwarded so the debug dump can confirm "ah, this Enter + // routed to the Codex branch" if the failing dump is on a + // Codex pane and we were debugging the wrong thing. + window.api.recordPasteDebugEvent(pasteId, { + layer: 'RENDER', + event: 'route:codex-bracketed-paste', + }) + await sendBracketedPasteThenSubmit(send, input, 0, { pasteId }) } else if (hasClaudeImages) { const savedImages = await Promise.all( draftImages.map(image => @@ -272,7 +311,17 @@ export function useComposerKeybinds({ if (!/\s$/.test(input)) await send(' ') } const payload = buildClaudeImagePastePayload('', imagePaths) - await sendBracketedPasteThenSubmit(send, payload, CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS) + window.api.recordPasteDebugEvent(pasteId, { + layer: 'RENDER', + event: 'route:claude-images', + data: { imageCount: imagePaths.length, textLen: input.length }, + }) + // Image-path paste uses the longer 750 ms timer because the + // image expansion in Claude's TUI is its own animation; the + // placeholder we detect for text pastes does NOT show up + // for image-path pastes. Keep the wall-clock fallback here; + // event-driven path is disabled for this branch on purpose. + await sendBracketedPasteThenSubmit(send, payload, CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS, { pasteId }) } else if (isClaudePasteLike) { // Keep the submit key OUT of the bracketed-paste write // and wait past Claude's paste debounce. Sending `\r` @@ -280,14 +329,46 @@ export function useComposerKeybinds({ // accumulator and can leave the prompt sitting in the // composer until a later keypress nudges it through // the normal submit path. - await sendBracketedPasteThenSubmit(send, input, CLAUDE_PASTE_SUBMIT_DELAY_MS) + // + // Event-driven mode (default ON via settings) polls Claude's + // screen for `[Pasted text #N]` and submits the instant it + // appears — load-independent, dramatically faster than the + // 125 ms wall-clock floor. Wall-clock delay is kept as a + // safety fallback if the placeholder never materializes + // (e.g. future Claude UI rename); see claudePaste.ts. + window.api.recordPasteDebugEvent(pasteId, { + layer: 'RENDER', + event: 'route:claude-paste-like', + data: { + inputLen: input.length, + hasNewline: input.includes('\n'), + eventDriven: eventDrivenPasteSubmit, + }, + }) + await sendBracketedPasteThenSubmit(send, input, CLAUDE_PASTE_SUBMIT_DELAY_MS, { + pasteId, + eventDriven: { enabled: eventDrivenPasteSubmit, sessionId }, + }) } else { - await send(input + '\r') + window.api.recordPasteDebugEvent(pasteId, { + layer: 'RENDER', + event: 'route:claude-plain-text', + data: { inputLen: input.length }, + }) + await send(input + '\r', pasteId) } setInputText('') if (submitProvider === 'claude' && draftImages.length > 0) { workspace.setDraftImages(sessionId, []) } + // OUTCOME marks the end of the submit flow from the renderer's + // POV. A real reader of the dump can compare this against + // PTY:main:write events to see whether the renderer thinks + // the submit completed at the same instant main does. + window.api.recordPasteDebugEvent(pasteId, { + layer: 'OUTCOME', + event: 'submit:returned', + }) } catch (err) { // Keep the draft visible if main no longer has a live // session for this pane. Clearing the composer on a @@ -297,6 +378,11 @@ export function useComposerKeybinds({ workspace.removeOptimisticCodexUserEntry(sessionId, input) } console.warn('[TileLeaf] submit failed', err) + window.api.recordPasteDebugEvent(pasteId, { + layer: 'ERROR', + event: 'submit:throw', + data: { message: err instanceof Error ? err.message : String(err) }, + }) } // Any submit exits history cycling — the prompt is // committed and the next Up should start a fresh walk from From f1491f1e6658ef036a8d1bd8a5d2a4e00194dd9e Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 11 May 2026 16:32:41 +0200 Subject: [PATCH 2/2] chore: bump claude-code-headless to main (PR #7 merged) --- packages/claude-code-headless | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/claude-code-headless b/packages/claude-code-headless index e59235ac8..b7a147ad2 160000 --- a/packages/claude-code-headless +++ b/packages/claude-code-headless @@ -1 +1 @@ -Subproject commit e59235ac828f9706675a1150dd5317ed08bc4702 +Subproject commit b7a147ad28d6328103e661be521f5caac25b42de