feat(sessions): add /sessions to resume or delete saved conversations (#2262)

* feat(sessions): add /sessions to resume or delete saved conversations

- New /sessions command: lists this repo's saved conversations; Enter opens
  Resume / Delete / Back; delete asks for confirmation (Cancel by default);
  `d` pressed twice deletes the focused session.
- Deleting removes the transcript and its sidecars (.cast, .replay.json,
  <id>/), refuses the active session and validates the id and path.
- Fix: late metadata writes no longer recreate a deleted transcript.
- Fix: launches that only run slash commands no longer create a session or
  print a resume hint at exit.

Closes #2223

* fix(sessions): keep writing to a transcript whose delete failed

- deleteSessionFiles: if removing the transcript fails, unmark it so
  later writes are not dropped while the file is still on disk
- forgetDeletedSession: also wait for in-flight direct appends to the
  deleted path, not only queued writes
- docstrings for sessionKey, isDeletedSessionFile, restoreSessionWrites

* fix(sessions): wait for every removal before restoring writes

A sidecar failing fast made Promise.all reject while the transcript was
still being removed, so writes were restored and could recreate it.
Settle all removals first; restore only if the transcript survived, and
treat a leftover sidecar as an orphan once the transcript is gone.

* fix(sessions): restore writes when the deletion barrier fails

forgetDeletedSession marks the transcript before flushing; if a pending
write fails, flush rejects and nothing is removed, but the transcript
stayed marked and later writes were skipped. Undo the mark and rethrow.

* fix(sessions): keep the list usable when a resume fails to load

loadFullLog ran outside any error handling, so a transcript that failed
to load left the dialog busy and ignoring input. Report the error, clear
busy and return to the list.

* fix(sessions): do not resume a transcript that failed to load

loadFullLog falls back to the message-less lite log when a transcript
can't be read, so resuming it opened an empty conversation. Treat that
fallback as a load failure, and test it with the real loader.

* fix(sessions): drop buffered entries when the session changes

Local-command entries are buffered until the first real message. If the
active session switched before that, they were written into the new
session's transcript. Track the session that owns the buffer and drop it
on a switch.
This commit is contained in:
Borges
2026-10-14 12:36:28 +08:00
committed by GitHub
parent e736c28257
commit fc3be25e25
13 changed files with 1286 additions and 9 deletions
+9
View File
@@ -214,6 +214,15 @@ openclaude --continue --fork-session
Forking is conversation branching only. It does not create filesystem isolation,
copy your working tree, or create a git worktree branch.
### Manage saved conversations
Run `/sessions` inside OpenClaude to list this project's saved conversations.
Press Enter on one to **Resume**, **Delete** (asks for confirmation, defaulting
to Cancel), or go **Back** to the list. As a shortcut, press `d` on a row and
then `d` again to delete it; any other key in between cancels. Deleting removes
the transcript and its sidecar files from disk and cannot be undone. The
session you are currently in is not listed and cannot be deleted.
### Background sessions
Run long non-interactive prompts detached from the current terminal:
+2
View File
@@ -56,6 +56,7 @@ import resume, { continueCommand } from './commands/resume/index.js'
import review, { ultrareview } from './commands/review.js'
import search from './commands/search/index.js'
import session from './commands/session/index.js'
import sessions from './commands/sessions/index.js'
import setContextWindow from './commands/set-context-window/index.js'
import share from './commands/share/index.js'
import skills from './commands/skills/index.js'
@@ -342,6 +343,7 @@ const COMMANDS = memoize((): Command[] => [
resume,
search,
session,
sessions,
setContextWindow,
skills,
stats,
+10
View File
@@ -0,0 +1,10 @@
import type { Command } from '../../commands.js'
const sessions = {
type: 'local-jsx',
name: 'sessions',
description: 'List saved conversations to resume or delete them',
load: () => import('./sessions.js'),
} satisfies Command
export default sessions
+284
View File
@@ -0,0 +1,284 @@
import { existsSync } from 'fs'
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { PassThrough } from 'node:stream'
import { stripVTControlCharacters as stripAnsi } from 'node:util'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import * as React from 'react'
import { createRoot } from '../../ink.js'
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
import { AppStateProvider } from '../../state/AppState.js'
import type { LocalJSXCommandContext } from '../../types/command.js'
import type { LogOption } from '../../types/logs.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
const ENTER = '\r'
const ESC = '\x1b'
const DOWN = '\x1b[B'
const ALPHA = '11111111-1111-4111-8111-111111111111'
const BETA = '22222222-2222-4222-8222-222222222222'
const GAMMA = '33333333-3333-4333-8333-333333333333'
type TestStdin = PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
let projectDir: string
let allLogs: LogOption[]
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) break
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) break
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) lastFrame = frame
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
async function waitUntil<T>(read: () => T, predicate: (value: T) => boolean): Promise<T> {
const startedAt = Date.now()
while (Date.now() - startedAt < 2500) {
const value = read()
if (predicate(value)) return value
await Bun.sleep(10)
}
throw new Error(`Timed out; last value: ${String(read())}`)
}
async function makeLog(sessionId: string, title: string, minutesAgo: number): Promise<LogOption> {
const fullPath = join(projectDir, `${sessionId}.jsonl`)
await writeFile(fullPath, '{}\n')
await mkdir(join(projectDir, sessionId), { recursive: true })
const modified = new Date(Date.now() - minutesAgo * 60_000)
return {
date: modified.toISOString(),
messages: [],
fullPath,
value: minutesAgo,
created: modified,
modified,
firstPrompt: title,
customTitle: title,
messageCount: 2,
isSidechain: false,
sessionId,
}
}
async function runSessions() {
const { call } = await import(`./sessions.js?${Date.now()}-${Math.random()}`)
const done: Array<string | undefined> = []
const resume = mock((_sessionId: string, _log: LogOption, _entrypoint: string) =>
Promise.resolve(),
)
const node = await call(
(result?: string) => {
done.push(result)
},
{ resume } as unknown as LocalJSXCommandContext,
'',
)
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as TestStdin
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(
<AppStateProvider>
<KeybindingSetup>{node}</KeybindingSetup>
</AppStateProvider>,
)
const frame = (): string => stripAnsi(extractLastFrame(output))
return {
done,
resume,
waitFor: (predicate: (f: string) => boolean) => waitUntil(frame, predicate),
press: async (...keys: string[]) => {
for (const key of keys) {
stdin.write(key)
await Bun.sleep(30)
}
},
unmount: () => {
root.unmount()
stdin.end()
stdout.end()
},
}
}
describe('/sessions', () => {
let mounted: Awaited<ReturnType<typeof runSessions>> | undefined
beforeEach(async () => {
projectDir = await mkdtemp(join(tmpdir(), 'openclaude-sessions-cmd-'))
allLogs = [
await makeLog(ALPHA, 'alpha task', 1),
await makeLog(BETA, 'beta task', 2),
await makeLog(GAMMA, 'gamma task', 3),
]
const real = await import('../../utils/sessionStorage.js')
mock.module('../../utils/getWorktreePaths.js', () => ({
getWorktreePaths: () => Promise.resolve([]),
}))
// Mirror the disk: a deleted transcript drops out of the next reload.
mock.module('../../utils/sessionStorage.js', () => ({
...real,
getSessionIdFromLog: (log: LogOption) => log.sessionId,
isLiteLog: () => false,
loadSameRepoMessageLogs: () =>
Promise.resolve(allLogs.filter(log => existsSync(log.fullPath!))),
}))
})
afterEach(async () => {
mounted?.unmount()
mounted = undefined
mock.restore()
await rm(projectDir, { recursive: true, force: true })
})
test('lists saved conversations with navigation and delete hints', async () => {
mounted = await runSessions()
const frame = await mounted.waitFor(f => f.includes('Sessions (3)'))
expect(frame).toContain('alpha task')
expect(frame).toContain('gamma task')
expect(frame).toContain('d d delete')
expect(frame).toContain('Esc close')
})
test('Enter opens Resume / Delete / Back, and Back returns to the list', async () => {
mounted = await runSessions()
await mounted.waitFor(f => f.includes('Sessions (3)'))
await mounted.press(ENTER)
const actions = await mounted.waitFor(f => f.includes('Return to the list'))
expect(actions).toContain('Resume')
expect(actions).toContain('Delete')
await mounted.press(DOWN, DOWN, ENTER)
expect(await mounted.waitFor(f => f.includes('Sessions (3)'))).toContain('Sessions (3)')
})
test('Esc in the sub-menu goes back instead of closing', async () => {
mounted = await runSessions()
await mounted.waitFor(f => f.includes('Sessions (3)'))
await mounted.press(ENTER)
await mounted.waitFor(f => f.includes('Return to the list'))
await mounted.press(ESC)
expect(await mounted.waitFor(f => f.includes('Sessions (3)'))).toContain('Sessions (3)')
expect(mounted.done).toEqual([])
})
test('Delete asks for confirmation defaulting to Cancel, then removes the files', async () => {
mounted = await runSessions()
await mounted.waitFor(f => f.includes('Sessions (3)'))
await mounted.press(DOWN, ENTER)
await mounted.waitFor(f => f.includes('Return to the list'))
await mounted.press(DOWN, ENTER)
const confirm = await mounted.waitFor(f => f.includes('This cannot be undone'))
expect(confirm).toContain('Delete "beta task"?')
// Enter on the default option cancels.
await mounted.press(ENTER)
await mounted.waitFor(f => f.includes('Return to the list'))
expect(existsSync(join(projectDir, `${BETA}.jsonl`))).toBe(true)
await mounted.press(ENTER)
await mounted.waitFor(f => f.includes('This cannot be undone'))
await mounted.press(DOWN, ENTER)
const after = await mounted.waitFor(f => f.includes('Deleted "beta task"'))
expect(after).toContain('Sessions (2)')
expect(existsSync(join(projectDir, `${BETA}.jsonl`))).toBe(false)
expect(existsSync(join(projectDir, BETA))).toBe(false)
expect(existsSync(join(projectDir, `${ALPHA}.jsonl`))).toBe(true)
})
test('d pressed twice deletes the focused conversation; another key disarms it', async () => {
mounted = await runSessions()
await mounted.waitFor(f => f.includes('Sessions (3)'))
await mounted.press(DOWN, DOWN, 'd')
await mounted.waitFor(f => f.includes('Press d again to delete "gamma task"'))
await mounted.press('x')
await mounted.waitFor(f => !f.includes('Press d again'))
expect(existsSync(join(projectDir, `${GAMMA}.jsonl`))).toBe(true)
await mounted.press('d', 'd')
const after = await mounted.waitFor(f => f.includes('Deleted "gamma task"'))
expect(after).toContain('Sessions (2)')
expect(existsSync(join(projectDir, `${GAMMA}.jsonl`))).toBe(false)
})
test('Resume hands the session to the REPL resume flow', async () => {
mounted = await runSessions()
await mounted.waitFor(f => f.includes('Sessions (3)'))
await mounted.press(DOWN, ENTER)
await mounted.waitFor(f => f.includes('Return to the list'))
await mounted.press(ENTER)
await waitUntil(() => mounted!.resume.mock.calls.length, n => n > 0)
expect(mounted.resume.mock.calls[0]?.[0]).toBe(BETA)
expect(mounted.resume.mock.calls[0]?.[2]).toBe('slash_command_picker')
})
test('a transcript that fails to load is not resumed and leaves the list usable', async () => {
// Real loadFullLog (kept by beforeEach's spread): the test transcripts hold
// no messages, so it falls back to the lite log, as for unreadable files.
const real = await import('../../utils/sessionStorage.js')
mock.module('../../utils/sessionStorage.js', () => ({
...real,
getSessionIdFromLog: (log: LogOption) => log.sessionId,
// beforeEach stubs isLiteLog to false; restore the upstream check.
isLiteLog: (log: LogOption) =>
log.messages.length === 0 && log.sessionId !== undefined,
loadSameRepoMessageLogs: () =>
Promise.resolve(allLogs.filter(log => existsSync(log.fullPath!))),
}))
mounted = await runSessions()
await mounted.waitFor(f => f.includes('Sessions (3)'))
await mounted.press(DOWN, ENTER)
await mounted.waitFor(f => f.includes('Return to the list'))
await mounted.press(ENTER)
await mounted.waitFor(f => f.includes('Transcript could not be loaded'))
expect(mounted.resume.mock.calls.length).toBe(0)
// Not stuck as busy: the list still takes input.
await mounted.press(ENTER)
await mounted.waitFor(f => f.includes('Return to the list'))
})
test('reports when there is nothing to list', async () => {
allLogs = []
mounted = await runSessions()
await waitUntil(() => mounted!.done.length, n => n > 0)
expect(mounted.done[0]).toBe('No saved conversations in this project.')
})
})
+296
View File
@@ -0,0 +1,296 @@
import type { UUID } from 'crypto'
import * as React from 'react'
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
import { Select } from '../../components/CustomSelect/index.js'
import { Dialog } from '../../components/design-system/Dialog.js'
import { Box, Text, useInput } from '../../ink.js'
import type {
LocalJSXCommandCall,
LocalJSXCommandOnDone,
ResumeEntrypoint,
} from '../../types/command.js'
import type { LogOption } from '../../types/logs.js'
import { formatLogMetadata } from '../../utils/format.js'
import { getWorktreePaths } from '../../utils/getWorktreePaths.js'
import { getLogDisplayTitle, logError } from '../../utils/log.js'
import { deleteSessionFiles } from '../../utils/sessionDeletion.js'
import {
getSessionIdFromLog,
getTranscriptPathForSession,
isLiteLog,
loadFullLog,
loadSameRepoMessageLogs,
} from '../../utils/sessionStorage.js'
import { validateUuid } from '../../utils/uuid.js'
import { filterResumableSessions } from '../resume/resume.js'
type View =
| { kind: 'list' }
| { kind: 'actions'; log: LogOption; focus?: 'resume' | 'delete' | 'back' }
| { kind: 'confirm-delete'; log: LogOption }
type Notice = { text: string; error?: boolean }
type OnResume = (
sessionId: UUID,
log: LogOption,
entrypoint: ResumeEntrypoint,
) => Promise<void>
const SESSIONS_LIST_HINT =
'↑/↓ navigate · Enter options · d d delete · Esc close'
/** Stable React key and focus id for a saved conversation row. */
function sessionKey(log: LogOption): string {
return getSessionIdFromLog(log) ?? log.fullPath ?? String(log.value)
}
/**
* `/sessions`: list this repo's saved conversations, open one to resume or
* delete it, and go back without leaving the command. `d` pressed twice on
* a row deletes it directly (#2223); Ctrl+D stays the global exit key.
*/
function SessionsManager({
onDone,
onResume,
}: {
onDone: LocalJSXCommandOnDone
onResume: OnResume
}): React.ReactNode {
const [logs, setLogs] = React.useState<LogOption[] | null>(null)
const [view, setView] = React.useState<View>({ kind: 'list' })
const [focusedKey, setFocusedKey] = React.useState<string | undefined>()
const [pendingDeleteKey, setPendingDeleteKey] = React.useState<string | null>(null)
const [notice, setNotice] = React.useState<Notice | null>(null)
const [busy, setBusy] = React.useState(false)
// Remounts the list so it picks up a new default focus after a reload.
const [listVersion, setListVersion] = React.useState(0)
const loadLogs = React.useCallback(async (): Promise<LogOption[]> => {
const paths = await getWorktreePaths(getOriginalCwd())
const all = await loadSameRepoMessageLogs(paths)
return filterResumableSessions(all, getSessionId())
}, [])
React.useEffect(() => {
loadLogs()
.then(loaded => {
if (loaded.length === 0) {
onDone('No saved conversations in this project.', { display: 'system' })
return
}
setLogs(loaded)
setFocusedKey(sessionKey(loaded[0]!))
})
.catch((error: unknown) => {
logError(error as Error)
onDone('Failed to load conversations', { display: 'system' })
})
}, [loadLogs, onDone])
const close = React.useCallback(() => {
onDone(undefined, { display: 'skip' })
}, [onDone])
const backToList = (log?: LogOption) => {
if (log) setFocusedKey(sessionKey(log))
setView({ kind: 'list' })
setListVersion(v => v + 1)
}
const deleteLog = async (log: LogOption) => {
if (!logs) return
setPendingDeleteKey(null)
setBusy(true)
const index = logs.findIndex(l => sessionKey(l) === sessionKey(log))
const sessionId = getSessionIdFromLog(log) ?? ''
const title = getLogDisplayTitle(log)
try {
const result = await deleteSessionFiles({
sessionId,
transcriptPath: log.fullPath ?? getTranscriptPathForSession(sessionId),
currentSessionId: getSessionId(),
})
if (!result.ok) {
setNotice({ text: result.reason, error: true })
backToList(log)
return
}
const remaining = await loadLogs()
if (remaining.length === 0) {
onDone(`Deleted "${title}". No saved conversations left.`, { display: 'system' })
return
}
setLogs(remaining)
// Keep focus on the same row position rather than jumping elsewhere.
setFocusedKey(sessionKey(remaining[Math.min(Math.max(index, 0), remaining.length - 1)]!))
setNotice({ text: `Deleted "${title}".` })
setView({ kind: 'list' })
setListVersion(v => v + 1)
} catch (error) {
logError(error as Error)
setNotice({ text: `Failed to delete "${title}": ${(error as Error).message}`, error: true })
backToList(log)
} finally {
setBusy(false)
}
}
const resumeLog = async (log: LogOption) => {
const sessionId = validateUuid(getSessionIdFromLog(log))
if (!sessionId) {
setNotice({ text: 'Failed to resume conversation', error: true })
return
}
setBusy(true)
try {
const fullLog = isLiteLog(log) ? await loadFullLog(log) : log
// loadFullLog falls back to the message-less lite log when the
// transcript can't be read; resuming it would open an empty conversation.
if (isLiteLog(fullLog)) {
throw new Error('Transcript could not be loaded')
}
await onResume(sessionId, fullLog, 'slash_command_picker')
} catch (error) {
// Loading the transcript failed (onResume reports its own errors): stay
// usable instead of leaving the dialog stuck as busy.
logError(error as Error)
setNotice({
text: `Failed to resume "${getLogDisplayTitle(log)}": ${(error as Error).message}`,
error: true,
})
setBusy(false)
backToList(log)
}
}
useInput(
(input, key) => {
if (!logs || key.ctrl || key.meta) return
const focused = logs.find(l => sessionKey(l) === focusedKey)
if (input === 'd' && focused) {
if (pendingDeleteKey === sessionKey(focused)) {
void deleteLog(focused)
} else {
setPendingDeleteKey(sessionKey(focused))
setNotice({ text: `Press d again to delete "${getLogDisplayTitle(focused)}".` })
}
return
}
if (pendingDeleteKey) {
setPendingDeleteKey(null)
setNotice(null)
}
},
{ isActive: view.kind === 'list' && !busy && logs !== null },
)
if (!logs) {
return <Text dimColor>Loading conversations…</Text>
}
const noticeLine = notice ? (
<Text color={notice.error ? 'error' : 'warning'}>{notice.text}</Text>
) : null
if (view.kind === 'confirm-delete') {
const { log } = view
return (
<Dialog
title={`Delete "${getLogDisplayTitle(log)}"?`}
subtitle={`${formatLogMetadata(log)} · This cannot be undone.`}
color="error"
onCancel={() => setView({ kind: 'actions', log, focus: 'delete' })}
>
<Select
options={[
{ label: 'Cancel', value: 'cancel' },
{ label: 'Delete', value: 'delete', color: 'error' },
]}
isDisabled={busy}
onChange={value => {
if (value === 'delete') void deleteLog(log)
else setView({ kind: 'actions', log, focus: 'delete' })
}}
onCancel={() => setView({ kind: 'actions', log, focus: 'delete' })}
/>
</Dialog>
)
}
if (view.kind === 'actions') {
const { log } = view
return (
<Dialog
title={getLogDisplayTitle(log)}
subtitle={formatLogMetadata(log)}
onCancel={() => backToList(log)}
>
<Box flexDirection="column">
{noticeLine}
<Select
options={[
{ label: 'Resume', value: 'resume', description: 'Continue this conversation' },
{ label: 'Delete', value: 'delete', description: 'Remove it from disk' },
{ label: 'Back', value: 'back', description: 'Return to the list' },
]}
defaultFocusValue={view.focus}
isDisabled={busy}
onChange={value => {
setNotice(null)
if (value === 'resume') void resumeLog(log)
else if (value === 'delete') setView({ kind: 'confirm-delete', log })
else backToList(log)
}}
onCancel={() => backToList(log)}
/>
</Box>
</Dialog>
)
}
return (
<Dialog
title={`Sessions (${logs.length})`}
subtitle={SESSIONS_LIST_HINT}
onCancel={close}
>
<Box flexDirection="column">
{noticeLine}
<Select
key={listVersion}
options={logs.map(log => ({
label: getLogDisplayTitle(log),
value: sessionKey(log),
description: formatLogMetadata(log),
color: pendingDeleteKey === sessionKey(log) ? 'error' : undefined,
}))}
defaultFocusValue={focusedKey}
isDisabled={busy}
onFocus={setFocusedKey}
onChange={value => {
const log = logs.find(l => sessionKey(l) === value)
if (!log) return
setNotice(null)
setPendingDeleteKey(null)
setView({ kind: 'actions', log })
}}
onCancel={close}
/>
</Box>
</Dialog>
)
}
export const call: LocalJSXCommandCall = async (onDone, context) => {
const onResume: OnResume = async (sessionId, log, entrypoint) => {
try {
await context.resume?.(sessionId, log, entrypoint)
onDone(undefined, { display: 'skip' })
} catch (error) {
logError(error as Error)
onDone(`Failed to resume: ${(error as Error).message}`)
}
}
return <SessionsManager onDone={onDone} onResume={onResume} />
}
+1
View File
@@ -47,6 +47,7 @@ const openClaudeCommandDescriptionKeys: Record<string, LocalizationKey> = {
rename: 'commands.rename.description',
'request-size': 'commands.request-size.description',
resume: 'commands.resume.description',
sessions: 'commands.sessions.description',
review: 'commands.review.description',
rewind: 'commands.rewind.description',
'security-review': 'commands.security-review.description',
+2
View File
@@ -68,6 +68,8 @@ export const en = {
'commands.request-size.description':
'Show estimated request context load and top contributors',
'commands.resume.description': 'Resume a previous conversation',
'commands.sessions.description':
'List saved conversations to resume or delete them',
'commands.review.description': 'Review a pull request',
'commands.rewind.description':
'Restore the code and/or conversation to a previous point',
+2
View File
@@ -68,6 +68,8 @@ export const vi = {
'commands.request-size.description':
'Hiện tải ngữ cảnh ước tính và các thành phần chính',
'commands.resume.description': 'Tiếp tục cuộc hội thoại trước',
'commands.sessions.description':
'Liệt kê các cuộc hội thoại đã lưu để tiếp tục hoặc xóa',
'commands.review.description': 'Đánh giá pull request',
'commands.rewind.description':
'Khôi phục mã và/hoặc cuộc hội thoại về điểm trước',
+106
View File
@@ -0,0 +1,106 @@
import { existsSync } from 'fs'
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { deleteSessionFiles } from './sessionDeletion.js'
const SESSION = '11111111-1111-4111-8111-111111111111'
const OTHER = '22222222-2222-4222-8222-222222222222'
const CURRENT = '33333333-3333-4333-8333-333333333333'
let projectDir: string
async function writeSession(id: string): Promise<string> {
const transcript = join(projectDir, `${id}.jsonl`)
await writeFile(transcript, '{}\n')
await writeFile(join(projectDir, `${id}.cast`), '')
await writeFile(join(projectDir, `${id}.replay.json`), '{}')
await mkdir(join(projectDir, id, 'tool-results'), { recursive: true })
await writeFile(join(projectDir, id, 'tool-results', 'out.txt'), 'x')
return transcript
}
beforeEach(async () => {
projectDir = await mkdtemp(join(tmpdir(), 'openclaude-session-delete-'))
})
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true })
})
describe('deleteSessionFiles', () => {
test('removes the transcript and every sidecar, leaving other sessions', async () => {
const transcript = await writeSession(SESSION)
const other = await writeSession(OTHER)
const result = await deleteSessionFiles({
sessionId: SESSION,
transcriptPath: transcript,
currentSessionId: CURRENT,
})
expect(result.ok).toBe(true)
expect(existsSync(transcript)).toBe(false)
expect(existsSync(join(projectDir, `${SESSION}.cast`))).toBe(false)
expect(existsSync(join(projectDir, `${SESSION}.replay.json`))).toBe(false)
expect(existsSync(join(projectDir, SESSION))).toBe(false)
expect(existsSync(other)).toBe(true)
expect(existsSync(join(projectDir, OTHER))).toBe(true)
})
test('succeeds when optional sidecars are missing', async () => {
const transcript = join(projectDir, `${SESSION}.jsonl`)
await writeFile(transcript, '{}\n')
const result = await deleteSessionFiles({
sessionId: SESSION,
transcriptPath: transcript,
currentSessionId: CURRENT,
})
expect(result.ok).toBe(true)
expect(existsSync(transcript)).toBe(false)
})
test('refuses to delete the current session', async () => {
const transcript = await writeSession(CURRENT)
const result = await deleteSessionFiles({
sessionId: CURRENT,
transcriptPath: transcript,
currentSessionId: CURRENT,
})
expect(result).toEqual({
ok: false,
reason: 'The current session cannot be deleted.',
})
expect(existsSync(transcript)).toBe(true)
})
test('rejects ids that are not UUIDs, so paths cannot escape the project dir', async () => {
const result = await deleteSessionFiles({
sessionId: '../../etc',
transcriptPath: join(projectDir, '../../etc.jsonl'),
currentSessionId: CURRENT,
})
expect(result.ok).toBe(false)
})
test('rejects a transcript path that belongs to another session', async () => {
const other = await writeSession(OTHER)
const result = await deleteSessionFiles({
sessionId: SESSION,
transcriptPath: other,
currentSessionId: CURRENT,
})
expect(result.ok).toBe(false)
expect(existsSync(other)).toBe(true)
})
})
+85
View File
@@ -0,0 +1,85 @@
import { existsSync } from 'fs'
import { rm } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { logError } from './log.js'
import { forgetDeletedSession, restoreSessionWrites } from './sessionStorage.js'
import { validateUuid } from './uuid.js'
export type DeleteSessionResult =
| { ok: true; removed: string[] }
| { ok: false; reason: string }
/**
* Deletes a saved conversation from disk: the transcript
* (`<projectDir>/<sessionId>.jsonl`) plus its sidecars, the same set
* `cleanupOldSessionFiles` removes (`.cast`, `.replay.json` and the
* `<sessionId>/` dir holding tool-results and subagent transcripts).
*
* Refuses the active session: its transcript is still being appended to, and
* removing it under a live REPL breaks later writes.
*/
export async function deleteSessionFiles({
sessionId,
transcriptPath,
currentSessionId,
}: {
sessionId: string
transcriptPath: string
currentSessionId: string
}): Promise<DeleteSessionResult> {
const id = validateUuid(sessionId)
if (!id) {
return { ok: false, reason: `Invalid session id: ${sessionId}` }
}
if (id === currentSessionId) {
return { ok: false, reason: 'The current session cannot be deleted.' }
}
// Only ever touch files named after the validated id, next to the transcript.
if (basename(transcriptPath) !== `${id}.jsonl`) {
return {
ok: false,
reason: `Transcript path does not match session ${id}.`,
}
}
// Block later writes first, or a pending AI title/summary/metadata append
// recreates the transcript and the session comes back.
try {
await forgetDeletedSession(id, transcriptPath)
} catch (error) {
// Nothing was removed (a pending write failed while settling): undo the
// mark so the still-present transcript keeps receiving writes.
restoreSessionWrites(transcriptPath)
throw error
}
const projectDir = dirname(transcriptPath)
const targets = [
transcriptPath,
join(projectDir, `${id}.cast`),
join(projectDir, `${id}.replay.json`),
join(projectDir, id),
]
// force: sidecars are optional, so a missing one is not an error.
// allSettled: decide on the transcript only after every removal finished,
// or a fast sidecar failure would restore writes mid-removal.
const results = await Promise.allSettled(
targets.map(target => rm(target, { recursive: true, force: true })),
)
const failures = results.flatMap(result =>
result.status === 'rejected' ? [result.reason] : [],
)
if (failures.length > 0 && existsSync(transcriptPath)) {
// The transcript survived (e.g. EBUSY on Windows): stop dropping writes
// to it, or disk and this process disagree until restart.
restoreSessionWrites(transcriptPath)
throw failures[0]
}
// The transcript is gone, so the conversation is deleted; a sidecar that
// could not be removed is only an orphan, like any missing one.
for (const failure of failures) logError(failure)
return {
ok: true,
removed: targets.filter((_, i) => results[i]!.status === 'fulfilled'),
}
}
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { type UUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
import {
getSessionId,
isSessionPersistenceDisabled,
setSessionPersistenceDisabled,
switchSession,
} from '../bootstrap/state.js'
import type { Message } from '../types/message.js'
import { setClaudeConfigHomeDirForTesting } from './envUtils.js'
import {
createAssistantMessage,
createUserMessage,
NO_RESPONSE_REQUESTED,
} from './messages.js'
import {
flushSessionStorage,
getTranscriptPathForSession,
recordTranscript,
resetProjectForTesting,
resetSessionFilePointer,
sessionIdExists,
} from './sessionStorage.js'
const SESSION = '00000000-0000-4000-8000-00000000c001' as UUID
const OTHER = '00000000-0000-4000-8000-00000000c002' as UUID
const tempDirs: string[] = []
const savedEnv = {
NODE_ENV: process.env.NODE_ENV,
TEST_ENABLE_SESSION_PERSISTENCE: process.env.TEST_ENABLE_SESSION_PERSISTENCE,
ENABLE_SESSION_PERSISTENCE: process.env.ENABLE_SESSION_PERSISTENCE,
}
let originalSessionId: string
let originalPersistenceDisabled: boolean
beforeEach(async () => {
await acquireSharedMutationLock('utils/sessionStorage.commandOnlySession.test.ts')
originalSessionId = getSessionId()
originalPersistenceDisabled = isSessionPersistenceDisabled()
process.env.NODE_ENV = 'development'
process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true'
process.env.ENABLE_SESSION_PERSISTENCE = 'true'
setSessionPersistenceDisabled(false)
const configDir = await mkdtemp(join(tmpdir(), 'openclaude-command-only-'))
tempDirs.push(configDir)
setClaudeConfigHomeDirForTesting(configDir)
resetProjectForTesting()
switchSession(SESSION as never)
await resetSessionFilePointer()
})
afterEach(async () => {
try {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
setSessionPersistenceDisabled(originalPersistenceDisabled)
switchSession(originalSessionId as never)
resetProjectForTesting()
await Promise.all(
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
)
} finally {
releaseSharedMutationLock()
}
})
/** What a local slash command such as `/clear` or `/exit` records. */
function localCommand(name: string, output: string): Message[] {
return [
createUserMessage({
content: '<local-command-caveat>Caveat: generated by the user.</local-command-caveat>',
isMeta: true,
}),
createUserMessage({
content: `<command-name>/${name}</command-name>\n<command-message>${name}</command-message>\n<command-args></command-args>`,
}),
createUserMessage({
content: `<local-command-stdout>${output}</local-command-stdout>`,
}),
]
}
test('a launch that only runs local commands leaves no transcript', async () => {
const messages = [
...localCommand('clear', ''),
...localCommand('exit', 'Catch you later!'),
createAssistantMessage({ content: NO_RESPONSE_REQUESTED }),
]
await recordTranscript(messages)
await flushSessionStorage()
expect(existsSync(getTranscriptPathForSession(SESSION))).toBe(false)
// No file means no "Resume this session with" hint at exit.
expect(sessionIdExists(SESSION)).toBe(false)
})
test('the first real prompt saves the session with the earlier commands', async () => {
const commands = localCommand('model', 'Set model to test')
await recordTranscript(commands)
const prompt = createUserMessage({ content: 'explain this repo' })
await recordTranscript([...commands, prompt])
await flushSessionStorage()
const transcript = getTranscriptPathForSession(SESSION)
expect(existsSync(transcript)).toBe(true)
const uuids = (await readFile(transcript, 'utf8'))
.trim()
.split('\n')
.map(line => JSON.parse(line).uuid)
for (const message of [...commands, prompt]) {
expect(uuids).toContain(message.uuid)
}
})
test('commands buffered for one session are not written into the next one', async () => {
const commands = localCommand('model', 'Set model to test')
await recordTranscript(commands)
// An SDK-style switch: the active session changes without resetting the
// shared session-file pointer.
switchSession(OTHER as never)
const prompt = createUserMessage({ content: 'explain this repo' })
await recordTranscript([prompt])
await flushSessionStorage()
const uuids = (await readFile(getTranscriptPathForSession(OTHER), 'utf8'))
.trim()
.split('\n')
.map(line => JSON.parse(line).uuid)
expect(uuids).toContain(prompt.uuid)
for (const message of commands) {
expect(uuids).not.toContain(message.uuid)
}
})
test('a model reply also saves the session', async () => {
const reply = createAssistantMessage({ content: 'hello' })
reply.message.model = 'test-model'
await recordTranscript([...localCommand('clear', ''), reply])
await flushSessionStorage()
expect(existsSync(getTranscriptPathForSession(SESSION))).toBe(true)
})
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test'
import { type UUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import * as fsPromises from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
import {
getSessionId,
isSessionPersistenceDisabled,
setSessionPersistenceDisabled,
switchSession,
} from '../bootstrap/state.js'
import { createGoalState } from '../services/goal/state.js'
import { setClaudeConfigHomeDirForTesting } from './envUtils.js'
import { deleteSessionFiles } from './sessionDeletion.js'
import * as sessionStorage from './sessionStorage.js'
import {
flushSessionStorage,
getTranscriptPathForSession,
recordGoalState,
resetProjectForTesting,
resetSessionFilePointer,
saveAiGeneratedTitle,
saveCustomTitle,
saveTaskSummary,
} from './sessionStorage.js'
const DELETED = '00000000-0000-4000-8000-00000000d001' as UUID
const ACTIVE = '00000000-0000-4000-8000-00000000d002' as UUID
const tempDirs: string[] = []
const savedEnv = {
NODE_ENV: process.env.NODE_ENV,
TEST_ENABLE_SESSION_PERSISTENCE: process.env.TEST_ENABLE_SESSION_PERSISTENCE,
ENABLE_SESSION_PERSISTENCE: process.env.ENABLE_SESSION_PERSISTENCE,
}
let originalSessionId: string
let originalPersistenceDisabled: boolean
beforeEach(async () => {
await acquireSharedMutationLock('utils/sessionStorage.deletedSession.test.ts')
originalSessionId = getSessionId()
originalPersistenceDisabled = isSessionPersistenceDisabled()
process.env.NODE_ENV = 'development'
process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true'
process.env.ENABLE_SESSION_PERSISTENCE = 'true'
setSessionPersistenceDisabled(false)
const configDir = await mkdtemp(join(tmpdir(), 'openclaude-deleted-session-'))
tempDirs.push(configDir)
setClaudeConfigHomeDirForTesting(configDir)
resetProjectForTesting()
})
afterEach(async () => {
try {
mock.restore()
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
setSessionPersistenceDisabled(originalPersistenceDisabled)
switchSession(originalSessionId as never)
resetProjectForTesting()
await Promise.all(
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
)
} finally {
releaseSharedMutationLock()
}
})
/** A saved conversation that this process has already written to. */
async function writtenOtherSession(): Promise<string> {
const transcript = getTranscriptPathForSession(DELETED)
await mkdir(dirname(transcript), { recursive: true })
await writeFile(
transcript,
`${JSON.stringify({ type: 'user', sessionId: DELETED, message: { role: 'user', content: 'hi' } })}\n`,
)
switchSession(ACTIVE as never)
await resetSessionFilePointer()
// Warms the "file exists" cache used for queued writes to other sessions.
await recordGoalState(createGoalState('before delete'), DELETED)
await flushSessionStorage()
return transcript
}
test('late metadata writes do not resurrect a deleted session transcript', async () => {
const transcript = await writtenOtherSession()
const result = await deleteSessionFiles({
sessionId: DELETED,
transcriptPath: transcript,
currentSessionId: ACTIVE,
})
expect(result.ok).toBe(true)
// Writers that finish after the delete: async AI title, periodic task
// summary, and a title write through the cached file path.
saveAiGeneratedTitle(DELETED, 'late ai title')
saveTaskSummary(DELETED, 'late summary')
await saveCustomTitle(DELETED, 'late title', transcript)
await recordGoalState(createGoalState('late goal'), DELETED)
await flushSessionStorage()
expect(existsSync(transcript)).toBe(false)
})
test('writes queued before the delete are dropped, not flushed after it', async () => {
const transcript = await writtenOtherSession()
// Queued but not yet drained when the user confirms the delete.
void recordGoalState(createGoalState('queued goal'), DELETED)
const result = await deleteSessionFiles({
sessionId: DELETED,
transcriptPath: transcript,
currentSessionId: ACTIVE,
})
expect(result.ok).toBe(true)
await flushSessionStorage()
expect(existsSync(transcript)).toBe(false)
})
test('a transcript that could not be removed keeps receiving writes', async () => {
const transcript = await writtenOtherSession()
const realRm = fsPromises.rm
spyOn(fsPromises, 'rm').mockImplementation(async (path, options) => {
if (path === transcript) {
throw Object.assign(new Error('resource busy or locked'), { code: 'EBUSY' })
}
return realRm(path, options)
})
await expect(
deleteSessionFiles({
sessionId: DELETED,
transcriptPath: transcript,
currentSessionId: ACTIVE,
}),
).rejects.toThrow('resource busy or locked')
mock.restore()
await recordGoalState(createGoalState('after failed delete'), DELETED)
await flushSessionStorage()
expect(await readFile(transcript, 'utf8')).toContain('after failed delete')
})
test('a sidecar failing first does not restore writes while the transcript is removed', async () => {
const transcript = await writtenOtherSession()
const realRm = fsPromises.rm
spyOn(fsPromises, 'rm').mockImplementation(async (path, options) => {
if (path === transcript) {
// Still removing when the sidecar below has already failed.
await new Promise(resolve => setTimeout(resolve, 20))
} else if (String(path).endsWith('.cast')) {
throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' })
}
return realRm(path, options)
})
const result = await deleteSessionFiles({
sessionId: DELETED,
transcriptPath: transcript,
currentSessionId: ACTIVE,
})
mock.restore()
expect(result.ok).toBe(true)
await recordGoalState(createGoalState('late goal'), DELETED)
await flushSessionStorage()
expect(existsSync(transcript)).toBe(false)
})
test('a failed deletion barrier keeps the transcript writable', async () => {
const transcript = await writtenOtherSession()
const realForget = sessionStorage.forgetDeletedSession
// Marks the path, then fails like a flush whose pending write errored.
spyOn(sessionStorage, 'forgetDeletedSession').mockImplementation(
async (sessionId, path) => {
await realForget(sessionId, path)
throw new Error('pending write failed')
},
)
await expect(
deleteSessionFiles({
sessionId: DELETED,
transcriptPath: transcript,
currentSessionId: ACTIVE,
}),
).rejects.toThrow('pending write failed')
mock.restore()
await recordGoalState(createGoalState('after failed barrier'), DELETED)
await flushSessionStorage()
expect(await readFile(transcript, 'utf8')).toContain('after failed barrier')
})
+130 -9
View File
@@ -16,7 +16,7 @@ import {
writeFile,
} from 'fs/promises'
import memoize from 'lodash-es/memoize.js'
import { basename, dirname, join } from 'path'
import { basename, dirname, join, resolve as resolvePath } from 'path'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
@@ -30,7 +30,13 @@ import {
getSessionProjectDir,
switchSession,
} from '../bootstrap/state.js'
import { COMMAND_NAME_TAG, TICK_TAG } from '../constants/xml.js'
import {
COMMAND_NAME_TAG,
LOCAL_COMMAND_CAVEAT_TAG,
LOCAL_COMMAND_STDERR_TAG,
LOCAL_COMMAND_STDOUT_TAG,
TICK_TAG,
} from '../constants/xml.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import * as sessionIngress from '../services/api/sessionIngress.js'
import { REPL_TOOL_NAME } from '../tools/REPLTool/constants.js'
@@ -82,7 +88,11 @@ import { getBranch } from './git.js'
import { gracefulShutdownSync, isShuttingDown } from './gracefulShutdown.js'
import { parseJSONL } from './json.js'
import { logError } from './log.js'
import { extractTag, isCompactBoundaryMessage } from './messages.js'
import {
extractTag,
isCompactBoundaryMessage,
SYNTHETIC_MODEL,
} from './messages.js'
import { sanitizePath } from './path.js'
import {
extractJsonStringField,
@@ -483,6 +493,35 @@ export function isChainParticipant(m: Pick<Message, 'type'>): boolean {
return m.type !== 'progress'
}
const LOCAL_COMMAND_TAGS = [
COMMAND_NAME_TAG,
LOCAL_COMMAND_STDOUT_TAG,
LOCAL_COMMAND_STDERR_TAG,
LOCAL_COMMAND_CAVEAT_TAG,
]
/**
* True for a message that makes the session worth saving: a real prompt, tool
* result or model reply. Local slash-command bookkeeping (`/clear`, `/exit`,
* `/sessions` and their output) and synthetic replies are not, so a launch
* that only runs commands leaves no transcript behind — no empty "/exit"
* entry in /resume and no resume hint for it at exit.
*/
function startsConversation(message: Message): boolean {
if (message.type === 'assistant') {
return message.message.model !== SYNTHETIC_MODEL
}
if (message.type !== 'user' || message.isMeta) return false
const content = message.message.content
const blocks =
typeof content === 'string' ? [{ type: 'text', text: content }] : content
return blocks.some(
block =>
block.type !== 'text' ||
!LOCAL_COMMAND_TAGS.some(tag => block.text.trimStart().startsWith(`<${tag}>`)),
)
}
type LegacyProgressEntry = {
type: 'progress'
uuid: UUID
@@ -901,6 +940,9 @@ class Project {
// Entries buffered while sessionFile is null. Flushed by materializeSessionFile
// on the first user/assistant message — prevents metadata-only session files.
private pendingEntries: Entry[] = []
// Session the buffered entries belong to; a switch drops them so they are
// not written into the next session's transcript.
private pendingEntriesSessionId: UUID | null = null
private remoteIngressUrl: string | null = null
private internalEventWriter: InternalEventWriter | null = null
private internalEventReader: InternalEventReader | null = null
@@ -1028,6 +1070,7 @@ class Project {
filePath: string,
data: string,
): Promise<void> {
if (this.isDeletedSessionFile(filePath)) return
transcriptRewriteHooksForTesting.beforeFileAppend?.(filePath)
await mkdir(dirname(filePath), { recursive: true, mode: 0o700 })
await withTranscriptFileLock(filePath, async signal => {
@@ -1093,6 +1136,7 @@ class Project {
): Promise<void> {
const earlierDirectAppends = this.pendingDirectAppends.get(filePath)
if (earlierDirectAppends) await Promise.all(earlierDirectAppends)
if (this.isDeletedSessionFile(filePath)) return
await withTranscriptFileLock(filePath, signal => rewrite(signal))
transcriptRewriteHooksForTesting.beforeBarrierRelease?.(filePath)
}
@@ -1230,6 +1274,18 @@ class Project {
resetSessionFile(): void {
this.sessionFile = null
this.pendingEntries = []
this.pendingEntriesSessionId = null
}
/** Drops buffered entries that belong to a session other than `sessionId`. */
private dropForeignPendingEntries(sessionId: UUID): void {
if (
this.pendingEntriesSessionId !== null &&
this.pendingEntriesSessionId !== sessionId
) {
this.pendingEntries = []
this.pendingEntriesSessionId = null
}
}
/**
@@ -1561,12 +1617,14 @@ class Project {
// (not appendEntry) so it would bypass the per-entry persistence check
// and create a metadata-only file despite --no-session-persistence.
if (this.shouldSkipPersistence()) return
this.dropForeignPendingEntries(getSessionId() as UUID)
this.ensureCurrentSessionFile()
// mode/agentSetting are cache-only pre-materialization; write them now.
this.reAppendSessionMetadata()
if (this.pendingEntries.length > 0) {
const buffered = this.pendingEntries
this.pendingEntries = []
this.pendingEntriesSessionId = null
for (const entry of buffered) {
await this.appendEntry(entry)
}
@@ -1583,12 +1641,10 @@ class Project {
return this.trackWrite(async () => {
let parentUuid: UUID | null = startingParentUuid ?? null
// First user/assistant message materializes the session file.
// Hook progress/attachment messages alone stay buffered.
if (
this.sessionFile === null &&
messages.some(m => m.type === 'user' || m.type === 'assistant')
) {
// First conversation message materializes the session file.
// Hook progress/attachment messages and local-command bookkeeping
// alone stay buffered.
if (this.sessionFile === null && messages.some(startsConversation)) {
await this.materializeSessionFile()
}
@@ -1775,6 +1831,8 @@ class Project {
if (isCurrentSession) {
// Buffer until materializeSessionFile runs (first user/assistant message).
if (this.sessionFile === null) {
this.dropForeignPendingEntries(currentSessionId)
this.pendingEntriesSessionId = currentSessionId
this.pendingEntries.push(entry)
return
}
@@ -1931,6 +1989,49 @@ class Project {
* Caches positive results so we only stat once per session.
*/
private existingSessionFiles = new Map<string, string>()
/**
* Transcripts deleted (e.g. from /sessions) during this process. Writers
* that finish later — async AI title, task summary, queued appends, the
* exit-time metadata re-append — would otherwise recreate the file with
* metadata only, and the session would reappear in /sessions and /resume.
*/
private deletedSessionFiles = new Set<string>()
/** True when `filePath` is a transcript deleted earlier in this process. */
isDeletedSessionFile(filePath: string): boolean {
return (
this.deletedSessionFiles.size > 0 &&
this.deletedSessionFiles.has(resolvePath(filePath))
)
}
async forgetDeletedSession(
sessionId: UUID,
transcriptPath: string,
): Promise<void> {
this.deletedSessionFiles.add(resolvePath(transcriptPath))
this.existingSessionFiles.delete(sessionId)
if (this.sessionFile && this.isDeletedSessionFile(this.sessionFile)) {
this.resetSessionFile()
}
// Let an in-flight drain finish so no write lands after the caller
// removes the file; queued appends for it are now skipped.
await this.flush()
// flush() does not wait for direct appends. One that passed the deleted
// check before the path was marked can still be waiting on mkdir or the
// file lock, and would recreate the file after the caller removes it.
const inFlight = [...this.pendingDirectAppends]
.filter(([path]) => this.isDeletedSessionFile(path))
.flatMap(([, appends]) => [...appends])
await Promise.allSettled(inFlight)
}
/** Undo forgetDeletedSession for a transcript that could not be removed. */
restoreSessionWrites(transcriptPath: string): void {
this.deletedSessionFiles.delete(resolvePath(transcriptPath))
}
private async getExistingSessionFile(
sessionId: UUID,
): Promise<string | null> {
@@ -2160,6 +2261,25 @@ export async function recordGoalState(
await getProject().insertGoalState(goal, sessionId)
}
/**
* Call before deleting a saved session's transcript: blocks every later
* write to it in this process and waits for pending writes to settle.
*/
export async function forgetDeletedSession(
sessionId: UUID,
transcriptPath: string,
): Promise<void> {
await getProject().forgetDeletedSession(sessionId, transcriptPath)
}
/**
* Undo forgetDeletedSession when the transcript is still on disk (its removal
* failed), so this process keeps writing to it instead of dropping writes.
*/
export function restoreSessionWrites(transcriptPath: string): void {
getProject().restoreSessionWrites(transcriptPath)
}
/**
* Reset the session file pointer after switchSession/regenerateSessionId.
* The new file is created lazily on the first user/assistant message.
@@ -3527,6 +3647,7 @@ function appendEntryToFile(
entry: Record<string, unknown>,
): void {
const fs = getFsImplementation()
if (project?.isDeletedSessionFile(fullPath)) return
const line = jsonStringify(entry) + '\n'
if (project?._deferSynchronousAppend(fullPath, line)) return
fs.mkdirSync(dirname(fullPath), { mode: 0o700 })