Skip to content
17 changes: 17 additions & 0 deletions cli/src/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ export async function claudeRemote(opts: {
}
const forkedFrom = forkSession ? startFrom : null;

// One-shot rewind flags (set by the RewindConversation handler) pass through
// claudeArgs; filterCatalogAffectingClaudeArgs strips them from additionalArgs,
// so they must be parsed here into first-class SDK options.
let resumeSessionAt: string | undefined;
const resumeDropsTurn: string[] = [];
if (opts.claudeArgs) {
for (let i = 0; i < opts.claudeArgs.length; i++) {
if (opts.claudeArgs[i] === '--resume-session-at' && i + 1 < opts.claudeArgs.length) {
resumeSessionAt = opts.claudeArgs[++i];
} else if (opts.claudeArgs[i] === '--resume-drops-turn' && i + 1 < opts.claudeArgs.length) {
resumeDropsTurn.push(opts.claudeArgs[++i]);
}
}
}

// Mode starts from the persisted session for fork bootstrap; updated when
// the first child prompt arrives. plan/auto must be present at process start.
const bootstrapMode: EnhancedMode = opts.bootstrapMode ?? { permissionMode: 'default' };
Expand Down Expand Up @@ -163,6 +178,8 @@ export async function claudeRemote(opts: {
cwd: opts.path,
resume: startFrom ?? undefined,
forkSession,
resumeSessionAt,
resumeDropsTurn: resumeDropsTurn.length > 0 ? resumeDropsTurn : undefined,
mcpServers: opts.mcpServers,
permissionMode: bootstrapMode.permissionMode,
model: bootstrapMode.model,
Expand Down
40 changes: 39 additions & 1 deletion cli/src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
private readonly session: Session;
private abortController: AbortController | null = null;
private abortFuture: Future<void> | null = null;
private restartRequested = false;
private permissionHandler: PermissionHandler | null = null;
private handleSessionFound: ((sessionId: string) => void) | null = null;

Expand Down Expand Up @@ -114,6 +115,17 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
await this.handleSwitchRequest();
}

/**
* Abort the current SDK attempt (without an exit reason) so the main loop
* respawns Claude with fresh one-shot args. Used by rewind, which needs a
* process restart to apply --resume-session-at.
*/
public async requestRestart(): Promise<void> {
logger.debug('[remote]: doRestart');
this.restartRequested = true;
await this.abort();
}

public async launch(): Promise<RemoteLauncherExitReason> {
return this.start({
onExit: () => this.handleExitFromUi(),
Expand All @@ -128,6 +140,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
const session = this.session;
const messageBuffer = this.messageBuffer;

session.requestRemoteRestart = () => this.requestRestart();

this.setupAbortHandlers(session.client.rpcHandlerManager, {
onAbort: () => this.handleAbortRequest(),
onSwitch: () => this.handleSwitchRequest()
Expand Down Expand Up @@ -428,6 +442,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
deliveredMessageThisAttempt = true;
const deliveredText = session.expandSkillReference(p.message)
inFlightMessage = { items: p.items, mode: p.mode, isolate: p.isolate, deliveredText };
session.onUserTurnDelivered?.(p.items.flatMap((item) => item.localId ? [item.localId] : []))
session.client.notePendingHubPromptEcho(
deliveredText,
p.items.flatMap((item) => item.localId ? [item.localId] : [])
Expand Down Expand Up @@ -463,6 +478,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
deliveredMessageThisAttempt = true;
const deliveredText = session.expandSkillReference(msg.message)
inFlightMessage = { items: msg.items, mode: msg.mode, isolate: msg.isolate, deliveredText };
session.onUserTurnDelivered?.(msg.items.flatMap((item) => item.localId ? [item.localId] : []))
Comment thread
junmo-kim marked this conversation as resolved.
Outdated
session.client.notePendingHubPromptEcho(
deliveredText,
msg.items.flatMap((item) => item.localId ? [item.localId] : [])
Expand Down Expand Up @@ -537,7 +553,11 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
});

if (!this.exitReason && controller.signal.aborted) {
session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' });
if (this.restartRequested) {
this.restartRequested = false;
} else {
session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' });
}
}

// A full attempt completed without throwing. Clear the
Expand Down Expand Up @@ -627,6 +647,23 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
message: `Process exited unexpectedly ${MAX_IMMEDIATE_RESPAWN_FAILURES} times in a row: ${detail}. Dropping the queued message; resolve the issue and resend it.`
});
immediateFailureCount = 0;
} else if (this.restartRequested) {
// Intentional restart (rewind): the aborted in-flight
// turn belongs to the pre-rewind history. Re-delivering
// it into the respawned (truncated) process would
// diverge native history from the hub transcript.
for (const item of inFlightMessage?.items ?? []) {
if (item.localId) {
session.client.discardPendingHubPromptEcho(item.localId)
}
}
if (inFlightMessage?.deliveredText) {
session.client.discardPendingHubPromptEchoText(inFlightMessage.deliveredText)
}
inFlightMessage = null;
session.client.sendSessionEvent({ type: 'message', message: `Process exited unexpectedly: ${detail}` });
await this.respawnBackoff(getRespawnBackoffMs(), controller.signal);
continue;
} else {
restoreInFlightMessage();
session.client.sendSessionEvent({ type: 'message', message: `Process exited unexpectedly: ${detail}` });
Expand Down Expand Up @@ -675,6 +712,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {

protected async cleanup(): Promise<void> {
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
this.session.requestRemoteRestart = null;

if (this.handleSessionFound) {
this.session.removeSessionFoundCallback(this.handleSessionFound);
Expand Down
118 changes: 118 additions & 0 deletions cli/src/claude/conversationHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readNativeTurns, resolveRewindPlan, supportsNativeRewind } from './conversationHistory'

const CWD = '/tmp/rewind-fixture-project'

function line(entry: Record<string, unknown>): string {
return JSON.stringify(entry)
}

function prompt(uuid: string, text: string): string {
return line({ type: 'user', uuid, message: { role: 'user', content: [{ type: 'text', text }] } })
}

function toolResult(uuid: string): string {
return line({
type: 'user',
uuid,
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }] }
})
}

function assistant(uuid: string, text: string): string {
return line({ type: 'assistant', uuid, message: { role: 'assistant', content: [{ type: 'text', text }] } })
}

function attachment(uuid: string): string {
return line({ type: 'attachment', uuid })
}

function sidechain(uuid: string): string {
return line({ type: 'assistant', uuid, isSidechain: true, message: { role: 'assistant', content: [] } })
}

function writeTranscript(lines: string[]): string {
const projectDir = join(mkdtempSync(join(tmpdir(), 'hapi-rewind-')), 'projects')
mkdirSync(projectDir, { recursive: true })
// getProjectPath encodes every non-alphanumeric char of the cwd as '-'
const projectId = CWD.replace(/[^a-zA-Z0-9]/g, '-')
const dir = join(projectDir, projectId)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'session-a.jsonl'), lines.join('\n') + '\n')
return projectDir
}

describe('readNativeTurns', () => {
it('collects ordered turns from prompts and assistant entries only', () => {
const root = writeTranscript([
line({ type: 'queue-operation', uuid: 'q1' }),
attachment('a0'),
prompt('u1', 'Say ONE'),
assistant('as1', 'ONE'),
toolResult('tr1'),
prompt('u2', 'Say TWO'),
assistant('as2', 'TWO'),
sidechain('sc1'),
prompt('u3', 'Say THREE')
])
try {
process.env.CLAUDE_CONFIG_DIR = root.replace(/\/projects$/, '')
expect(readNativeTurns(CWD, 'session-a')).toEqual([
{ promptUuid: 'u1', endUuid: 'as1' },
{ promptUuid: 'u2', endUuid: 'as2' },
{ promptUuid: 'u3', endUuid: 'u3' }
])
} finally {
delete process.env.CLAUDE_CONFIG_DIR
rmSync(root, { recursive: true, force: true })
}
})

it('returns empty for a missing transcript', () => {
process.env.CLAUDE_CONFIG_DIR = join(tmpdir(), 'hapi-rewind-missing-config')
try {
expect(readNativeTurns('/nonexistent-cwd', 'nope')).toEqual([])
} finally {
delete process.env.CLAUDE_CONFIG_DIR
}
})
})

describe('supportsNativeRewind', () => {
it('accepts versions at or above 2.1.223', () => {
expect(supportsNativeRewind('2.1.240 (Claude Code)')).toBe(true)
expect(supportsNativeRewind('2.2.0 (Claude Code)')).toBe(true)
expect(supportsNativeRewind('3.0.1')).toBe(true)
expect(supportsNativeRewind('2.1.223 (Claude Code)')).toBe(true)
})

it('rejects older and undetectable binaries', () => {
expect(supportsNativeRewind('2.1.222 (Claude Code)')).toBe(false)
expect(supportsNativeRewind('2.0.55')).toBe(false)
expect(supportsNativeRewind(null)).toBe(false)
expect(supportsNativeRewind('Claude Code')).toBe(false)
})
})

describe('resolveRewindPlan', () => {
const turns = [
{ promptUuid: 'u1', endUuid: 'as1' },
{ promptUuid: 'u2', endUuid: 'as2' },
{ promptUuid: 'u3', endUuid: 'as3' }
]

it('keeps the previous turn boundary and drops the selected turn onward', () => {
expect(resolveRewindPlan(turns, 1)).toEqual({
resumeSessionAt: 'as1',
dropsTurns: ['u2', 'u3']
})
})

it('rejects dropping the first turn and out-of-range indexes', () => {
expect(() => resolveRewindPlan(turns, 0)).toThrow('Cannot rewind the first message')
expect(() => resolveRewindPlan(turns, 3)).toThrow('no native history')
})
})
101 changes: 101 additions & 0 deletions cli/src/claude/conversationHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { getProjectPath } from './utils/path'

export type NativeTurn = {
/** uuid of the user prompt entry that starts the turn. */
promptUuid: string
/** uuid of the last user/assistant entry belonging to the turn. */
endUuid: string
}

type TranscriptEntry = {
type?: string
uuid?: string
isSidechain?: boolean
message?: { content?: unknown }
}

function isPromptEntry(entry: TranscriptEntry): boolean {
const content = entry.message?.content
if (typeof content === 'string') return true
Comment thread
junmo-kim marked this conversation as resolved.
Outdated
return Array.isArray(content) && content.some((block) => (block as { type?: string } | null)?.type === 'text')
}

/**
* Parse the native Claude transcript for a session into ordered turns.
* The transcript is append-only; rewinds re-parent new turns, so dropped
* entries remain in the file. Only completed prompt turns are reported.
*/
export function readNativeTurns(workingDirectory: string, sessionId: string): NativeTurn[] {
const file = join(getProjectPath(workingDirectory), `${sessionId}.jsonl`)
if (!existsSync(file)) return []
const turns: NativeTurn[] = []
for (const line of readFileSync(file, 'utf-8').split('\n')) {
if (!line.trim()) continue
let entry: TranscriptEntry
try {
entry = JSON.parse(line)
} catch {
continue
}
if (entry.isSidechain) continue
if ((entry.type !== 'user' && entry.type !== 'assistant') || typeof entry.uuid !== 'string') continue
if (entry.type === 'user') {
if (!isPromptEntry(entry)) continue
turns.push({ promptUuid: entry.uuid, endUuid: entry.uuid })
} else if (turns.length > 0) {
turns[turns.length - 1]!.endUuid = entry.uuid
}
}
return turns
}

export type RewindPlan = {
resumeSessionAt?: string
dropsTurns: string[]
}

/** Native `--resume-session-at` truncation landed in Claude Code v2.1.223. */
export const NATIVE_REWIND_MIN_VERSION = [2, 1, 223] as const

export function parseClaudeVersion(versionOutput: string | null | undefined): number[] | null {
if (!versionOutput) return null
const match = /(\d+)\.(\d+)\.(\d+)/.exec(versionOutput)
if (!match) return null
return [Number(match[1]), Number(match[2]), Number(match[3])]
}

/**
* Whether the installed Claude Code supports resume-time truncation.
* `null`/unparseable output (detection failed) conservatively reports false so
* the rewind capability is not advertised against an unknown binary.
*/
export function supportsNativeRewind(versionOutput: string | null | undefined): boolean {
const version = parseClaudeVersion(versionOutput)
if (!version) return false
for (let i = 0; i < NATIVE_REWIND_MIN_VERSION.length; i++) {
const actual = version[i] ?? 0
const min = NATIVE_REWIND_MIN_VERSION[i]!
if (actual !== min) return actual > min
}
return true
}

/**
* Build the resume flags to drop turns `[dropFromTurnIndex, turns.length)`.
* The kept boundary is the last entry of the previous turn; dropping every
* turn including the first is not representable and is rejected.
*/
export function resolveRewindPlan(turns: NativeTurn[], dropFromTurnIndex: number): RewindPlan {
if (dropFromTurnIndex < 0 || dropFromTurnIndex >= turns.length) {
throw new Error('Selected message has no native history to drop')
}
if (dropFromTurnIndex === 0) {
throw new Error('Cannot rewind the first message')
}
return {
resumeSessionAt: turns[dropFromTurnIndex - 1]!.endUuid,
dropsTurns: turns.slice(dropFromTurnIndex).map((turn) => turn.promptUuid)
}
}
Loading
Loading