(null)
+ const closeMenu = useCallback(() => setContextMenu(null), [])
+ useEffect(closeMenu, [closeMenu, sessionId])
+ useDismissable({ open: menu !== null, refs: [menuRef], onDismiss: closeMenu })
+ const handleMenuKeyDown = useMenuKeyboard({ open: menu !== null, menuRef, triggerRef: menuTriggerRef, onClose: closeMenu })
+ const menuPosition = useAnchoredPosition({
+ open: menu !== null,
+ anchorRect: { top: menu?.y ?? 0, bottom: menu?.y ?? 0, left: menu?.x ?? 0, right: menu?.x ?? 0 },
+ floatingRef: menuRef,
+ offset: 0,
+ clampHeight: true,
+ })
const treeView = useWorkspaceContentStore((state) => state.treeViewBySession[sessionId] ?? EMPTY_WORKSPACE_TREE_VIEW)
const setTreeView = useWorkspaceContentStore((state) => state.setTreeView)
const { filter } = treeView
@@ -312,7 +331,21 @@ export function WorkspaceFileTreePane({
handleActivate(row)
}}
onFocus={() => setFocusedPath(row.path)}
- onKeyDown={(event) => handleKeyDown(event, row)}
+ onContextMenu={(event) => {
+ event.preventDefault()
+ menuTriggerRef.current = event.currentTarget
+ setContextMenu({ sessionId, row, x: event.clientX, y: event.clientY })
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
+ event.preventDefault()
+ const rect = event.currentTarget.getBoundingClientRect()
+ menuTriggerRef.current = event.currentTarget
+ setContextMenu({ sessionId, row, x: rect.left, y: rect.bottom })
+ return
+ }
+ handleKeyDown(event, row)
+ }}
style={{ paddingLeft: 6 + row.depth * 16 }}
className={[
'relative flex h-[34px] cursor-default items-center gap-1.5 rounded-[var(--radius-sm)] pr-2 text-[14px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
@@ -339,6 +372,26 @@ export function WorkspaceFileTreePane({
})
)}
+ {menu ? (
+
+
+
+ ) : null}
)
}
diff --git a/src/goals/goalState.test.ts b/src/goals/goalState.test.ts
index 49fcbd1b..afa6694d 100644
--- a/src/goals/goalState.test.ts
+++ b/src/goals/goalState.test.ts
@@ -128,6 +128,22 @@ describe('goalState', () => {
expect(cleared).toBeNull()
})
+ test('does not resurrect a cleared goal from an in-flight turn transcript (#1339)', () => {
+ const { appState, context } = hookContext()
+ const transcript = [
+ createCommandInputMessage('Goal set: ship it'),
+ ]
+ setThreadGoalHook(context, 'thread-cleared-in-flight', 'ship it')
+ clearThreadGoalHook(context, 'thread-cleared-in-flight')
+
+ expect(ensureThreadGoalHookFromTranscript(context, 'thread-cleared-in-flight', transcript)).toBeNull()
+ expect(appState.sessionHooks.get('thread-cleared-in-flight')?.hooks.Stop).toBeUndefined()
+
+ setThreadGoalHook(context, 'thread-cleared-in-flight', 'new goal')
+ expect(getThreadGoal('thread-cleared-in-flight')?.objective).toBe('new goal')
+ clearThreadGoalHook(context, 'thread-cleared-in-flight')
+ })
+
test('identifies standalone goal local command output for SDK forwarding', () => {
expect(
isGoalLocalCommandOutputContent(
diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts
index 0a95e239..fcd0922a 100644
--- a/src/goals/goalState.ts
+++ b/src/goals/goalState.ts
@@ -31,6 +31,9 @@ const GOAL_HOOK_MARKER = ''
const GOAL_HOOK_TIMEOUT_SECONDS = 45
const RESERVED_GOAL_ARGS = new Set(['status', 'pause', 'resume', 'complete'])
const goalsByThread = new Map()
+// A running turn can still hold a transcript from before /goal clear.
+// Explicit session state must win over those stale transcript anchors.
+const clearedGoalThreads = new Set()
export function parseGoalCommand(args: string): ParsedGoalCommand {
const trimmed = args.trim()
@@ -62,6 +65,7 @@ export function setThreadGoalHook(
now = Date.now(),
): ThreadGoal {
clearThreadGoalHook(context, threadId)
+ clearedGoalThreads.delete(threadId)
const hook = createGoalPromptHook(objective)
const goal: ThreadGoal = {
@@ -97,6 +101,7 @@ export function clearThreadGoalHook(
context: Pick,
threadId: string,
): ThreadGoal | null {
+ clearedGoalThreads.add(threadId)
const goal = goalsByThread.get(threadId) ?? null
if (goal) {
removeSessionHook(context.setAppState, threadId, 'Stop', goal.hook)
@@ -113,6 +118,7 @@ export function ensureThreadGoalHookFromTranscript(
): ThreadGoal | null {
const current = goalsByThread.get(threadId)
if (current) return current
+ if (clearedGoalThreads.has(threadId)) return null
const restored = findActiveGoalObjective(messages)
if (!restored) return null
diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts
index 2c18c908..8bb25df7 100644
--- a/src/server/__tests__/h5-access-auth.test.ts
+++ b/src/server/__tests__/h5-access-auth.test.ts
@@ -23,6 +23,7 @@ let originalH5DistDir: string | undefined
let originalClaudeAppRoot: string | undefined
let originalServerAuthRequired: string | undefined
let originalLocalAccessToken: string | undefined
+let originalTrustedRendererOrigin: string | undefined
let originalPetAccessToken: string | undefined
let originalServerPort = 3456
const PHONE_ORIGIN = 'https://phone.example'
@@ -221,6 +222,8 @@ beforeEach(async () => {
originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
originalServerAuthRequired = process.env.SERVER_AUTH_REQUIRED
originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
+ originalTrustedRendererOrigin = process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN
+ delete process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN
originalPetAccessToken = process.env.CC_HAHA_PET_ACCESS_TOKEN
originalServerPort = ProviderService.getServerPort()
process.env.CLAUDE_CONFIG_DIR = tmpDir
@@ -257,6 +260,8 @@ afterEach(async () => {
else process.env.SERVER_AUTH_REQUIRED = originalServerAuthRequired
if (originalLocalAccessToken === undefined) delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
else process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken
+ if (originalTrustedRendererOrigin === undefined) delete process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN
+ else process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN = originalTrustedRendererOrigin
if (originalPetAccessToken === undefined) delete process.env.CC_HAHA_PET_ACCESS_TOKEN
else process.env.CC_HAHA_PET_ACCESS_TOKEN = originalPetAccessToken
@@ -269,6 +274,34 @@ afterEach(async () => {
})
describe('remote H5 auth and CORS integration', () => {
+ test('allows only the configured dev renderer preflight with a desktop process token', async () => {
+ process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'fixture-desktop-token'
+ process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN = 'http://localhost:1420'
+ await restartRemoteServer()
+ const headers = {
+ Origin: 'http://localhost:1420',
+ 'Access-Control-Request-Method': 'GET',
+ 'Access-Control-Request-Headers': 'authorization,content-type',
+ }
+ const preflight = await fetch(`${baseUrl}/api/settings`, { method: 'OPTIONS', headers })
+ expect(preflight.status).toBe(204)
+ expect(preflight.headers.get('Access-Control-Allow-Origin')).toBe(headers.Origin)
+ const authenticated = await fetch(`${baseUrl}/api/settings`, {
+ headers: { Origin: headers.Origin, Authorization: 'Bearer fixture-desktop-token' },
+ })
+ expect(authenticated.status).toBe(200)
+ const unauthenticated = await fetch(`${baseUrl}/api/settings`, { headers: { Origin: headers.Origin } })
+ expect(unauthenticated.status).toBe(403)
+ for (const origin of ['http://localhost:5173', 'http://127.0.0.1:1420', 'http://localhost:1421', 'http://[::1]:1420']) {
+ const response = await fetch(`${baseUrl}/api/settings`, { method: 'OPTIONS', headers: { ...headers, Origin: origin } })
+ expect(response.status).toBe(403)
+ }
+ for (const endpoint of ['/api/h5-access', '/api/h5-access/enable', '/api/settings/session-cleanup']) {
+ const response = await fetch(`${baseUrl}${endpoint}`, { method: 'OPTIONS', headers })
+ expect(response.status).toBe(403)
+ }
+ })
+
test('serves the packaged H5 shell and static assets from the remote server', async () => {
const shellResponse = await fetch(`${baseUrl}/`)
expect(shellResponse.status).toBe(200)
diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts
index 7d5310f5..9bc513f3 100644
--- a/src/server/__tests__/h5-access-policy.test.ts
+++ b/src/server/__tests__/h5-access-policy.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import {
classifyH5Request,
+ resolveTrustedRendererOrigin,
isLocalCredentialOnlyPath,
isLoopbackHost,
requiresLocalAccessCredential,
@@ -16,6 +17,22 @@ const localContext = { clientAddress: '127.0.0.1' }
const remoteContext = { clientAddress: '192.168.0.44' }
describe('h5AccessPolicy', () => {
+ test('validates the development renderer origin before granting a preflight exception', () => {
+ expect(resolveTrustedRendererOrigin('http://localhost:1420/app')).toBe('http://localhost:1420')
+ for (const value of [undefined, '', 'invalid', 'file://', 'https://remote.example', 'http://user:pass@localhost:1420']) {
+ expect(resolveTrustedRendererOrigin(value)).toBeNull()
+ }
+ const request = req('http://127.0.0.1:3456/api/settings', {
+ method: 'OPTIONS',
+ headers: { Origin: 'http://localhost:1420', 'Access-Control-Request-Method': 'GET' },
+ })
+ const context = { ...localContext, localAccessTokenConfigured: true, trustedRendererOrigin: 'http://localhost:1420' }
+ expect(classifyH5Request(request, new URL(request.url), context)).toBe('local-trusted')
+ expect(classifyH5Request(request, new URL(request.url), { ...context, ...remoteContext })).toBe('h5-browser')
+ request.headers.set('X-Forwarded-For', '192.168.0.44')
+ expect(classifyH5Request(request, new URL(request.url), context)).toBe('h5-browser')
+ })
+
test('recognizes loopback hosts as local trusted requests', () => {
expect(isLoopbackHost('localhost')).toBe(true)
expect(isLoopbackHost('127.0.0.1')).toBe(true)
diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts
index 6d8b9cfb..24a73810 100644
--- a/src/server/__tests__/open-target-service.test.ts
+++ b/src/server/__tests__/open-target-service.test.ts
@@ -152,12 +152,65 @@ describe('openTargetService', () => {
.toBe('/api/open-targets/icons/vscode')
})
+ it.each(['/Applications/Zed.app', join(homedir(), 'Applications', 'Zed.app')])(
+ 'detects Zed installed at %s and opens projects and files through the app bundle',
+ async (appPath) => {
+ const dir = await makeDir('cc-haha zed-')
+ const file = join(dir, 'hello world.txt')
+ await writeFile(file, 'fixture')
+ const state = createService('darwin', { paths: { [appPath]: true } })
+ try {
+ expect((await state.service.listTargets()).targets).toContainEqual({
+ id: 'zed', kind: 'ide', label: 'Zed', icon: 'zed',
+ iconUrl: '/api/open-targets/icons/zed', platform: 'darwin',
+ })
+ await state.service.openTarget({ targetId: 'zed', path: dir })
+ await state.service.openTarget({ targetId: 'zed', path: file })
+ expect(state.launched).toEqual([
+ { command: 'open', args: ['-a', appPath, dir] },
+ { command: 'open', args: ['-a', appPath, file] },
+ ])
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ },
+ )
+
+ it.each(['zed', 'zeditor'])('detects and launches Linux Zed using %s', async (command) => {
+ const dir = await makeDir('cc-haha zed-')
+ const state = createService('linux', { commands: { [command]: true } })
+ try {
+ expect((await state.service.listTargets()).targets.map((target) => target.id)).toEqual(['zed'])
+ await state.service.openTarget({ targetId: 'zed', path: dir })
+ expect(state.launched).toEqual([{ command, args: [dir] }])
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('detects and launches the resolved Windows Zed executable', async () => {
+ const dir = await makeDir('cc-haha zed-')
+ const executablePath = 'C:/fixture/Zed/zed.exe'
+ const state = createService('win32', {
+ commandPaths: { 'zed.exe': executablePath },
+ paths: { [executablePath]: true },
+ })
+ try {
+ expect((await state.service.listTargets()).targets.map((target) => target.id)).toEqual(['zed', 'explorer'])
+ await state.service.openTarget({ targetId: 'zed', path: dir })
+ expect(state.launched).toEqual([{ command: executablePath, args: [dir] }])
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
it('does not treat macOS command shims as installed IDEs without the app bundle', async () => {
const { service } = createService('darwin', {
commands: {
code: true,
goland: true,
pycharm: true,
+ zed: true,
},
})
diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts
index 00114324..78048d70 100644
--- a/src/server/h5AccessPolicy.ts
+++ b/src/server/h5AccessPolicy.ts
@@ -1,6 +1,7 @@
export type H5RequestKind = 'local-trusted' | 'internal-sdk' | 'h5-browser'
export type H5RequestContext = {
clientAddress: string | null
+ trustedRendererOrigin?: string | null
localAccessTokenConfigured?: boolean
localAccessAuthorized?: boolean
internalSdkAuthorized?: boolean
@@ -76,6 +77,14 @@ function isLoopbackBrowserOrigin(origin: string): boolean {
return isLoopbackHost(parsed.hostname)
}
+/** Accept only an explicitly configured loopback web origin from the dev launcher. */
+export function resolveTrustedRendererOrigin(value: string | undefined): string | null {
+ if (!value || !isLoopbackBrowserOrigin(value)) return null
+ const parsed = new URL(value)
+ if (parsed.username || parsed.password) return null
+ return parsed.origin
+}
+
function pathnameDirectory(pathname: string): string {
const slash = pathname.lastIndexOf('/')
return slash < 0 ? '/' : pathname.slice(0, slash + 1)
@@ -187,6 +196,12 @@ function isLocalDesktopOrNavigationOrigin(
): boolean {
if (!origin) return !isCrossSiteSubresource(request.headers)
if (LOCAL_DESKTOP_ORIGINS.has(origin)) return true
+ // Chromium omits Authorization on CORS preflight. Exempt only the dev
+ // launcher's exact origin and only OPTIONS; real requests still need the
+ // desktop process credential, including the H5 control plane.
+ if (request.method === 'OPTIONS' &&
+ origin === context.trustedRendererOrigin &&
+ request.headers.has('Access-Control-Request-Method')) return true
// A configured process credential distinguishes the Electron renderer from
// arbitrary pages served by another loopback process. Keep tokenless
diff --git a/src/server/index.ts b/src/server/index.ts
index c4495212..0ef6f8b8 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -29,6 +29,7 @@ import { ensurePersistentStorageUpgraded } from './services/persistentStorageMig
import { handleStaticH5Request } from './staticH5.js'
import {
classifyH5Request,
+ resolveTrustedRendererOrigin,
isH5AccessControlPath,
isLocalCredentialOnlyPath,
requiresLocalAccessCredential,
@@ -221,6 +222,7 @@ function originFromUrl(value: string | null): string | null {
export function startServer(port = PORT, host = HOST) {
enableConfigs()
+ const trustedRendererOrigin = resolveTrustedRendererOrigin(process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN)
// Warm the synchronous disconnect-grace cache from managed settings so the
// first client disconnect honors the configured value (issue #764).
void refreshDisconnectGraceMs()
@@ -333,6 +335,7 @@ export function startServer(port = PORT, host = HOST) {
const sdkToken = url.searchParams.get('token')
const h5RequestContext = {
clientAddress,
+ trustedRendererOrigin,
localAccessTokenConfigured:
hasConfiguredLocalAccessToken() || hasConfiguredPetAccessToken(),
localAccessAuthorized:
diff --git a/src/server/proxy/streaming/openaiChatStreamToAnthropic.test.ts b/src/server/proxy/streaming/openaiChatStreamToAnthropic.test.ts
index aa635837..9e5330b3 100644
--- a/src/server/proxy/streaming/openaiChatStreamToAnthropic.test.ts
+++ b/src/server/proxy/streaming/openaiChatStreamToAnthropic.test.ts
@@ -134,3 +134,10 @@ describe('Chat stream protocol boundaries', () => {
expect(cancelled).toBe(true)
})
})
+
+test('nested prompt cache survives zero direct cache creation in streaming usage (#1327)', async () => {
+ const usage = { prompt_tokens: 149293, completion_tokens: 551, cache_creation_input_tokens: 0, prompt_tokens_details: { cached_tokens: 147840 } }
+ const events = await collect(chunk({ content: 'Done' }) + chunk({}, 'stop')
+ + `data: ${JSON.stringify({ choices: [], usage })}\n\ndata: [DONE]\n\n`)
+ expect(events.find(e => e.type === 'message_delta').usage).toMatchObject({ input_tokens: 1453, output_tokens: 551, cache_read_input_tokens: 147840 })
+})
diff --git a/src/server/proxy/transform/openaiChatToAnthropic.test.ts b/src/server/proxy/transform/openaiChatToAnthropic.test.ts
index 7b6ebcdb..710fa19f 100644
--- a/src/server/proxy/transform/openaiChatToAnthropic.test.ts
+++ b/src/server/proxy/transform/openaiChatToAnthropic.test.ts
@@ -28,3 +28,13 @@ describe('Chat non-streaming response integrity', () => {
expect(result.content.some(block => block.type === 'tool_use')).toBe(false)
})
})
+
+// Issue #1327: zero-valued compatibility cache fields must not hide OpenAI usage.
+for (const direct of [{ cache_creation_input_tokens: 0 }, { cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }]) {
+ test(`nested prompt cache survives zero direct fields ${JSON.stringify(direct)}`, () => {
+ const upstream = response({}, 'stop')
+ upstream.choices[0].message.tool_calls = undefined
+ upstream.usage = { prompt_tokens: 149293, completion_tokens: 551, prompt_tokens_details: { cached_tokens: 147840 }, ...direct }
+ expect(openaiChatToAnthropic(upstream, 'fixture').usage).toEqual({ input_tokens: 1453, output_tokens: 551, cache_read_input_tokens: 147840 })
+ })
+}
diff --git a/src/server/proxy/transform/usage.ts b/src/server/proxy/transform/usage.ts
index c19617f6..17915ac5 100644
--- a/src/server/proxy/transform/usage.ts
+++ b/src/server/proxy/transform/usage.ts
@@ -58,12 +58,14 @@ export function openaiUsageToAnthropic(usage: OpenAICompatibleUsage | undefined)
usage.cache_creation_input_tokens,
)
const hasDirectCacheUsage =
- directCacheRead !== undefined || directCacheCreation !== undefined
+ (directCacheRead ?? 0) > 0 || (directCacheCreation ?? 0) > 0
const nestedCacheRead = validTokenCount(
usage.input_tokens_details?.cached_tokens,
) ?? validTokenCount(usage.prompt_tokens_details?.cached_tokens) ?? 0
- // Direct cache_* fields use Anthropic's exclusive-input semantics. Nested
+ // Positive direct cache_* fields use Anthropic's exclusive-input semantics.
+ // Some Chat gateways include zero placeholders alongside real nested counts;
+ // those placeholders must not suppress the nested cache hit accounting. Nested
// OpenAI details are inclusive in input/prompt and must be subtracted. Cap
// malformed nested cache counts at the reported input to preserve totals.
const cacheRead = hasDirectCacheUsage
diff --git a/src/server/services/openTargetService.ts b/src/server/services/openTargetService.ts
index dbdd6595..c72ffbf1 100644
--- a/src/server/services/openTargetService.ts
+++ b/src/server/services/openTargetService.ts
@@ -142,6 +142,22 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
darwin: ['/Applications/Cursor.app', posixPath.join(homedir(), 'Applications', 'Cursor.app')],
},
},
+ {
+ id: 'zed',
+ kind: 'ide',
+ label: 'Zed',
+ icon: 'zed',
+ platforms: ['darwin', 'win32', 'linux'],
+ commands: {
+ darwin: ['zed'],
+ win32: ['zed.exe'],
+ linux: ['zed', 'zeditor'],
+ },
+ windowsExecutableNames: ['zed.exe'],
+ appPaths: {
+ darwin: ['/Applications/Zed.app', posixPath.join(homedir(), 'Applications', 'Zed.app')],
+ },
+ },
{
id: 'sublime',
kind: 'ide',
diff --git a/src/services/autoDream/autoDream.ts b/src/services/autoDream/autoDream.ts
index d387d9f6..06307ce5 100644
--- a/src/services/autoDream/autoDream.ts
+++ b/src/services/autoDream/autoDream.ts
@@ -40,6 +40,7 @@ import {
listSessionsTouchedSince,
tryAcquireConsolidationLock,
rollbackConsolidationLock,
+ completeConsolidationLock,
} from './consolidationLock.js'
import {
registerDreamTask,
@@ -232,6 +233,7 @@ ${sessionIds.map(id => `- ${id}`).join('\n')}`
onMessage: makeDreamProgressWatcher(taskId, setAppState),
})
+ await completeConsolidationLock()
completeDreamTask(taskId, setAppState)
// Inline completion summary in the main transcript (same surface as
// extractMemories's "Saved N memories" message).
diff --git a/src/services/autoDream/consolidationLock.test.ts b/src/services/autoDream/consolidationLock.test.ts
new file mode 100644
index 00000000..5f53f396
--- /dev/null
+++ b/src/services/autoDream/consolidationLock.test.ts
@@ -0,0 +1,118 @@
+import { afterEach, beforeEach, expect, test } from 'bun:test'
+import { mkdtemp, rm, readFile, writeFile, utimes } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { getAutoMemPath } from '../../memdir/paths.js'
+import {
+ readLastConsolidatedAt,
+ tryAcquireConsolidationLock,
+ completeConsolidationLock,
+ rollbackConsolidationLock,
+ recordConsolidation,
+} from './consolidationLock.js'
+
+let directory: string
+let previousOverride: string | undefined
+beforeEach(async () => {
+ directory = await mkdtemp(join(tmpdir(), 'consolidation-lock-'))
+ previousOverride = process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE
+ process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE = directory
+ getAutoMemPath.cache.clear?.()
+})
+afterEach(async () => {
+ if (previousOverride === undefined) delete process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE
+ else process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE = previousOverride
+ getAutoMemPath.cache.clear?.()
+ await rm(directory, { recursive: true, force: true })
+})
+
+for (const ageHours of [0, 2]) {
+ test(`preserves a legacy successful PID stamp after its process exits (${ageHours}h old, #1349)`, async () => {
+ const child = Bun.spawn([process.execPath, '--eval', ''], { stdout: 'ignore', stderr: 'ignore' })
+ await child.exited
+ const lock = join(directory, '.consolidate-lock')
+ await writeFile(lock, String(child.pid))
+ const previous = (Date.now() - ageHours * 3_600_000) / 1000
+ await utimes(lock, previous, previous)
+ expect(await readLastConsolidatedAt()).toBeCloseTo(previous * 1000, 0)
+ })
+}
+
+test('preserves completed blank-body legacy timestamps', async () => {
+ const lock = join(directory, '.consolidate-lock')
+ await writeFile(lock, '')
+ expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000)
+})
+
+test('preserves a live holder timestamp', async () => {
+ await writeFile(join(directory, '.consolidate-lock'), String(process.pid))
+ expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000)
+})
+
+test('successful consolidation releases its holder while preserving the timestamp', async () => {
+ expect(await tryAcquireConsolidationLock()).toBe(0)
+ const acquired = await readLastConsolidatedAt()
+ expect(await tryAcquireConsolidationLock()).toBeNull()
+ await completeConsolidationLock()
+ expect(await readLastConsolidatedAt()).toBeCloseTo(acquired, 0)
+ // A completed attempt no longer excludes its own process from acquiring.
+ expect(await tryAcquireConsolidationLock()).toBeCloseTo(acquired, 0)
+})
+
+async function leaveInterruptedAttempt(priorMtime: number): Promise {
+ const lock = join(directory, '.consolidate-lock')
+ if (priorMtime > 0) {
+ await writeFile(lock, '')
+ await utimes(lock, priorMtime / 1000, priorMtime / 1000)
+ }
+ const moduleUrl = new URL('./consolidationLock.ts', import.meta.url).href
+ const child = Bun.spawn([process.execPath, '--eval',
+ `const { tryAcquireConsolidationLock } = await import(${JSON.stringify(moduleUrl)}); await tryAcquireConsolidationLock()`,
+ ], { env: { ...process.env }, stdout: 'ignore', stderr: 'pipe' })
+ expect(await child.exited).toBe(0)
+}
+
+for (const ageHours of [0, 2]) {
+ test(`recovers a recognizably interrupted new attempt before the time gate (${ageHours}h old)`, async () => {
+ const priorMtime = Date.now() - 48 * 3_600_000
+ await leaveInterruptedAttempt(priorMtime)
+ const attemptTime = (Date.now() - ageHours * 3_600_000) / 1000
+ await utimes(join(directory, '.consolidate-lock'), attemptTime, attemptTime)
+ expect(await readLastConsolidatedAt()).toBeCloseTo(priorMtime, 0)
+ const prior = await tryAcquireConsolidationLock()
+ expect(prior).toBeCloseTo(priorMtime, 0)
+ await rollbackConsolidationLock(prior!)
+ expect(await readLastConsolidatedAt()).toBeCloseTo(priorMtime, 0)
+ })
+}
+
+test('recovers an interrupted first attempt with no prior timestamp', async () => {
+ await leaveInterruptedAttempt(0)
+ expect(await readLastConsolidatedAt()).toBe(0)
+ const prior = await tryAcquireConsolidationLock()
+ expect(prior).toBe(0)
+ await rollbackConsolidationLock(prior!)
+ expect(await readLastConsolidatedAt()).toBe(0)
+})
+
+test('migrates a legacy successful PID stamp only when a new attempt is acquired', async () => {
+ const child = Bun.spawn([process.execPath, '--eval', ''], { stdout: 'ignore', stderr: 'ignore' })
+ await child.exited
+ const lock = join(directory, '.consolidate-lock')
+ const legacy = String(child.pid)
+ const previous = Date.now() - 48 * 3_600_000
+ await writeFile(lock, legacy)
+ await utimes(lock, previous / 1000, previous / 1000)
+ expect(await readLastConsolidatedAt()).toBeCloseTo(previous, 0)
+ expect(await readFile(lock, 'utf8')).toBe(legacy)
+ expect(await tryAcquireConsolidationLock()).toBeCloseTo(previous, 0)
+ expect(await readFile(lock, 'utf8')).toContain('auto-dream-v1')
+ await rollbackConsolidationLock(previous)
+ expect(await readLastConsolidatedAt()).toBeCloseTo(previous, 0)
+})
+
+test('manual consolidation records a completed timestamp', async () => {
+ await recordConsolidation()
+ expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000)
+ expect(await tryAcquireConsolidationLock()).toBeGreaterThan(Date.now() - 5000)
+})
diff --git a/src/services/autoDream/consolidationLock.ts b/src/services/autoDream/consolidationLock.ts
index 621232bb..d138cbd7 100644
--- a/src/services/autoDream/consolidationLock.ts
+++ b/src/services/autoDream/consolidationLock.ts
@@ -1,4 +1,6 @@
-// Lock file whose mtime IS lastConsolidatedAt. Body is the holder's PID.
+// Lock file whose mtime is the consolidation timestamp. Legacy PID-only and
+// empty bodies retain that meaning. New attempts explicitly record their
+// in-progress state and prior timestamp, so crashes can be recovered safely.
//
// Lives inside the memory dir (getAutoMemPath) so it keys on git-root
// like memory does, and so it's writable even when the memory path comes
@@ -15,6 +17,18 @@ import { getProjectDir } from '../../utils/sessionStorage.js'
const LOCK_FILE = '.consolidate-lock'
+type InProgressLock = { pid: number; priorMtime: number }
+
+function parseInProgressLock(raw: string): InProgressLock | null {
+ const match = /^(\d+) auto-dream-v1 (\d+(?:\.\d+)?)$/.exec(raw.trim())
+ if (!match) return null
+ const pid = Number(match[1])
+ const priorMtime = Number(match[2])
+ return Number.isSafeInteger(pid) && pid > 1 && Number.isFinite(priorMtime)
+ ? { pid, priorMtime }
+ : null
+}
+
// Stale past this even if the PID is live (PID reuse guard).
const HOLDER_STALE_MS = 60 * 60 * 1000
@@ -24,11 +38,14 @@ function lockPath(): string {
/**
* mtime of the lock file = lastConsolidatedAt. 0 if absent.
- * Per-turn cost: one stat.
+ * Only explicitly marked attempts can be identified as interrupted. A legacy
+ * dead PID may represent a successful run, so its timestamp stays authoritative.
*/
export async function readLastConsolidatedAt(): Promise {
try {
- const s = await stat(lockPath())
+ const [s, raw] = await Promise.all([stat(lockPath()), readFile(lockPath(), 'utf8')])
+ const attempt = parseInProgressLock(raw)
+ if (attempt && !isProcessRunning(attempt.pid)) return attempt.priorMtime
return s.mtimeMs
} catch {
return 0
@@ -36,10 +53,10 @@ export async function readLastConsolidatedAt(): Promise {
}
/**
- * Acquire: write PID → mtime = now. Returns the pre-acquire mtime
+ * Acquire: write a versioned PID + prior timestamp → mtime = now. Returns the pre-acquire mtime
* (for rollback), or null if blocked / lost a race.
*
- * Success → do nothing. mtime stays at now.
+ * Success → completeConsolidationLock() clears PID, keeping mtime.
* Failure → rollbackConsolidationLock(priorMtime) rewinds mtime.
* Crash → mtime stuck, dead PID → next process reclaims.
*/
@@ -48,9 +65,12 @@ export async function tryAcquireConsolidationLock(): Promise {
let mtimeMs: number | undefined
let holderPid: number | undefined
+ let priorMtime = 0
try {
const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')])
mtimeMs = s.mtimeMs
+ // Upgrade lazily on acquisition; never reinterpret a legacy PID as failure.
+ priorMtime = parseInProgressLock(raw)?.priorMtime ?? s.mtimeMs
const parsed = parseInt(raw.trim(), 10)
holderPid = Number.isFinite(parsed) ? parsed : undefined
} catch {
@@ -69,7 +89,8 @@ export async function tryAcquireConsolidationLock(): Promise {
// Memory dir may not exist yet.
await mkdir(getAutoMemPath(), { recursive: true })
- await writeFile(path, String(process.pid))
+ const record = `${process.pid} auto-dream-v1 ${priorMtime}`
+ await writeFile(path, record)
// Two reclaimers both write → last wins the PID. Loser bails on re-read.
let verify: string
@@ -78,9 +99,22 @@ export async function tryAcquireConsolidationLock(): Promise {
} catch {
return null
}
- if (parseInt(verify.trim(), 10) !== process.pid) return null
+ if (verify !== record) return null
- return mtimeMs ?? 0
+ return priorMtime
+}
+
+/** Mark success without leaving a dead PID that looks like an interrupted run. */
+export async function completeConsolidationLock(): Promise {
+ const path = lockPath()
+ try {
+ const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')])
+ if (parseInProgressLock(raw)?.pid !== process.pid) return
+ await writeFile(path, '')
+ await utimes(path, s.atimeMs / 1000, s.mtimeMs / 1000)
+ } catch (e: unknown) {
+ logForDebugging(`[autoDream] completion stamp failed: ${(e as Error).message}`)
+ }
}
/**
@@ -131,7 +165,7 @@ export async function recordConsolidation(): Promise {
try {
// Memory dir may not exist yet (manual /dream before any auto-trigger).
await mkdir(getAutoMemPath(), { recursive: true })
- await writeFile(lockPath(), String(process.pid))
+ await writeFile(lockPath(), '')
} catch (e: unknown) {
logForDebugging(
`[autoDream] recordConsolidation write failed: ${(e as Error).message}`,