diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo new file mode 100755 index 000000000..ae1134b40 --- /dev/null +++ b/packages/desktop/bin/paseo @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) + +# macOS: MacOS/Paseo is a sibling of Resources/ +# Linux: the executable sits next to resources/ +if [ -x "${RESOURCES_DIR}/../MacOS/Paseo" ]; then + APP_EXECUTABLE="${RESOURCES_DIR}/../MacOS/Paseo" +elif [ -x "${RESOURCES_DIR}/../Paseo" ]; then + APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo" +else + echo "Bundled Paseo executable not found relative to ${RESOURCES_DIR}" >&2 + exit 1 +fi + +CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js" + +exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@" diff --git a/packages/desktop/bin/paseo.cmd b/packages/desktop/bin/paseo.cmd new file mode 100644 index 000000000..af4a44e93 --- /dev/null +++ b/packages/desktop/bin/paseo.cmd @@ -0,0 +1,15 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "RESOURCES_DIR=%SCRIPT_DIR%.." +set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe" +set "CLI_ENTRY=%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" + +if not exist "%APP_EXECUTABLE%" ( + echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2 + exit /b 1 +) + +set "ELECTRON_RUN_AS_NODE=1" +"%APP_EXECUTABLE%" "%CLI_ENTRY%" %* diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 73dc5e5ec..c25082007 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -19,26 +19,26 @@ mac: notarize: true entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.inherit.plist - extraFiles: - - from: scripts/paseo - to: MacOS/paseo + extraResources: + - from: bin/paseo + to: bin/paseo target: - dmg - zip linux: category: Development icon: assets - extraFiles: - - from: scripts/paseo - to: paseo + extraResources: + - from: bin/paseo + to: bin/paseo target: - AppImage - tar.gz win: icon: assets/icon.ico - extraFiles: - - from: scripts/paseo.cmd - to: paseo.cmd + extraResources: + - from: bin/paseo.cmd + to: bin/paseo.cmd target: - nsis - zip diff --git a/packages/desktop/scripts/paseo b/packages/desktop/scripts/paseo deleted file mode 100755 index ad08d4a10..000000000 --- a/packages/desktop/scripts/paseo +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh -set -eu - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -APP_EXECUTABLE="${SCRIPT_DIR}/Paseo" - -if [ -d "${SCRIPT_DIR}/../Resources" ]; then - RESOURCES_DIR="${SCRIPT_DIR}/../Resources" -elif [ -d "${SCRIPT_DIR}/resources" ]; then - RESOURCES_DIR="${SCRIPT_DIR}/resources" -else - echo "Unable to locate Paseo resources directory next to ${SCRIPT_DIR}" >&2 - exit 1 -fi - -CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js" - -if [ ! -x "${APP_EXECUTABLE}" ]; then - echo "Bundled Paseo executable not found at ${APP_EXECUTABLE}" >&2 - exit 1 -fi - -if [ ! -f "${CLI_ENTRY}" ]; then - echo "Bundled Paseo CLI entrypoint not found at ${CLI_ENTRY}" >&2 - exit 1 -fi - -exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@" diff --git a/packages/desktop/scripts/paseo.cmd b/packages/desktop/scripts/paseo.cmd deleted file mode 100644 index 95f83ebb3..000000000 --- a/packages/desktop/scripts/paseo.cmd +++ /dev/null @@ -1,19 +0,0 @@ -@echo off -setlocal - -set "SCRIPT_DIR=%~dp0" -set "APP_EXECUTABLE=%SCRIPT_DIR%Paseo.exe" -set "CLI_ENTRY=%SCRIPT_DIR%resources\app.asar\node_modules\@getpaseo\cli\dist\index.js" - -if not exist "%APP_EXECUTABLE%" ( - echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2 - exit /b 1 -) - -if not exist "%CLI_ENTRY%" ( - echo Bundled Paseo CLI entrypoint not found at %CLI_ENTRY% 1>&2 - exit /b 1 -) - -set "ELECTRON_RUN_AS_NODE=1" -"%APP_EXECUTABLE%" "%CLI_ENTRY%" %* diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 1067a549b..cdd003bad 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -158,6 +158,8 @@ function buildDesktopDaemonCorsOriginsEnv(): string | undefined { .filter((value) => value.length > 0) ); + origins.add("paseo://app"); + const devServerUrl = process.env.EXPO_DEV_URL ?? DEFAULT_ELECTRON_DEV_SERVER_URL; try { const parsed = new URL(devServerUrl); @@ -479,7 +481,7 @@ function getCliSymlinkInstructions(): CliSymlinkInstructions { if (process.platform === "darwin") { const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, ""); - const cliPath = path.join(appBundle, "Contents", "MacOS", cliShimFilename); + const cliPath = path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename); return { title: "Add paseo to your shell", detail: "Create a symlink to the bundled Paseo CLI shim.", @@ -488,18 +490,20 @@ function getCliSymlinkInstructions(): CliSymlinkInstructions { } if (process.platform === "win32") { + const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); return { title: "Add paseo to your PATH", detail: "Add the Paseo installation directory to your system PATH so paseo.cmd is available.", - commands: `setx PATH "%PATH%;${path.dirname(electronExePath)}"`, + commands: `setx PATH "%PATH%;${path.dirname(cliPath)}"`, }; } // Linux + const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); return { title: "Add paseo to your shell", detail: "Create a symlink to the bundled Paseo CLI shim.", - commands: `sudo ln -sf "${path.join(path.dirname(electronExePath), cliShimFilename)}" /usr/local/bin/paseo`, + commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`, }; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 897c74296..b969d9412 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,6 +1,7 @@ import path from "node:path"; +import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; -import { app, BrowserWindow, nativeImage } from "electron"; +import { app, BrowserWindow, nativeImage, net, protocol } from "electron"; import { registerDaemonManager } from "./daemon/daemon-manager.js"; import { parseCliPassthroughArgsFromArgv, runCliPassthroughCommand } from "./daemon/runtime-paths.js"; import { closeAllTransportSessions } from "./daemon/local-transport.js"; @@ -11,8 +12,13 @@ import { registerOpenerHandlers } from "./features/opener.js"; import { setupApplicationMenu } from "./features/menu.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; +const APP_SCHEME = "paseo"; app.setName("Paseo"); +protocol.registerSchemesAsPrivileged([ + { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, +]); + // --------------------------------------------------------------------------- // Window creation // --------------------------------------------------------------------------- @@ -21,12 +27,12 @@ function getPreloadPath(): string { return path.join(__dirname, "preload.js"); } -function getProductionEntryPath(): string { +function getAppDistDir(): string { if (app.isPackaged) { - return path.join(process.resourcesPath, "app-dist", "index.html"); + return path.join(process.resourcesPath, "app-dist"); } - return path.resolve(__dirname, "../../app/dist/index.html"); + return path.resolve(__dirname, "../../app/dist"); } function getWindowIconPath(): string | null { @@ -94,7 +100,7 @@ async function createMainWindow(): Promise { return; } - await mainWindow.loadFile(getProductionEntryPath()); + await mainWindow.loadURL(`${APP_SCHEME}://app/`); } // --------------------------------------------------------------------------- @@ -148,6 +154,34 @@ async function bootstrap(): Promise { } await app.whenReady(); + + const appDistDir = getAppDistDir(); + protocol.handle(APP_SCHEME, (request) => { + const { pathname, search, hash } = new URL(request.url); + const decodedPath = decodeURIComponent(pathname); + + // Chromium can occasionally request the exported entrypoint directly. + // Canonicalize it back to the route URL so Expo Router sees `/`, not `/index.html`. + if (decodedPath.endsWith("/index.html")) { + const normalizedPath = decodedPath.slice(0, -"/index.html".length) || "/"; + return Response.redirect(`${APP_SCHEME}://app${normalizedPath}${search}${hash}`, 307); + } + + const filePath = path.join(appDistDir, decodedPath); + const relativePath = path.relative(appDistDir, filePath); + + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return new Response("Not found", { status: 404 }); + } + + // SPA fallback: serve index.html for routes without a file extension + if (!relativePath || !path.extname(relativePath)) { + return net.fetch(pathToFileURL(path.join(appDistDir, "index.html")).toString()); + } + + return net.fetch(pathToFileURL(filePath).toString()); + }); + applyAppIcon(); setupApplicationMenu(); ensureNotificationCenterRegistration(); diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index 4bb8baf8b..ffa1d0cb1 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -224,6 +224,16 @@ function collectAssistantText(events: AgentStreamEvent[]): string { .join(""); } +function collectUserText(events: AgentStreamEvent[]): string { + return events + .filter( + (event): event is Extract => + event.type === "timeline" && event.item.type === "user_message" + ) + .map((event) => event.item.text) + .join(""); +} + function createTimedIteratorReader(params: { iterator: AsyncIterator }) { const { iterator } = params; let pendingNext: Promise> | null = null; @@ -314,6 +324,176 @@ describe("ClaudeAgentSession interrupt restart regression", () => { await session.close(); }); + test("restarts after interrupt scaffold query drain without surfacing placeholder transcript noise", async () => { + const logger = createTestLogger(); + let queryCreateCount = 0; + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryCreateCount += 1; + + if (queryCreateCount === 1) { + let step = 0; + return { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "interrupt-scaffold-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "user", + message: { + role: "user", + content: [{ type: "text", text: "[Request interrupted by user]" }], + }, + parent_tool_use_id: null, + uuid: "interrupt-scaffold-1", + session_id: "interrupt-scaffold-session", + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { + content: "Got it. Sorry for the mess.", + }, + }, + }; + } + if (step === 3) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { + content: "No response requested.", + }, + }, + }; + } + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + } satisfies QueryMock; + } + + const readPromptUuid = createPromptUuidReader(prompt); + let step = 0; + return { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "interrupt-scaffold-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid"; + return { + done: false, + value: { + type: "user", + message: { role: "user", content: "fresh prompt" }, + parent_tool_use_id: null, + uuid: promptUuid, + session_id: "interrupt-scaffold-session", + isReplay: true, + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { + content: "FRESH_RESPONSE_AFTER_INTERRUPT", + }, + }, + }; + } + if (step === 3) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, + }, + }; + } + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + } satisfies QueryMock; + }); + + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const events = await collectUntilTerminal(session.stream("fresh prompt")); + const assistantText = collectAssistantText(events); + const userText = collectUserText(events); + await session.close(); + + expect(queryCreateCount).toBeGreaterThanOrEqual(2); + expect(assistantText).toContain("FRESH_RESPONSE_AFTER_INTERRUPT"); + expect(assistantText).not.toContain("Got it. Sorry for the mess."); + expect(assistantText).not.toContain("No response requested."); + expect(userText).not.toContain("[Request interrupted by user]"); + expect( + events.some( + (event) => + event.type === "turn_failed" && + event.error.includes("Claude stream ended before terminal result") + ) + ).toBe(false); + expect(events.some((event) => event.type === "turn_completed")).toBe(true); + }); + test("ignores stale interrupted query completion after the replacement run starts", async () => { const logger = createTestLogger(); const releaseOldDone = deferred(); diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 7be627ea5..e9564c54b 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -112,6 +112,31 @@ describe("convertClaudeHistoryEntry", () => { expect(mapBlocks).not.toHaveBeenCalled(); }); + test("skips interrupt placeholder transcript noise", () => { + const interruptEntry = { + type: "user", + message: { + role: "user", + content: [{ type: "text", text: "[Request interrupted by user]" }], + }, + }; + + const assistantNoiseEntry = { + type: "assistant", + message: { + role: "assistant", + content: "No response requested.", + }, + }; + + const mapBlocks = vi.fn().mockReturnValue([ + { type: "assistant_message", text: "No response requested." }, + ]); + + expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]); + expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); + }); + test("maps task notifications to synthetic tool calls", () => { const entry = { type: "system", diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index d090d2c74..e773804c3 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -334,6 +334,9 @@ const REWIND_COMMAND: AgentSlashCommand = { argumentHint: "[user_message_uuid]", }; const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]"; +const INTERRUPT_PLACEHOLDER_PATTERN = + /^\[Request interrupted by user(?:[^\]]*)\]$/; +const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested."; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -584,11 +587,88 @@ function coerceToolResultContentToString(content: unknown): string { return deterministicStringify(content); } +function normalizeClaudeTranscriptText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function isClaudeInterruptPlaceholderText(value: unknown): boolean { + const normalized = normalizeClaudeTranscriptText(value); + return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized); +} + +function isClaudeNoResponsePlaceholderText(value: unknown): boolean { + return ( + normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER + ); +} + +function isClaudeTranscriptNoiseText(value: unknown): boolean { + return ( + isClaudeInterruptPlaceholderText(value) || + isClaudeNoResponsePlaceholderText(value) + ); +} + +function collectClaudeTextContentParts(content: unknown): string[] { + if (typeof content === "string") { + const normalized = normalizeClaudeTranscriptText(content); + return normalized ? [normalized] : []; + } + + if (!Array.isArray(content)) { + return []; + } + + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const text = normalizeClaudeTranscriptText( + (block as { text?: unknown }).text + ); + if (text) { + parts.push(text); + continue; + } + const input = normalizeClaudeTranscriptText( + (block as { input?: unknown }).input + ); + if (input) { + parts.push(input); + } + } + + return parts; +} + +function isClaudeInterruptScaffoldContent(content: unknown): boolean { + const parts = collectClaudeTextContentParts(content); + return ( + parts.length > 0 && + parts.every((part) => isClaudeInterruptPlaceholderText(part)) + ); +} + +function isClaudeTranscriptNoiseContent(content: unknown): boolean { + const parts = collectClaudeTextContentParts(content); + return ( + parts.length > 0 && + parts.every((part) => isClaudeTranscriptNoiseText(part)) + ); +} export function extractUserMessageText(content: unknown): string | null { if (typeof content === "string") { const normalized = content.trim(); - return normalized.length > 0 ? normalized : null; + if (!normalized || isClaudeTranscriptNoiseText(normalized)) { + return null; + } + return normalized; } if (!Array.isArray(content)) { @@ -602,12 +682,18 @@ export function extractUserMessageText(content: unknown): string | null { } const text = typeof block.text === "string" ? block.text : undefined; if (text && text.trim()) { - parts.push(text.trim()); + const trimmed = text.trim(); + if (!isClaudeTranscriptNoiseText(trimmed)) { + parts.push(trimmed); + } continue; } const input = typeof block.input === "string" ? block.input : undefined; if (input && input.trim()) { - parts.push(input.trim()); + const trimmed = input.trim(); + if (!isClaudeTranscriptNoiseText(trimmed)) { + parts.push(trimmed); + } } } @@ -1208,7 +1294,8 @@ class TimelineAssembler { const nextAssistantText = state.assistantText.slice(state.emittedAssistantLength); if ( nextAssistantText.length > 0 && - nextAssistantText !== INTERRUPT_TOOL_USE_PLACEHOLDER + nextAssistantText !== INTERRUPT_TOOL_USE_PLACEHOLDER && + !isClaudeTranscriptNoiseText(nextAssistantText) ) { state.emittedAssistantLength = state.assistantText.length; items.push({ type: "assistant_message", text: nextAssistantText }); @@ -1332,12 +1419,21 @@ function isMetadataOnlySdkMessage(message: SDKMessage): boolean { if (message.type === "system") { return true; } + if ( + message.type === "assistant" && + isClaudeTranscriptNoiseContent(message.message?.content) + ) { + return true; + } if (message.type !== "user") { return false; } if (isSyntheticUserEntry(message)) { return true; } + if (isClaudeInterruptScaffoldContent(message.message?.content)) { + return true; + } return isTaskNotificationUserContent(message.message?.content); } @@ -1491,6 +1587,7 @@ class ClaudeAgentSession implements AgentSession { private toolUseInputBuffers = new Map(); private pendingPermissions = new Map(); private activeForegroundTurn: ForegroundTurnState | null = null; + private activeForegroundPrompt: SDKUserMessage | null = null; private liveEventQueue = new Pushable(); private readonly runTracker = new RunTracker(); private readonly timelineAssembler = new TimelineAssembler(); @@ -1501,6 +1598,7 @@ class ClaudeAgentSession implements AgentSession { private historyLineFragment = ""; private turnState: TurnState = "idle"; private preReplayMetadataSeen = false; + private preReplayDoneRecoveryUsed = false; private pendingAutonomousWakeReservations = 0; private nextRunOrdinal = 1; private cancelCurrentTurn: (() => void) | null = null; @@ -1627,6 +1725,7 @@ class ClaudeAgentSession implements AgentSession { } this.suppressLocalReplayActivity = false; this.pendingAutonomousWakeReservations = 0; + this.preReplayDoneRecoveryUsed = false; const slashCommand = this.resolveSlashCommandInvocation(prompt); if (slashCommand?.commandName === REWIND_COMMAND_NAME) { @@ -1655,6 +1754,7 @@ class ClaudeAgentSession implements AgentSession { queue, }; this.activeForegroundTurn = foregroundTurn; + this.activeForegroundPrompt = sdkMessage; this.preReplayMetadataSeen = false; this.transitionTurnState("foreground", "foreground stream started"); this.clearRecentStderr(); @@ -1672,6 +1772,7 @@ class ClaudeAgentSession implements AgentSession { cancelIssued = true; if (this.activeForegroundTurn?.runId === run.id) { this.activeForegroundTurn = null; + this.activeForegroundPrompt = null; } if (this.cancelCurrentTurn === requestCancel) { this.cancelCurrentTurn = null; @@ -1925,6 +2026,7 @@ class ClaudeAgentSession implements AgentSession { this.cancelCurrentTurn?.(); this.activeForegroundTurn?.queue.end(); this.activeForegroundTurn = null; + this.activeForegroundPrompt = null; this.cancelCurrentTurn = null; this.turnState = "idle"; this.suppressLocalReplayActivity = false; @@ -2533,7 +2635,9 @@ class ClaudeAgentSession implements AgentSession { if (this.activeForegroundTurn?.runId === run.id) { this.activeForegroundTurn = null; + this.activeForegroundPrompt = null; this.preReplayMetadataSeen = false; + this.preReplayDoneRecoveryUsed = false; } this.logger.trace( { @@ -2930,6 +3034,31 @@ class ClaudeAgentSession implements AgentSession { this.input = null; } const activeRuns = this.runTracker.listActiveRuns(); + if (this.shouldRecoverFromPreReplayQueryDone(activeRuns)) { + this.logger.warn( + { + claudeSessionId: this.claudeSessionId, + activeRunCount: activeRuns.length, + preReplayMetadataSeen: this.preReplayMetadataSeen, + }, + "Claude query ended before prompt replay after interrupt scaffold; retrying fresh query" + ); + try { + await this.ensureQuery(); + if (this.input && this.activeForegroundPrompt) { + this.input.push(this.activeForegroundPrompt); + } + } catch (error) { + for (const run of activeRuns) { + this.failRun( + run, + error instanceof Error ? error.message : "Claude stream failed" + ); + } + await this.waitForLiveHistoryPoll(); + } + continue; + } if (activeRuns.length > 0) { for (const run of activeRuns) { this.failRun(run, "Claude stream ended before terminal result"); @@ -3133,6 +3262,28 @@ class ClaudeAgentSession implements AgentSession { } } + private shouldRecoverFromPreReplayQueryDone(activeRuns: RunRecord[]): boolean { + if ( + this.preReplayDoneRecoveryUsed || + !this.preReplayMetadataSeen || + !this.activeForegroundTurn || + activeRuns.length !== 1 + ) { + return false; + } + + const foregroundRun = this.runTracker.getRun(this.activeForegroundTurn.runId); + if (!foregroundRun || activeRuns[0]?.id !== foregroundRun.id) { + return false; + } + if (foregroundRun.owner !== "foreground" || foregroundRun.promptReplaySeen) { + return false; + } + + this.preReplayDoneRecoveryUsed = true; + return true; + } + private updateRunLifecycleForMessage( run: RunRecord, message: SDKMessage, @@ -3146,6 +3297,7 @@ class ClaudeAgentSession implements AgentSession { ) { run.promptReplaySeen = true; this.preReplayMetadataSeen = false; + this.preReplayDoneRecoveryUsed = false; } if (run.state === "queued") { @@ -3185,11 +3337,22 @@ class ClaudeAgentSession implements AgentSession { } private shouldSuppressLocalReplayActivity(message: SDKMessage): boolean { - const localReplay = this.isLocalReplayUserMessage(message); - if (!this.activeForegroundTurn && localReplay) { + const replayUuid = this.getLocalReplayUserMessageUuid(message); + if (replayUuid) { + // Don't suppress the echo of the current foreground turn's prompt — + // updateRunLifecycleForMessage needs it to set promptReplaySeen. + if (this.activeForegroundTurn) { + const foregroundRun = this.runTracker.getRun( + this.activeForegroundTurn.runId + ); + if (foregroundRun?.messageIds.has(replayUuid)) { + this.suppressLocalReplayActivity = false; + return false; + } + } this.suppressLocalReplayActivity = true; this.logger.debug( - { uuid: (message as { uuid?: string }).uuid }, + { uuid: replayUuid }, "Suppressing local replay user message from live pump" ); return true; @@ -3201,7 +3364,7 @@ class ClaudeAgentSession implements AgentSession { // Suppress only replay scaffolding. Do not suppress autonomous // assistant/result events; otherwise task-notification replies can be dropped. - if (localReplay) { + if (replayUuid) { return true; } @@ -3231,17 +3394,17 @@ class ClaudeAgentSession implements AgentSession { return false; } - private isLocalReplayUserMessage(message: SDKMessage): boolean { + private getLocalReplayUserMessageUuid(message: SDKMessage): string | null { if (message.type !== "user") { - return false; + return null; } const uuid = readTrimmedString( (message as unknown as { uuid?: unknown }).uuid ); if (!uuid) { - return false; + return null; } - return this.localUserMessageIds.has(uuid); + return this.localUserMessageIds.has(uuid) ? uuid : null; } private async interruptActiveTurn(): Promise { @@ -3652,15 +3815,17 @@ class ClaudeAgentSession implements AgentSession { } if (typeof content === "string" && content.length > 0) { // String content from user messages (e.g., local command output) - events.push({ - type: "timeline", - item: { - type: "user_message", - text: content, - ...(messageId ? { messageId } : {}), - }, - provider: "claude", - }); + if (!isClaudeTranscriptNoiseText(content)) { + events.push({ + type: "timeline", + item: { + type: "user_message", + text: content, + ...(messageId ? { messageId } : {}), + }, + provider: "claude", + }); + } } else if (Array.isArray(content)) { // User SDK entries with array content (e.g. interrupt messages, replayed JSONL entries) // must declare textMessageType so mapBlocksToTimeline emits user_message, not assistant_message. @@ -4210,7 +4375,11 @@ class ClaudeAgentSession implements AgentSession { const suppressReasoning = options?.suppressReasoning ?? false; if (typeof content === "string") { - if (!content || content === INTERRUPT_TOOL_USE_PLACEHOLDER) { + if ( + !content || + content === INTERRUPT_TOOL_USE_PLACEHOLDER || + isClaudeTranscriptNoiseText(content) + ) { return []; } if (suppressText) { @@ -4226,7 +4395,11 @@ class ClaudeAgentSession implements AgentSession { switch (block.type) { case "text": case "text_delta": - if (block.text && block.text !== INTERRUPT_TOOL_USE_PLACEHOLDER) { + if ( + block.text && + block.text !== INTERRUPT_TOOL_USE_PLACEHOLDER && + !isClaudeTranscriptNoiseText(block.text) + ) { if (textMessageType === "user_message") { const trimmed = block.text.trim(); if (trimmed) { @@ -4802,6 +4975,12 @@ export function convertClaudeHistoryEntry( } const content = message.content; + if ( + (entry.type === "user" || entry.type === "assistant") && + isClaudeTranscriptNoiseContent(content) + ) { + return []; + } const normalizedBlocks = normalizeHistoryBlocks(content); const contentValue = typeof content === "string" @@ -5040,15 +5219,24 @@ function extractClaudeUserText(message: any): string | null { return null; } if (typeof message.content === "string") { - return message.content.trim(); + const normalized = message.content.trim(); + return normalized && !isClaudeTranscriptNoiseText(normalized) + ? normalized + : null; } if (typeof message.text === "string") { - return message.text.trim(); + const normalized = message.text.trim(); + return normalized && !isClaudeTranscriptNoiseText(normalized) + ? normalized + : null; } if (Array.isArray(message.content)) { for (const block of message.content) { if (block && typeof block.text === "string") { - return block.text.trim(); + const normalized = block.text.trim(); + if (normalized && !isClaudeTranscriptNoiseText(normalized)) { + return normalized; + } } } }