diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts
index 9765d29a..2ec470b0 100644
--- a/desktop/src/api/sessions.ts
+++ b/desktop/src/api/sessions.ts
@@ -1,8 +1,12 @@
import { api } from './client'
+import type { AgentTaskNotification } from '../types/chat'
import type { SessionListItem, MessageEntry } from '../types/session'
type SessionsResponse = { sessions: SessionListItem[]; total: number }
-type MessagesResponse = { messages: MessageEntry[] }
+type MessagesResponse = {
+ messages: MessageEntry[]
+ taskNotifications?: AgentTaskNotification[]
+}
type CreateSessionResponse = { sessionId: string }
export type SessionRewindResponse = {
target: {
diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx
index edd27666..dd4457cc 100644
--- a/desktop/src/components/chat/CurrentTurnChangeCard.tsx
+++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx
@@ -199,13 +199,16 @@ export function CurrentTurnChangeCard({
)
}
-function relativizeWorkspacePath(filePath: string, workDir: string | null): string {
+export function relativizeWorkspacePath(filePath: string, workDir: string | null): string {
const normalizedPath = filePath.replace(/\\/g, '/')
- if (!workDir || !normalizedPath.startsWith('/')) return normalizedPath
+ const isAbsolute = normalizedPath.startsWith('/') || /^[a-zA-Z]:\//.test(normalizedPath)
+ if (!workDir || !isAbsolute) return normalizedPath
const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '')
- if (normalizedPath === normalizedWorkDir) return ''
- if (normalizedPath.startsWith(`${normalizedWorkDir}/`)) {
+ const comparablePath = normalizedPath.toLowerCase()
+ const comparableWorkDir = normalizedWorkDir.toLowerCase()
+ if (comparablePath === comparableWorkDir) return ''
+ if (comparablePath.startsWith(`${comparableWorkDir}/`)) {
return normalizedPath.slice(normalizedWorkDir.length + 1)
}
return normalizedPath
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index 70d16cbb..b1e75d32 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MessageList, buildRenderModel } from './MessageList'
+import { relativizeWorkspacePath } from './CurrentTurnChangeCard'
import { sessionsApi } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
@@ -945,6 +946,13 @@ describe('MessageList nested tool calls', () => {
)
})
+ it('relativizes Windows checkpoint paths against the turn workdir', () => {
+ expect(relativizeWorkspacePath(
+ 'C:\\Users\\Relakkes\\aacc\\src\\App.tsx',
+ 'c:/users/relakkes/aacc',
+ )).toBe('src/App.tsx')
+ })
+
it('matches live turn change checkpoints by user message index when transcript ids differ from local UI ids', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
@@ -1003,6 +1011,51 @@ describe('MessageList nested tool calls', () => {
)
})
+ it('keeps turn change cards anchored when the only response item is filtered from rendering', async () => {
+ vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
+ checkpoints: [
+ {
+ target: {
+ targetUserMessageId: 'user-1',
+ userMessageIndex: 0,
+ userMessageCount: 1,
+ },
+ code: {
+ available: true,
+ filesChanged: ['src/blank-response.ts'],
+ insertions: 3,
+ deletions: 0,
+ },
+ },
+ ],
+ })
+
+ useChatStore.setState({
+ sessions: {
+ [ACTIVE_TAB]: makeSessionState({
+ messages: [
+ {
+ id: 'user-1',
+ type: 'user_text',
+ content: '生成文件',
+ timestamp: 1,
+ },
+ {
+ id: 'assistant-empty',
+ type: 'assistant_text',
+ content: '\n ',
+ timestamp: 2,
+ },
+ ],
+ }),
+ },
+ })
+
+ render()
+
+ expect(await screen.findByText('src/blank-response.ts')).toBeTruthy()
+ })
+
it('keeps historical turn change cards visible while the next turn is running', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index a221ad24..ce9f1f1a 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -178,11 +178,13 @@ function buildTurnCardInsertionMap(
turnChangeCards: TurnChangeCardModel[],
) {
const lastResponseIndexByTurnId = new Map()
+ const userIndexByTurnId = new Map()
let activeTurnId: string | null = null
renderItems.forEach((item, index) => {
if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) {
activeTurnId = item.message.id
+ userIndexByTurnId.set(activeTurnId, index)
return
}
@@ -193,7 +195,9 @@ function buildTurnCardInsertionMap(
const cardsByRenderIndex = new Map()
turnChangeCards.forEach((card) => {
- const renderIndex = lastResponseIndexByTurnId.get(card.target.messageId)
+ const renderIndex =
+ lastResponseIndexByTurnId.get(card.target.messageId) ??
+ userIndexByTurnId.get(card.target.messageId)
if (renderIndex === undefined) return
const existing = cardsByRenderIndex.get(renderIndex)
if (existing) {
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index 91a30f63..b518964e 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -96,7 +96,7 @@ vi.mock('./cliTaskStore', () => ({
},
}))
-import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore'
+import { mapHistoryMessagesToUiMessages, reconstructAgentNotifications, useChatStore } from './chatStore'
const TEST_SESSION_ID = 'test-session-1'
const initialState = useChatStore.getState()
@@ -213,6 +213,83 @@ describe('chatStore history mapping', () => {
])
})
+ it('filters task-notification turns and resumes at the next real user message', () => {
+ const messages: MessageEntry[] = [
+ {
+ id: 'user-real-1',
+ type: 'user',
+ timestamp: '2026-04-06T00:00:00.000Z',
+ content: '创建项目',
+ },
+ {
+ id: 'assistant-real-1',
+ type: 'assistant',
+ timestamp: '2026-04-06T00:00:01.000Z',
+ content: [{ type: 'text', text: '项目创建好了' }],
+ },
+ {
+ id: 'task-notification',
+ type: 'user',
+ timestamp: '2026-04-06T00:00:02.000Z',
+ content: '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n',
+ },
+ {
+ id: 'assistant-task-response',
+ type: 'assistant',
+ timestamp: '2026-04-06T00:00:03.000Z',
+ content: [{ type: 'text', text: '旧后台任务通知,无需处理' }],
+ },
+ {
+ id: 'user-real-2',
+ type: 'user',
+ timestamp: '2026-04-06T00:00:04.000Z',
+ content: '继续真实问题',
+ },
+ ]
+
+ const mapped = mapHistoryMessagesToUiMessages(messages)
+
+ expect(mapped).toMatchObject([
+ {
+ id: 'user-real-1',
+ type: 'user_text',
+ content: '创建项目',
+ },
+ {
+ type: 'assistant_text',
+ content: '项目创建好了',
+ },
+ {
+ id: 'user-real-2',
+ type: 'user_text',
+ content: '继续真实问题',
+ },
+ ])
+ expect(JSON.stringify(mapped)).not.toContain('')
+ expect(JSON.stringify(mapped)).not.toContain('旧后台任务通知')
+ })
+
+ it('reconstructs task notifications from transcript XML before filtering it from UI', () => {
+ const restored = reconstructAgentNotifications([
+ {
+ id: 'task-notification',
+ type: 'user',
+ timestamp: '2026-04-06T00:00:00.000Z',
+ content: '\nbg-1\ntoolu_bg\ncompleted\nBackground command & agent done\nC:\\Temp\\bg.output\n',
+ },
+ ])
+
+ expect(restored).toEqual({
+ toolu_bg: {
+ taskId: 'bg-1',
+ toolUseId: 'toolu_bg',
+ status: 'completed',
+ summary: 'Background command & agent done',
+ outputFile: 'C:\\Temp\\bg.output',
+ },
+ })
+ })
+
it('surfaces teammate prompt content when mapping member transcript history', () => {
const messages: MessageEntry[] = [
{
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 5284489f..754948da 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -183,11 +183,14 @@ function updateSessionIn(
}
async function fetchAndMapSessionHistory(sessionId: string) {
- const { messages } = await sessionsApi.getMessages(sessionId)
+ const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId)
return {
rawMessages: messages,
uiMessages: mapHistoryMessagesToUiMessages(messages),
- restoredNotifications: reconstructAgentNotifications(messages),
+ restoredNotifications: {
+ ...reconstructAgentNotifications(messages),
+ ...agentNotificationRecordFromList(taskNotifications ?? []),
+ },
lastTodos: extractLastTodoWriteFromHistory(messages),
hasMessagesAfterTaskCompletion: hasUserMessagesAfterTaskCompletion(messages),
}
@@ -863,6 +866,8 @@ export const useChatStore = create((set, get) => ({
type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string }
+const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i
+
/**
* Check if text is a teammate-message (internal agent-to-agent communication).
* Uses full open+close tag match to avoid false positives on user text
@@ -872,6 +877,82 @@ function isTeammateMessage(text: string): boolean {
return text.includes('')
}
+function extractHistoryTextBlocks(content: unknown): string[] {
+ if (typeof content === 'string') return [content]
+ if (!Array.isArray(content)) return []
+
+ return content
+ .flatMap((block) => {
+ if (!block || typeof block !== 'object') return []
+ const record = block as Record
+ return record.type === 'text' && typeof record.text === 'string'
+ ? [record.text]
+ : []
+ })
+ .map((text) => text.trim())
+ .filter(Boolean)
+}
+
+function isTaskNotificationContent(content: unknown): boolean {
+ const textBlocks = extractHistoryTextBlocks(content)
+ return textBlocks.length > 0 && textBlocks.every((text) => extractTaskNotificationXml(text) !== null)
+}
+
+function extractTaskNotificationXml(text: string): string | null {
+ const trimmed = text.trim()
+ if (TASK_NOTIFICATION_RE.test(trimmed)) return trimmed
+ return trimmed.match(/\s*[\s\S]*?<\/task-notification>/i)?.[0] ?? null
+}
+
+function decodeXmlText(text: string): string {
+ return text
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&')
+}
+
+function readXmlTag(xml: string, tag: string): string | undefined {
+ const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'))
+ return match?.[1] ? decodeXmlText(match[1].trim()) : undefined
+}
+
+function extractTaskNotification(content: unknown): AgentTaskNotification | null {
+ const xml = extractHistoryTextBlocks(content)
+ .map((text) => extractTaskNotificationXml(text))
+ .find((value): value is string => value !== null)
+ if (!xml) return null
+
+ const toolUseId = readXmlTag(xml, 'tool-use-id')
+ const status = readXmlTag(xml, 'status')
+ if (
+ !toolUseId ||
+ (status !== 'completed' && status !== 'failed' && status !== 'stopped')
+ ) {
+ return null
+ }
+
+ const taskId = readXmlTag(xml, 'task-id') || toolUseId
+ const summary = readXmlTag(xml, 'summary')
+ const outputFile = readXmlTag(xml, 'output-file')
+ return {
+ taskId,
+ toolUseId,
+ status,
+ ...(summary ? { summary } : {}),
+ ...(outputFile ? { outputFile } : {}),
+ }
+}
+
+function agentNotificationRecordFromList(
+ notifications: AgentTaskNotification[],
+): Record {
+ return Object.fromEntries(
+ notifications.map((notification) => [notification.toolUseId, notification]),
+ )
+}
+
const TEAMMATE_CONTENT_REGEX = /]*>\n?([\s\S]*?)\n?<\/teammate-message>/g
function extractVisibleTeammateMessageContents(text: string): string[] {
@@ -982,6 +1063,11 @@ function extractLeadingFileReferences(text: string): {
* teammate_ids found in subsequent user messages.
*/
export function reconstructAgentNotifications(messages: MessageEntry[]): Record {
+ const taskNotifications = messages
+ .filter((message) => message.type === 'user')
+ .map((message) => extractTaskNotification(message.content))
+ .filter((notification): notification is AgentTaskNotification => notification !== null)
+
// Step 1: Collect Agent tool_use blocks → map agent name to toolUseId
const agentNameToToolUseId = new Map()
@@ -998,7 +1084,9 @@ export function reconstructAgentNotifications(messages: MessageEntry[]): Record<
}
}
- if (agentNameToToolUseId.size === 0) return {}
+ if (agentNameToToolUseId.size === 0) {
+ return agentNotificationRecordFromList(taskNotifications)
+ }
// Step 2: Extract content by teammate_id
// Skip lifecycle messages (shutdown_approved, idle_notification, etc.)
@@ -1044,6 +1132,10 @@ export function reconstructAgentNotifications(messages: MessageEntry[]): Record<
}
}
+ for (const notification of taskNotifications) {
+ notifications[notification.toolUseId] = notification
+ }
+
return notifications
}
@@ -1053,7 +1145,19 @@ export function mapHistoryMessagesToUiMessages(
): UIMessage[] {
const includeTeammateMessages = options?.includeTeammateMessages === true
const uiMessages: UIMessage[] = []
+ let suppressTaskNotificationResponse = false
+
for (const msg of messages) {
+ if (msg.type === 'user' && isTaskNotificationContent(msg.content)) {
+ suppressTaskNotificationResponse = true
+ continue
+ }
+ if (msg.type === 'user') {
+ suppressTaskNotificationResponse = false
+ } else if (suppressTaskNotificationResponse) {
+ continue
+ }
+
const timestamp = new Date(msg.timestamp).getTime()
if (msg.type === 'user' && typeof msg.content === 'string') {
if (isTeammateMessage(msg.content)) {
diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts
index 05a0b031..1a07b691 100644
--- a/src/server/__tests__/conversations.test.ts
+++ b/src/server/__tests__/conversations.test.ts
@@ -14,6 +14,24 @@ import { ConversationService, ConversationStartupError, conversationService } fr
import { SessionService } from '../services/sessionService.js'
import { ProviderService } from '../services/providerService.js'
+async function rmWithRetry(targetPath: string): Promise {
+ const attempts = process.platform === 'win32' ? 5 : 1
+ for (let attempt = 0; attempt < attempts; attempt++) {
+ try {
+ await fs.rm(targetPath, { recursive: true, force: true })
+ return
+ } catch (error) {
+ if (
+ attempt === attempts - 1 ||
+ !['EBUSY', 'EPERM', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code || '')
+ ) {
+ throw error
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
+ }
+ }
+}
+
// ============================================================================
// ConversationService unit tests
// ============================================================================
@@ -909,6 +927,7 @@ describe('WebSocket Chat Integration', () => {
it('should keep a long desktop session alive in a /tmp project across engineering turns', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-issue247-project-'))
+ let sessionId: string | undefined
try {
await fs.writeFile(
@@ -927,7 +946,7 @@ describe('WebSocket Chat Integration', () => {
body: JSON.stringify({ workDir: projectDir }),
})
expect(createRes.status).toBe(201)
- const { sessionId } = await createRes.json() as { sessionId: string }
+ ;({ sessionId } = await createRes.json() as { sessionId: string })
const prompts = [
'Inspect this TypeScript project and summarize what you see.',
@@ -943,7 +962,10 @@ describe('WebSocket Chat Integration', () => {
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
}
} finally {
- await fs.rm(projectDir, { recursive: true, force: true })
+ if (sessionId) {
+ await conversationService.stopSession(sessionId)
+ }
+ await rmWithRetry(projectDir)
}
}, 20_000)
diff --git a/src/server/__tests__/filesystem.test.ts b/src/server/__tests__/filesystem.test.ts
index 8eddfeab..ca53d838 100644
--- a/src/server/__tests__/filesystem.test.ts
+++ b/src/server/__tests__/filesystem.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'bun:test'
import * as fs from 'fs'
import * as fsp from 'fs/promises'
+import * as os from 'os'
import * as path from 'path'
import { handleFilesystemRoute } from '../api/filesystem.js'
@@ -23,7 +24,7 @@ afterEach(async () => {
describe('filesystem API', () => {
it('allows browsing a directory under the user home directory', async () => {
- const homeFixtureDir = await fsp.mkdtemp(path.join(process.env.HOME || path.sep, 'claude-filesystem-test-'))
+ const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-'))
cleanupDirs.add(homeFixtureDir)
await fsp.writeFile(path.join(homeFixtureDir, 'note.txt'), 'hello')
diff --git a/src/server/__tests__/haha-oauth-service.test.ts b/src/server/__tests__/haha-oauth-service.test.ts
index 423ad24d..3965656e 100644
--- a/src/server/__tests__/haha-oauth-service.test.ts
+++ b/src/server/__tests__/haha-oauth-service.test.ts
@@ -51,7 +51,9 @@ describe('HahaOAuthService — file storage', () => {
const oauthPath = path.join(tmpDir, 'cc-haha', 'oauth.json')
const stat = await fs.stat(oauthPath)
- expect(stat.mode & 0o777).toBe(0o600)
+ if (process.platform !== 'win32') {
+ expect(stat.mode & 0o777).toBe(0o600)
+ }
const loaded = await service.loadTokens()
expect(loaded).toEqual(tokens)
diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts
index e3e208d6..040009ba 100644
--- a/src/server/__tests__/sessions.test.ts
+++ b/src/server/__tests__/sessions.test.ts
@@ -626,6 +626,98 @@ describe('SessionService', () => {
})
})
+ it('should hide task-notification turns and their automatic responses from history', async () => {
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
+ const firstUserId = crypto.randomUUID()
+ const firstAssistantId = crypto.randomUUID()
+ const taskNotificationId = crypto.randomUUID()
+ const taskAssistantId = crypto.randomUUID()
+ const taskToolUseMessageId = crypto.randomUUID()
+ const taskToolResultId = crypto.randomUUID()
+ const taskAfterToolId = crypto.randomUUID()
+ const realFollowUpId = crypto.randomUUID()
+ const realAssistantId = crypto.randomUUID()
+
+ await writeSessionFile('-tmp-project', sessionId, [
+ makeSnapshotEntry(),
+ {
+ ...makeUserEntry('创建一个项目', firstUserId),
+ parentUuid: null,
+ },
+ {
+ ...makeAssistantEntry('项目已经创建', firstUserId),
+ uuid: firstAssistantId,
+ },
+ {
+ ...makeUserEntry(
+ '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n',
+ taskNotificationId,
+ ),
+ parentUuid: firstAssistantId,
+ },
+ {
+ ...makeAssistantEntry('旧后台任务通知,无需处理', taskNotificationId),
+ uuid: taskAssistantId,
+ },
+ {
+ ...makeAssistantToolUseEntry([{
+ id: 'toolu_restart',
+ name: 'Bash',
+ input: { command: 'npm run dev' },
+ }], taskAssistantId),
+ uuid: taskToolUseMessageId,
+ },
+ {
+ type: 'user',
+ message: {
+ role: 'user',
+ content: [{
+ type: 'tool_result',
+ tool_use_id: 'toolu_restart',
+ content: 'server restarted',
+ }],
+ },
+ uuid: taskToolResultId,
+ parentUuid: taskToolUseMessageId,
+ timestamp: '2026-01-01T00:03:00.000Z',
+ },
+ {
+ ...makeAssistantEntry('后台任务触发的工具调用完成', taskToolResultId),
+ uuid: taskAfterToolId,
+ },
+ {
+ ...makeUserEntry('继续真实问题', realFollowUpId),
+ parentUuid: taskAfterToolId,
+ },
+ {
+ ...makeAssistantEntry('真实回答', realFollowUpId),
+ uuid: realAssistantId,
+ },
+ ])
+
+ const messages = await service.getSessionMessages(sessionId)
+ const taskNotifications = await service.getSessionTaskNotifications(sessionId)
+
+ expect(messages.map((message) => message.id)).toEqual([
+ firstUserId,
+ firstAssistantId,
+ realFollowUpId,
+ realAssistantId,
+ ])
+ expect(JSON.stringify(messages)).not.toContain('')
+ expect(JSON.stringify(messages)).not.toContain('旧后台任务通知')
+ expect(JSON.stringify(messages)).not.toContain('server restarted')
+ expect(JSON.stringify(messages)).not.toContain('后台任务触发的工具调用完成')
+ expect(taskNotifications).toEqual([
+ {
+ taskId: 'bg-1',
+ toolUseId: 'toolu_bg',
+ status: 'completed',
+ summary: 'Background command completed',
+ },
+ ])
+ })
+
it('should reconstruct parent agent tool linkage from parentUuid chains', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const userUuid = crypto.randomUUID()
@@ -1030,13 +1122,31 @@ describe('Sessions API', () => {
makeSnapshotEntry(),
makeUserEntry('Hello'),
makeAssistantEntry('World'),
+ makeUserEntry(
+ '\nbg-1\ntoolu_bg\nfailed\nBackground command failed & stopped\nC:\\Temp\\bg.output\n',
+ crypto.randomUUID(),
+ ),
+ makeAssistantEntry('internal task response'),
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
expect(res.status).toBe(200)
- const body = (await res.json()) as { messages: unknown[] }
+ const body = (await res.json()) as {
+ messages: unknown[]
+ taskNotifications: unknown[]
+ }
expect(body.messages).toHaveLength(2)
+ expect(JSON.stringify(body.messages)).not.toContain('')
+ expect(body.taskNotifications).toEqual([
+ {
+ taskId: 'bg-1',
+ toolUseId: 'toolu_bg',
+ status: 'failed',
+ summary: 'Background command failed & stopped',
+ outputFile: 'C:\\Temp\\bg.output',
+ },
+ ])
})
it('DELETE /api/sessions/:id should delete the session', async () => {
@@ -1332,6 +1442,107 @@ describe('Sessions API', () => {
expect(todoDiffBody.diff).toContain('+- ship workspace panel')
})
+ it('GET /api/sessions/:id/workspace/* should surface file-history changes for a non-git generated subdirectory', async () => {
+ const sessionId = crypto.randomUUID()
+ const workDir = path.join(tmpDir, 'workspace-file-history-generated')
+ const generatedFile = path.join(workDir, 'aacc', 'src', 'App.tsx')
+ const userId = crypto.randomUUID()
+
+ await fs.mkdir(path.dirname(generatedFile), { recursive: true })
+ await fs.writeFile(
+ generatedFile,
+ 'export default function App() { return Tetris }\n',
+ 'utf-8',
+ )
+
+ await writeSessionFile(sanitizePath(workDir), sessionId, [
+ makeSessionMetaEntry(workDir),
+ makeFileHistorySnapshotEntry(userId, {
+ 'aacc/src/App.tsx': {
+ backupFileName: null,
+ version: 1,
+ backupTime: '2026-01-01T00:00:00.000Z',
+ },
+ }),
+ {
+ ...makeUserEntry('create aacc project', userId),
+ cwd: workDir,
+ sessionId,
+ },
+ makeAssistantEntry('DONE', userId),
+ makeUserEntry(
+ '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n',
+ crypto.randomUUID(),
+ ),
+ makeAssistantEntry('Background task completed again, no action needed'),
+ ])
+
+ const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`)
+ expect(statusRes.status).toBe(200)
+ const statusBody = await statusRes.json() as {
+ state: string
+ workDir: string
+ isGitRepo: boolean
+ changedFiles: Array<{
+ path: string
+ status: string
+ additions: number
+ deletions: number
+ }>
+ }
+ expect(statusBody).toMatchObject({
+ state: 'ok',
+ workDir,
+ isGitRepo: false,
+ })
+ expect(statusBody.changedFiles).toEqual([
+ expect.objectContaining({
+ path: 'aacc/src/App.tsx',
+ status: 'added',
+ additions: 1,
+ deletions: 0,
+ }),
+ ])
+
+ const diffRes = await fetch(
+ `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('aacc/src/App.tsx')}`,
+ )
+ expect(diffRes.status).toBe(200)
+ const diffBody = await diffRes.json() as {
+ state: string
+ path: string
+ diff: string
+ }
+ expect(diffBody).toMatchObject({
+ state: 'ok',
+ path: 'aacc/src/App.tsx',
+ })
+ expect(diffBody.diff).toContain('diff --session /dev/null b/aacc/src/App.tsx')
+ expect(diffBody.diff).toContain('+export default function App() { return Tetris }')
+
+ const checkpointsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
+ expect(checkpointsRes.status).toBe(200)
+ const checkpointsBody = await checkpointsRes.json() as {
+ checkpoints: Array<{
+ target: {
+ targetUserMessageId: string
+ userMessageIndex: number
+ userMessageCount: number
+ }
+ code: {
+ filesChanged: string[]
+ }
+ }>
+ }
+ expect(checkpointsBody.checkpoints).toHaveLength(1)
+ expect(checkpointsBody.checkpoints[0]?.target).toMatchObject({
+ targetUserMessageId: userId,
+ userMessageIndex: 0,
+ userMessageCount: 1,
+ })
+ expect(checkpointsBody.checkpoints[0]?.code.filesChanged).toEqual([generatedFile])
+ })
+
it('GET /api/sessions/:id/workspace/file and diff should require a path query', async () => {
const workDir = await createWorkspaceApiGitRepo(tmpDir)
const { sessionId } = await service.createSession(workDir)
diff --git a/src/server/__tests__/workspace-service.test.ts b/src/server/__tests__/workspace-service.test.ts
index 82f29043..408c7c3e 100644
--- a/src/server/__tests__/workspace-service.test.ts
+++ b/src/server/__tests__/workspace-service.test.ts
@@ -204,6 +204,84 @@ describe('WorkspaceService', () => {
expect(diff.diff).toContain('+export default function App() { return New }')
})
+ it('reports file-history changes without requiring a git repository', async () => {
+ const nonGitDir = await makeTempDir('workspace-service-file-history-')
+ const generatedFile = path.join(nonGitDir, 'aacc', 'src', 'App.tsx')
+ await fs.mkdir(path.dirname(generatedFile), { recursive: true })
+ await fs.writeFile(generatedFile, 'export default function App() { return Tetris }\n')
+
+ const service = new WorkspaceService(
+ async () => nonGitDir,
+ async () => [],
+ async () => [{
+ messageId: '11111111-1111-4111-8111-111111111111',
+ timestamp: new Date('2026-01-01T00:00:00.000Z'),
+ trackedFileBackups: {
+ 'aacc/src/App.tsx': {
+ backupFileName: null,
+ version: 1,
+ backupTime: new Date('2026-01-01T00:00:00.000Z'),
+ },
+ },
+ }],
+ )
+
+ const status = await service.getStatus('session-1')
+
+ expect(status).toMatchObject({
+ state: 'ok',
+ workDir: nonGitDir,
+ isGitRepo: false,
+ changedFiles: [{
+ path: 'aacc/src/App.tsx',
+ status: 'added',
+ additions: 1,
+ deletions: 0,
+ }],
+ })
+
+ const diff = await service.getDiff('session-1', 'aacc/src/App.tsx')
+ expect(diff.state).toBe('ok')
+ expect(diff.diff).toContain('diff --session /dev/null b/aacc/src/App.tsx')
+ expect(diff.diff).toContain('+export default function App() { return Tetris }')
+ })
+
+ it('matches Windows file-history paths case-insensitively inside the workspace', async () => {
+ if (process.platform !== 'win32') return
+
+ const nonGitDir = await makeTempDir('workspace-service-windows-paths-')
+ const targetFile = path.join(nonGitDir, 'Child', 'index.ts')
+ await fs.mkdir(path.dirname(targetFile), { recursive: true })
+ await fs.writeFile(targetFile, 'export const value = 1\n')
+
+ const lowerDrivePath = targetFile[0]?.toLowerCase() + targetFile.slice(1)
+ const service = new WorkspaceService(
+ async () => nonGitDir,
+ async () => [],
+ async () => [{
+ messageId: '22222222-2222-4222-8222-222222222222',
+ timestamp: new Date('2026-01-01T00:00:00.000Z'),
+ trackedFileBackups: {
+ [lowerDrivePath]: {
+ backupFileName: null,
+ version: 1,
+ backupTime: new Date('2026-01-01T00:00:00.000Z'),
+ },
+ },
+ }],
+ )
+
+ const status = await service.getStatus('session-1')
+
+ expect(status.changedFiles).toEqual([{
+ path: 'Child/index.ts',
+ oldPath: undefined,
+ status: 'added',
+ additions: 1,
+ deletions: 0,
+ }])
+ })
+
it('rejects traversal attempts for file, diff, and tree access', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async () => repoDir)
diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts
index 9ebab116..252bd01f 100644
--- a/src/server/api/filesystem.ts
+++ b/src/server/api/filesystem.ts
@@ -20,7 +20,14 @@ const IMAGE_MIME_TYPES: Record = {
}
function isWithinRoot(targetPath: string, rootPath: string): boolean {
- return targetPath === rootPath || targetPath.startsWith(`${rootPath}${path.sep}`)
+ const target = normalizeComparablePath(targetPath)
+ const root = normalizeComparablePath(rootPath)
+ return target === root || target.startsWith(`${root}${path.sep}`)
+}
+
+function normalizeComparablePath(filePath: string): string {
+ const resolved = path.resolve(filePath)
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
}
function isAllowedFilesystemPath(targetPath: string): boolean {
@@ -95,7 +102,7 @@ async function handleServeFile(url: URL): Promise {
}
async function handleBrowse(url: URL): Promise {
- const targetPath = url.searchParams.get('path') || process.env.HOME || '/'
+ const targetPath = url.searchParams.get('path') || os.homedir() || '/'
const resolvedPath = path.resolve(targetPath)
if (!isAllowedFilesystemPath(resolvedPath)) {
diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts
index 324a9cbd..0c940c04 100644
--- a/src/server/api/sessions.ts
+++ b/src/server/api/sessions.ts
@@ -29,10 +29,14 @@ import {
type RewindTargetSelector,
} from '../services/sessionRewindService.js'
-const workspaceService = new WorkspaceService(async (sessionId) => (
- conversationService.getSessionWorkDir(sessionId) ||
- await sessionService.getSessionWorkDir(sessionId)
-), async (sessionId) => sessionService.getSessionMessages(sessionId))
+const workspaceService = new WorkspaceService(
+ async (sessionId) => (
+ conversationService.getSessionWorkDir(sessionId) ||
+ await sessionService.getSessionWorkDir(sessionId)
+ ),
+ async (sessionId) => sessionService.getSessionMessages(sessionId),
+ async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId),
+)
export async function handleSessionsApi(
req: Request,
@@ -202,8 +206,11 @@ async function getSession(sessionId: string): Promise {
}
async function getSessionMessages(sessionId: string): Promise {
- const messages = await sessionService.getSessionMessages(sessionId)
- return Response.json({ messages })
+ const [messages, taskNotifications] = await Promise.all([
+ sessionService.getSessionMessages(sessionId),
+ sessionService.getSessionTaskNotifications(sessionId),
+ ])
+ return Response.json({ messages, taskNotifications })
}
async function handleSessionWorkspaceRoute(
diff --git a/src/server/services/searchService.ts b/src/server/services/searchService.ts
index a3edad2d..ce7c5800 100644
--- a/src/server/services/searchService.ts
+++ b/src/server/services/searchService.ts
@@ -56,7 +56,16 @@ export class SearchService {
}
}
- return this.searchWithGrep(query, cwd, maxResults, options)
+ const hasGrep = await this.commandExists('grep')
+ if (hasGrep) {
+ try {
+ return await this.searchWithGrep(query, cwd, maxResults, options)
+ } catch {
+ // grep failed or is not available; fall back to a portable search.
+ }
+ }
+
+ return this.searchWithFilesystem(query, cwd, maxResults, options)
}
// ---------------------------------------------------------------------------
@@ -285,6 +294,101 @@ export class SearchService {
return results
}
+ // ---------------------------------------------------------------------------
+ // Portable filesystem fallback
+ // ---------------------------------------------------------------------------
+
+ private async searchWithFilesystem(
+ query: string,
+ cwd: string,
+ maxResults: number,
+ options?: {
+ glob?: string
+ caseSensitive?: boolean
+ },
+ ): Promise {
+ const results: SearchResult[] = []
+ const needle = options?.caseSensitive === false ? query.toLowerCase() : query
+
+ await this.searchDirectory(cwd, needle, results, maxResults, {
+ caseSensitive: options?.caseSensitive !== false,
+ glob: options?.glob,
+ })
+
+ return results
+ }
+
+ private async searchDirectory(
+ dir: string,
+ needle: string,
+ results: SearchResult[],
+ maxResults: number,
+ options: {
+ caseSensitive: boolean
+ glob?: string
+ },
+ ): Promise {
+ if (results.length >= maxResults) return
+
+ let entries: import('fs').Dirent[]
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true })
+ } catch {
+ return
+ }
+
+ for (const entry of entries) {
+ if (results.length >= maxResults) return
+ if (entry.name === 'node_modules' || entry.name === '.git') continue
+
+ const fullPath = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ await this.searchDirectory(fullPath, needle, results, maxResults, options)
+ continue
+ }
+
+ if (!entry.isFile()) continue
+ if (options.glob && !this.matchesSimpleGlob(entry.name, options.glob)) continue
+
+ await this.searchFile(fullPath, needle, results, maxResults, options.caseSensitive)
+ }
+ }
+
+ private async searchFile(
+ filePath: string,
+ needle: string,
+ results: SearchResult[],
+ maxResults: number,
+ caseSensitive: boolean,
+ ): Promise {
+ let content: string
+ try {
+ const buffer = await fs.readFile(filePath)
+ if (buffer.includes(0)) return
+ content = buffer.toString('utf8')
+ } catch {
+ return
+ }
+
+ const lines = content.split(/\r?\n/)
+ for (let index = 0; index < lines.length && results.length < maxResults; index++) {
+ const haystack = caseSensitive ? lines[index] : lines[index].toLowerCase()
+ if (!haystack.includes(needle)) continue
+
+ results.push({
+ file: filePath,
+ line: index + 1,
+ text: lines[index],
+ })
+ }
+ }
+
+ private matchesSimpleGlob(fileName: string, glob: string): boolean {
+ if (!glob.includes('*')) return fileName === glob
+ const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
+ return new RegExp(`^${escaped}$`).test(fileName)
+ }
+
// ---------------------------------------------------------------------------
// 工具方法
// ---------------------------------------------------------------------------
@@ -318,7 +422,8 @@ export class SearchService {
/** 检测命令是否存在 */
private commandExists(cmd: string): Promise {
return new Promise((resolve) => {
- const proc = spawn('which', [cmd], { stdio: 'ignore' })
+ const lookup = process.platform === 'win32' ? 'where' : 'which'
+ const proc = spawn(lookup, [cmd], { stdio: 'ignore' })
proc.on('close', (code) => resolve(code === 0))
proc.on('error', () => resolve(false))
})
diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts
index 6abab2cd..292bbf93 100644
--- a/src/server/services/sessionService.ts
+++ b/src/server/services/sessionService.ts
@@ -62,6 +62,14 @@ export type MessageEntry = {
isSidechain?: boolean
}
+export type SessionTaskNotification = {
+ taskId: string
+ toolUseId: string
+ status: 'completed' | 'failed' | 'stopped'
+ summary?: string
+ outputFile?: string
+}
+
export type TranscriptUsageSnapshot = {
source: 'transcript'
totalCostUSD: number
@@ -175,6 +183,8 @@ const USER_INTERRUPTION_TEXTS = new Set([
])
const NO_RESPONSE_REQUESTED_TEXT = 'No response requested.'
+const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i
+const TASK_NOTIFICATION_BLOCK_RE = /\s*[\s\S]*?<\/task-notification>/i
// ============================================================================
// Service
@@ -352,6 +362,72 @@ export class SessionService {
)
}
+ private isToolResultContent(content: unknown): boolean {
+ return (
+ Array.isArray(content) &&
+ content.some((block) =>
+ block &&
+ typeof block === 'object' &&
+ (block as Record).type === 'tool_result'
+ )
+ )
+ }
+
+ private isTaskNotificationContent(content: unknown): boolean {
+ const textBlocks = this.extractTextBlocks(content)
+ return (
+ textBlocks.length > 0 &&
+ textBlocks.every((text) => this.extractTaskNotificationXml(text) !== null)
+ )
+ }
+
+ private extractTaskNotificationXml(text: string): string | null {
+ const trimmed = text.trim()
+ if (TASK_NOTIFICATION_RE.test(trimmed)) return trimmed
+ return trimmed.match(TASK_NOTIFICATION_BLOCK_RE)?.[0] ?? null
+ }
+
+ private decodeXmlText(text: string): string {
+ return text
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&')
+ }
+
+ private readXmlTag(xml: string, tag: string): string | undefined {
+ const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'))
+ return match?.[1] ? this.decodeXmlText(match[1].trim()) : undefined
+ }
+
+ private parseTaskNotificationContent(content: unknown): SessionTaskNotification | null {
+ const xml = this.extractTextBlocks(content)
+ .map((text) => this.extractTaskNotificationXml(text))
+ .find((value): value is string => value !== null)
+ if (!xml) return null
+
+ const toolUseId = this.readXmlTag(xml, 'tool-use-id')
+ const status = this.readXmlTag(xml, 'status')
+ if (
+ !toolUseId ||
+ (status !== 'completed' && status !== 'failed' && status !== 'stopped')
+ ) {
+ return null
+ }
+
+ const taskId = this.readXmlTag(xml, 'task-id') || toolUseId
+ const summary = this.readXmlTag(xml, 'summary')
+ const outputFile = this.readXmlTag(xml, 'output-file')
+ return {
+ taskId,
+ toolUseId,
+ status,
+ ...(summary ? { summary } : {}),
+ ...(outputFile ? { outputFile } : {}),
+ }
+ }
+
private shouldHideTranscriptEntry(entry: RawEntry): boolean {
const role = entry.message?.role
const content = entry.message?.content
@@ -359,7 +435,8 @@ export class SessionService {
if (role === 'user') {
return (
this.isInternalCommandBreadcrumb(content) ||
- this.isSyntheticUserInterruption(content)
+ this.isSyntheticUserInterruption(content) ||
+ this.isTaskNotificationContent(content)
)
}
@@ -1446,6 +1523,24 @@ export class SessionService {
return [...snapshotsByMessageId.values()]
}
+ async getSessionTaskNotifications(
+ sessionId: string,
+ ): Promise {
+ const found = await this.findSessionFile(sessionId)
+ if (!found) {
+ throw ApiError.notFound(`Session not found: ${sessionId}`)
+ }
+
+ const entries = await this.readJsonlFile(found.filePath)
+ const notifications: SessionTaskNotification[] = []
+ for (const entry of entries) {
+ if (entry.message?.role !== 'user') continue
+ const notification = this.parseTaskNotificationContent(entry.message.content)
+ if (notification) notifications.push(notification)
+ }
+ return notifications
+ }
+
// --------------------------------------------------------------------------
// Private helpers
// --------------------------------------------------------------------------
@@ -1454,6 +1549,7 @@ export class SessionService {
const messages: MessageEntry[] = []
const entriesByUuid = new Map()
const parentToolUseIdCache = new Map()
+ let suppressTaskNotificationResponse = false
for (const entry of entries) {
if (typeof entry.uuid === 'string' && entry.uuid.length > 0) {
@@ -1468,6 +1564,23 @@ export class SessionService {
// Skip meta entries (CLI internal bookkeeping)
if (entry.isMeta) continue
+ const isTaskNotification =
+ entry.message.role === 'user' &&
+ this.isTaskNotificationContent(entry.message.content)
+ if (isTaskNotification) {
+ suppressTaskNotificationResponse = true
+ continue
+ }
+
+ if (
+ entry.message.role === 'user' &&
+ !this.isToolResultContent(entry.message.content)
+ ) {
+ suppressTaskNotificationResponse = false
+ } else if (suppressTaskNotificationResponse) {
+ continue
+ }
+
if (this.shouldHideTranscriptEntry(entry)) continue
// Skip non-transcript entry types
diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts
index e2883b07..2d93e290 100644
--- a/src/server/services/workspaceService.ts
+++ b/src/server/services/workspaceService.ts
@@ -2,7 +2,10 @@ import * as fs from 'node:fs/promises'
import { execFile as execFileCallback } from 'node:child_process'
import * as path from 'node:path'
import { promisify } from 'node:util'
+import { diffLines } from 'diff'
import type { MessageEntry } from './sessionService.js'
+import type { FileHistorySnapshot } from '../../utils/fileHistory.js'
+import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
const MAX_PREVIEW_BYTES = 1024 * 1024
const MAX_UNTRACKED_STAT_BYTES = 256 * 1024
@@ -225,6 +228,9 @@ export class WorkspaceService {
private readonly resolveSessionMessages: (
sessionId: string,
) => Promise = async () => [],
+ private readonly resolveSessionFileHistorySnapshots: (
+ sessionId: string,
+ ) => Promise = async () => [],
) {}
async getStatus(sessionId: string): Promise {
@@ -253,33 +259,28 @@ export class WorkspaceService {
}
const repoInfo = await this.getGitRepoInfo(workDir)
- const sessionChanges = await this.getSessionFileChanges(
- sessionId,
- workspaceInfo.workspaceRoot,
+ const sessionChanges = this.mergeSessionFileChanges(
+ [
+ ...await this.getSessionFileChanges(
+ sessionId,
+ workspaceInfo.workspaceRoot,
+ ),
+ ...await this.getFileHistoryChanges(
+ sessionId,
+ workspaceInfo.workspaceRoot,
+ ),
+ ],
)
- if (sessionChanges.length > 0) {
- sessionChanges.sort((a, b) => a.path.localeCompare(b.path))
- return {
- state: 'ok',
- workDir,
- repoName: repoInfo.kind === 'ok'
- ? path.basename(repoInfo.repoRoot)
- : path.basename(workspaceInfo.workspaceRoot),
- branch: repoInfo.kind === 'ok' ? repoInfo.branch : null,
- isGitRepo: repoInfo.kind === 'ok',
- changedFiles: sessionChanges.map(({ diff: _diff, ...change }) => change),
- }
- }
-
if (repoInfo.kind === 'not_git_repo') {
+ sessionChanges.sort((a, b) => a.path.localeCompare(b.path))
return {
state: 'ok',
workDir,
repoName: path.basename(workspaceInfo.workspaceRoot),
branch: null,
isGitRepo: false,
- changedFiles: [],
+ changedFiles: sessionChanges.map(({ diff: _diff, ...change }) => change),
}
}
if (repoInfo.kind === 'error') {
@@ -363,6 +364,20 @@ export class WorkspaceService {
}
changedFiles.sort((a, b) => a.path.localeCompare(b.path))
+ const changedFileByPath = new Map(changedFiles.map((file) => [file.path, file]))
+ for (const change of sessionChanges) {
+ if (!changedFileByPath.has(change.path)) {
+ changedFileByPath.set(change.path, {
+ path: change.path,
+ oldPath: change.oldPath,
+ status: change.status,
+ additions: change.additions,
+ deletions: change.deletions,
+ })
+ }
+ }
+ const mergedChangedFiles = [...changedFileByPath.values()]
+ .sort((a, b) => a.path.localeCompare(b.path))
return {
state: 'ok',
@@ -370,7 +385,7 @@ export class WorkspaceService {
repoName: path.basename(repoInfo.repoRoot),
branch: repoInfo.branch,
isGitRepo: true,
- changedFiles,
+ changedFiles: mergedChangedFiles,
}
}
@@ -542,6 +557,15 @@ export class WorkspaceService {
return { state: 'ok', path: resolvedPath.relativePath, diff: sessionDiff }
}
+ const fileHistoryDiff = await this.getFileHistoryDiff(
+ sessionId,
+ resolvedPath.workspaceRoot,
+ resolvedPath.relativePath,
+ )
+ if (fileHistoryDiff) {
+ return { state: 'ok', path: resolvedPath.relativePath, diff: fileHistoryDiff }
+ }
+
const repoInfo = await this.getGitRepoInfo(resolvedPath.workspaceRoot)
if (repoInfo.kind === 'not_git_repo') {
return { state: 'not_git_repo', path: resolvedPath.relativePath }
@@ -681,6 +705,151 @@ export class WorkspaceService {
return [...changes.values()]
}
+ private async getFileHistoryChanges(
+ sessionId: string,
+ workspaceRoot: string,
+ ): Promise {
+ let snapshots: FileHistorySnapshot[]
+ try {
+ snapshots = await this.resolveSessionFileHistorySnapshots(sessionId)
+ } catch {
+ return []
+ }
+ if (snapshots.length === 0) return []
+
+ const changes: SessionFileChange[] = []
+ const trackedPaths = this.collectFileHistoryTrackedPaths(snapshots)
+
+ for (const trackingPath of trackedPaths) {
+ const relativePath = this.resolveFileHistoryRelativePath(trackingPath, workspaceRoot)
+ if (!relativePath) continue
+
+ const beforeContent = await this.readFileHistoryBackupContent(
+ sessionId,
+ this.getEarliestFileHistoryBackupName(trackingPath, snapshots),
+ )
+ if (beforeContent === undefined) continue
+
+ const absolutePath = path.resolve(workspaceRoot, relativePath)
+ const afterContent = await this.readTextFileOrNull(absolutePath)
+ if (beforeContent === afterContent) continue
+
+ const stats = this.countDiffStats(beforeContent ?? '', afterContent ?? '')
+ changes.push({
+ path: relativePath,
+ status: beforeContent === null
+ ? 'added'
+ : afterContent === null
+ ? 'deleted'
+ : 'modified',
+ additions: stats.additions,
+ deletions: stats.deletions,
+ diff: this.buildSyntheticDiff(
+ beforeContent === null ? '/dev/null' : relativePath,
+ afterContent === null ? '/dev/null' : relativePath,
+ beforeContent ?? '',
+ afterContent ?? '',
+ ),
+ })
+ }
+
+ return changes
+ }
+
+ private async getFileHistoryDiff(
+ sessionId: string,
+ workspaceRoot: string,
+ relativePath: string,
+ ): Promise {
+ const changes = await this.getFileHistoryChanges(sessionId, workspaceRoot)
+ return changes.find((change) => change.path === relativePath)?.diff ?? null
+ }
+
+ private mergeSessionFileChanges(changes: SessionFileChange[]): SessionFileChange[] {
+ const merged = new Map()
+ for (const change of changes) {
+ const existing = merged.get(change.path)
+ if (!existing) {
+ merged.set(change.path, change)
+ continue
+ }
+
+ merged.set(change.path, {
+ ...existing,
+ status: change.status,
+ additions: change.additions,
+ deletions: change.deletions,
+ diff: change.diff ?? existing.diff,
+ })
+ }
+ return [...merged.values()]
+ }
+
+ private collectFileHistoryTrackedPaths(snapshots: FileHistorySnapshot[]): Set {
+ const trackedPaths = new Set()
+ for (const snapshot of snapshots) {
+ for (const trackingPath of Object.keys(snapshot.trackedFileBackups)) {
+ trackedPaths.add(trackingPath)
+ }
+ }
+ return trackedPaths
+ }
+
+ private getEarliestFileHistoryBackupName(
+ trackingPath: string,
+ snapshots: FileHistorySnapshot[],
+ ): string | null | undefined {
+ for (const snapshot of snapshots) {
+ const backup = snapshot.trackedFileBackups[trackingPath]
+ if (backup !== undefined) {
+ return backup.backupFileName
+ }
+ }
+ return undefined
+ }
+
+ private resolveFileHistoryRelativePath(
+ trackingPath: string,
+ workspaceRoot: string,
+ ): string | null {
+ const absolutePath = path.isAbsolute(trackingPath)
+ ? path.resolve(trackingPath)
+ : path.resolve(workspaceRoot, trackingPath)
+ if (!this.isWithinRoot(absolutePath, workspaceRoot)) return null
+ return this.normalizeRelativePath(path.relative(workspaceRoot, absolutePath))
+ }
+
+ private async readFileHistoryBackupContent(
+ sessionId: string,
+ backupFileName: string | null | undefined,
+ ): Promise {
+ if (backupFileName === undefined) return undefined
+ if (backupFileName === null) return null
+ return await this.readTextFileOrNull(
+ path.join(getClaudeConfigHomeDir(), 'file-history', sessionId, backupFileName),
+ )
+ }
+
+ private async readTextFileOrNull(filePath: string): Promise {
+ try {
+ const content = await fs.readFile(filePath)
+ if (content.includes(0)) return null
+ return content.toString('utf8')
+ } catch {
+ return null
+ }
+ }
+
+ private countDiffStats(oldContent: string, newContent: string): { additions: number; deletions: number } {
+ let additions = 0
+ let deletions = 0
+ for (const change of diffLines(oldContent, newContent)) {
+ if (change.added) additions += change.count || 0
+ if (change.removed) deletions += change.count || 0
+ }
+ return { additions, deletions }
+ }
+
private extractSessionChangesFromTool(
toolName: string,
input: Record,
@@ -812,9 +981,13 @@ export class WorkspaceService {
if (newLines.at(-1) === '') newLines.pop()
return [
- `diff --session a/${oldPath} b/${newPath}`,
+ `diff --session ${
+ oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`
+ } ${
+ newPath === '/dev/null' ? '/dev/null' : `b/${newPath}`
+ }`,
`--- ${oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`}`,
- `+++ b/${newPath}`,
+ `+++ ${newPath === '/dev/null' ? '/dev/null' : `b/${newPath}`}`,
`@@ -1,${oldLines.length} +1,${newLines.length} @@`,
...oldLines.map((line) => `-${line}`),
...newLines.map((line) => `+${line}`),
@@ -971,7 +1144,14 @@ export class WorkspaceService {
}
private isWithinRoot(targetPath: string, rootPath: string): boolean {
- return targetPath === rootPath || targetPath.startsWith(`${rootPath}${path.sep}`)
+ const target = this.normalizeComparableAbsolutePath(targetPath)
+ const root = this.normalizeComparableAbsolutePath(rootPath)
+ return target === root || target.startsWith(`${root}${path.sep}`)
+ }
+
+ private normalizeComparableAbsolutePath(filePath: string): string {
+ const resolved = path.resolve(filePath)
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
}
private normalizeRelativePath(filePath: string): string {