mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
fix(ai-vault): index Cline sessions (#16814)
* fix(ai-vault): index Cline sessions * fix(ai-vault): constrain Cline session discovery
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
|
||||
import type { RemoteSessionSource } from './remote-session-scanner-types'
|
||||
import {
|
||||
clineMessagesPathForMetadata,
|
||||
isClineSessionMetadataPath,
|
||||
parseClineSessionContent
|
||||
} from './session-scanner-cline-parser'
|
||||
|
||||
export function remoteClineSource(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent: 'cline',
|
||||
rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'),
|
||||
extensions: ['.json'],
|
||||
filePredicate: isClineSessionMetadataPath,
|
||||
contentDependencyPath: clineMessagesPathForMetadata,
|
||||
directoryPredicate: (_name, depth) => depth === 0,
|
||||
parse: async (file, content, context) => {
|
||||
let messagesContent: string | null = null
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path))
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
messagesContent = read.isBinary ? null : read.content
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (!isMissingRemoteSessionPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,18 +29,7 @@ export async function discoverRemoteSourceCandidates(args: {
|
||||
const files = await mapRemoteScanBatches(
|
||||
paths,
|
||||
REMOTE_DISCOVERY_CONCURRENCY,
|
||||
(path) =>
|
||||
statRemoteSessionFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues,
|
||||
{
|
||||
missingIsExpected: Boolean(args.source.fixedChildFileSegments),
|
||||
signal: args.context.signal
|
||||
}
|
||||
),
|
||||
(path) => statRemoteCandidateFile(path, args.source, args.context, args.issues),
|
||||
args.context.signal
|
||||
)
|
||||
return files
|
||||
@@ -52,6 +41,46 @@ export async function discoverRemoteSourceCandidates(args: {
|
||||
}))
|
||||
}
|
||||
|
||||
async function statRemoteCandidateFile(
|
||||
path: string,
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<FileWithMtime | null> {
|
||||
const file = await statRemoteSessionFile(
|
||||
context.provider,
|
||||
path,
|
||||
source.agent,
|
||||
context.executionHostId,
|
||||
issues,
|
||||
{
|
||||
missingIsExpected: Boolean(source.fixedChildFileSegments),
|
||||
signal: context.signal
|
||||
}
|
||||
)
|
||||
if (!file || !source.contentDependencyPath) {
|
||||
return file
|
||||
}
|
||||
const dependency = await statRemoteSessionFile(
|
||||
context.provider,
|
||||
source.contentDependencyPath(path),
|
||||
source.agent,
|
||||
context.executionHostId,
|
||||
issues,
|
||||
{ missingIsExpected: true, signal: context.signal }
|
||||
)
|
||||
if (!dependency) {
|
||||
return file
|
||||
}
|
||||
const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs)
|
||||
return {
|
||||
...file,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString(),
|
||||
sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function listRemoteFixedChildFiles(
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subag
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
|
||||
import { remoteClineSource } from './remote-session-scanner-cline-source'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
RemoteScannerContext,
|
||||
@@ -77,6 +78,7 @@ export function remoteSessionSources(
|
||||
parseCursorSessionContent,
|
||||
(path) => remotePathSegments(path).includes('agent-transcripts')
|
||||
),
|
||||
remoteClineSource(remoteHome, hostPlatform),
|
||||
source(
|
||||
'hermes',
|
||||
remoteHome,
|
||||
|
||||
@@ -33,6 +33,7 @@ export type RemoteSessionSource = {
|
||||
codexHome?: string
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
contentDependencyPath?: (path: string) => string
|
||||
// Depth 0 denotes a direct child of rootDir.
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
// A canonical file directly beneath every top-level session directory.
|
||||
|
||||
@@ -5,6 +5,98 @@ import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-f
|
||||
import { primeAgentFixture } from './session-scanner-prime-agent-fixtures'
|
||||
|
||||
describe('scanRemoteAiVaultSessions', () => {
|
||||
it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
cwd: '/home/ada/repo'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.messages.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix remote Cline history' }]
|
||||
}
|
||||
]
|
||||
}),
|
||||
11
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'cline',
|
||||
sessionId,
|
||||
title: 'Fix remote Cline history',
|
||||
cwd: '/home/ada/repo',
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
filePath: `${sessionDir}/${sessionId}.json`
|
||||
})
|
||||
})
|
||||
|
||||
it('indexes and resumes Cline sessions from a Windows SSH host', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}`
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
cwd: 'C:/repo/app'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.messages.json`,
|
||||
JSON.stringify({
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }]
|
||||
}),
|
||||
11
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'cline',
|
||||
sessionId,
|
||||
executionHostPlatform: 'win32',
|
||||
filePath: `${sessionDir}/${sessionId}.json`,
|
||||
resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""`
|
||||
})
|
||||
})
|
||||
|
||||
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
|
||||
@@ -19,6 +19,37 @@ const ROVO_ROOT = join(HOME, '.rovodev', 'sessions')
|
||||
const GROK_ROOT = join(HOME, '.grok', 'sessions')
|
||||
|
||||
describe('validateAiVaultSessionDeleteTarget', () => {
|
||||
it('allows a canonical Cline manifest and removes its whole session directory', () => {
|
||||
const root = join('/tmp', 'cline-sessions')
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = join(root, sessionId)
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'cline',
|
||||
filePath: join(sessionDir, `${sessionId}.json`),
|
||||
executionHostId: 'local',
|
||||
rootOptions: { clineSessionsDir: root }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
agent: 'cline',
|
||||
removals: [{ path: sessionDir, kind: 'directory' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a Cline messages companion as an undiscoverable delete target', () => {
|
||||
const root = join('/tmp', 'cline-sessions')
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'cline',
|
||||
filePath: join(root, sessionId, `${sessionId}.messages.json`),
|
||||
executionHostId: 'local',
|
||||
rootOptions: { clineSessionsDir: root }
|
||||
})
|
||||
|
||||
expect(result).toEqual({ allowed: false, agent: 'cline', reason: 'undiscoverable-path' })
|
||||
})
|
||||
|
||||
it('allows a supported agent whose file resolves inside its known root', () => {
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'gemini',
|
||||
|
||||
@@ -21,7 +21,11 @@ import type { AiVaultScanOptions } from './session-scanner-types'
|
||||
// Agents whose session IS the directory holding the scanned file: everything
|
||||
// beside it belongs to the same session (rovo's session_context.json, grok's
|
||||
// chat_history.jsonl), so the directory is the only complete delete unit.
|
||||
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>(['rovo', 'grok'])
|
||||
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>([
|
||||
'rovo',
|
||||
'grok',
|
||||
'cline'
|
||||
])
|
||||
|
||||
export type ValidateAiVaultSessionDeleteTargetArgs = {
|
||||
agent: AiVaultAgent
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseDevinSessionFile } from './session-scanner-devin-parser'
|
||||
import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser'
|
||||
import { parseDroidSessionFile } from './session-scanner-droid-parser'
|
||||
import { parseClineSessionFile } from './session-scanner-cline-parser'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
@@ -72,6 +73,8 @@ export async function parseAgentSessionFile(
|
||||
return parseMessageGraphSessionFile('prime-agent', candidate.file, platform)
|
||||
case 'droid':
|
||||
return parseDroidSessionFile(candidate.file, platform)
|
||||
case 'cline':
|
||||
return parseClineSessionFile(candidate.file, platform)
|
||||
case 'devin':
|
||||
return parseDevinSessionFile(candidate.file, platform)
|
||||
case 'kimi':
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion'
|
||||
import { resolveGrokSessionsDir } from '../../shared/grok-session-paths'
|
||||
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
|
||||
import {
|
||||
clineMessagesPathForMetadata,
|
||||
isClineSessionMetadataPath
|
||||
} from './session-scanner-cline-parser'
|
||||
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
|
||||
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
|
||||
import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots'
|
||||
@@ -40,6 +44,8 @@ const DEVIN_TRANSCRIPTS_DIR = join(
|
||||
)
|
||||
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
|
||||
const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
|
||||
const CLINE_SESSIONS_DIR =
|
||||
process.env.CLINE_SESSION_DATA_DIR?.trim() || join(homedir(), '.cline', 'data', 'sessions')
|
||||
|
||||
/**
|
||||
* Where one agent's session files live and which of them count as sessions.
|
||||
@@ -55,6 +61,8 @@ export type AiVaultAgentSource = {
|
||||
rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[]
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (filePath: string) => boolean
|
||||
// A sibling whose stat participates in candidate freshness and recency.
|
||||
contentDependencyPath?: (filePath: string) => string
|
||||
// Return false to skip a directory; depth 0 is a child of the root.
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
// Roots that are alternates for one install rather than distinct locations,
|
||||
@@ -221,6 +229,19 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
|
||||
],
|
||||
extensions: ['.jsonl']
|
||||
},
|
||||
cline: {
|
||||
rootDirs: (options, wslHomeDirs) =>
|
||||
sessionRootDirs(options.clineSessionsDir ?? CLINE_SESSIONS_DIR, wslHomeDirs, [
|
||||
'.cline',
|
||||
'data',
|
||||
'sessions'
|
||||
]),
|
||||
extensions: ['.json'],
|
||||
filePredicate: isClineSessionMetadataPath,
|
||||
contentDependencyPath: clineMessagesPathForMetadata,
|
||||
// Cline stores one manifest directly beneath each session directory.
|
||||
directoryPredicate: (_name, depth) => depth === 0
|
||||
},
|
||||
kimi: {
|
||||
rootDirs: (options, wslHomeDirs) =>
|
||||
sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
describe('Cline AI Vault sessions', () => {
|
||||
it('indexes one manifest-backed session without surfacing its messages file as a phantom', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = join(roots.clineSessionsDir, sessionId)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
source: 'cli',
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
status: 'idle',
|
||||
interactive: true,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
cwd: '/repo/cline',
|
||||
workspace_root: '/repo/cline'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
agent: 'lead',
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix the Cline vault scanner' }]
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'I will inspect the scanner.' }],
|
||||
ts: 1_786_466_280_000,
|
||||
modelInfo: { id: 'deepseek-v4-flash', provider: 'deepseek' }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1)
|
||||
expect(result.sessions.find((session) => session.agent === 'cline')).toMatchObject({
|
||||
sessionId,
|
||||
title: 'Fix the Cline vault scanner',
|
||||
cwd: '/repo/cline',
|
||||
model: 'deepseek-v4-flash',
|
||||
messageCount: 2,
|
||||
resumeCommand: `cd '/repo/cline' && cline --id '${sessionId}'`,
|
||||
filePath: join(sessionDir, `${sessionId}.json`)
|
||||
})
|
||||
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:39:00.000Z',
|
||||
sessionId,
|
||||
messages: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'I inspected the scanner.' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Add the regression test too' }] }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const rescanned = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
expect(rescanned.sessions.find((session) => session.agent === 'cline')).toMatchObject({
|
||||
messageCount: 3,
|
||||
updatedAt: '2026-08-11T16:39:00.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('only indexes manifests directly beneath a session directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-nested-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = 'direct-session'
|
||||
const nestedId = 'nested-session'
|
||||
await mkdir(join(roots.clineSessionsDir, sessionId), { recursive: true })
|
||||
await mkdir(join(roots.clineSessionsDir, 'workspace', nestedId), { recursive: true })
|
||||
const metadata = JSON.stringify({ session_id: sessionId, cwd: '/repo' })
|
||||
await writeFile(join(roots.clineSessionsDir, sessionId, `${sessionId}.json`), metadata)
|
||||
await writeFile(
|
||||
join(roots.clineSessionsDir, 'workspace', nestedId, `${nestedId}.json`),
|
||||
JSON.stringify({ session_id: nestedId, cwd: '/unexpected' })
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
|
||||
expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1)
|
||||
expect(result.sessions.find((session) => session.agent === 'cline')?.sessionId).toBe(sessionId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractString,
|
||||
normalizeTitleText
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export function isClineSessionMetadataPath(filePath: string): boolean {
|
||||
const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
const fileName = segments.pop()
|
||||
const sessionId = segments.pop()
|
||||
return Boolean(fileName && sessionId && fileName === `${sessionId}.json`)
|
||||
}
|
||||
|
||||
export function clineMessagesPathForMetadata(filePath: string): string {
|
||||
return filePath.endsWith('.json') ? `${filePath.slice(0, -'.json'.length)}.messages.json` : ''
|
||||
}
|
||||
|
||||
export async function parseClineSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan')
|
||||
let messagesContent: string | null = null
|
||||
try {
|
||||
messagesContent = await wslGatedReadFile(
|
||||
clineMessagesPathForMetadata(file.path),
|
||||
'utf-8',
|
||||
'scan'
|
||||
)
|
||||
} catch (error) {
|
||||
// The manifest is written before the first turn; a missing messages file is
|
||||
// a valid metadata-only session, while a WSL gate refusal must stay visible.
|
||||
if (error instanceof WslTranscriptFsError || !isMissingSessionPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return parseClineSessionContent(file, metadataContent, messagesContent, platform)
|
||||
}
|
||||
|
||||
function isMissingSessionPathError(error: unknown): boolean {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
: null
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
export function parseClineSessionContent(
|
||||
file: FileWithMtime,
|
||||
metadataContent: string,
|
||||
messagesContent: string | null,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const metadata = parseJsonRecord(metadataContent)
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? ''
|
||||
const accumulator = createAccumulator({ agent: 'cline', file, sessionId })
|
||||
accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
|
||||
accumulator.model = extractString(metadata.model)
|
||||
updateTimeline(accumulator, metadata.started_at)
|
||||
|
||||
const messages = messagesContent ? parseJsonRecord(messagesContent) : null
|
||||
if (messages) {
|
||||
updateTimeline(accumulator, messages.updated_at)
|
||||
for (const value of arrayValue(messages.messages)) {
|
||||
const message = asRecord(value)
|
||||
const role = message?.role
|
||||
if (!message || (role !== 'user' && role !== 'assistant')) {
|
||||
continue
|
||||
}
|
||||
accumulator.messageCount++
|
||||
updateTimeline(accumulator, message.ts)
|
||||
const content = message.content
|
||||
if (role === 'user' && !accumulator.fallbackTitle) {
|
||||
accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '')
|
||||
}
|
||||
if (role === 'assistant' && !accumulator.model) {
|
||||
accumulator.model = extractString(asRecord(message.modelInfo)?.id)
|
||||
}
|
||||
addPreviewContent(accumulator, role, content, message.ts)
|
||||
}
|
||||
}
|
||||
accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '')
|
||||
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
function parseJsonRecord(content: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return asRecord(JSON.parse(content) as unknown)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export async function discoverFiles(args: {
|
||||
issues: AiVaultScanIssue[]
|
||||
extensions: string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
contentDependencyPath?: (path: string) => string
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
}): Promise<SessionFileDiscovery> {
|
||||
let paths: string[]
|
||||
@@ -41,11 +42,13 @@ export async function discoverFiles(args: {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const fileStat = await wslGatedStat(path, 'scan')
|
||||
const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path))
|
||||
const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0)
|
||||
files.push({
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
sizeBytes: fileStat.size,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString(),
|
||||
sizeBytes: fileStat.size + (dependencyStat?.size ?? 0),
|
||||
dev: fileStat.dev,
|
||||
ino: fileStat.ino,
|
||||
nlink: fileStat.nlink
|
||||
@@ -65,6 +68,23 @@ export async function discoverFiles(args: {
|
||||
}
|
||||
}
|
||||
|
||||
async function optionalContentDependencyStat(
|
||||
filePath: string | undefined
|
||||
): Promise<{ mtimeMs: number; size: number } | null> {
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const fileStat = await wslGatedStat(filePath, 'scan')
|
||||
return { mtimeMs: fileStat.mtimeMs, size: fileStat.size }
|
||||
} catch (error) {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function walkSessionFiles(
|
||||
dirPath: string,
|
||||
agent: AiVaultAgent,
|
||||
|
||||
@@ -76,6 +76,7 @@ function resumableStateFactoryFor(
|
||||
case 'devin':
|
||||
case 'grok':
|
||||
case 'hermes':
|
||||
case 'cline':
|
||||
case 'kimi':
|
||||
case 'opencode':
|
||||
case 'rovo':
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('buildAiVaultServiceEnv', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
@@ -49,6 +50,7 @@ describe('buildAiVaultServiceEnv', () => {
|
||||
|
||||
expect(env).toEqual({
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const RUNTIME_ENV_ALLOWLIST = [
|
||||
// dropping one hides every session of a user who relocated that agent's home.
|
||||
const AGENT_ROOT_ENV_ALLOWLIST = [
|
||||
'CODEX_HOME',
|
||||
'CLINE_SESSION_DATA_DIR',
|
||||
'COPILOT_HOME',
|
||||
'DEVIN_HOME',
|
||||
'GROK_HOME',
|
||||
|
||||
@@ -55,6 +55,7 @@ function agentDiscoveries(
|
||||
issues,
|
||||
extensions: [...source.extensions],
|
||||
filePredicate: source.filePredicate,
|
||||
contentDependencyPath: source.contentDependencyPath,
|
||||
directoryPredicate: source.directoryPredicate
|
||||
})
|
||||
return source.mergeRootDiscoveries
|
||||
|
||||
@@ -24,6 +24,7 @@ export function isolatedScanRoots(root: string) {
|
||||
primeAgentSessionsDir: join(root, 'prime-agent-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
clineSessionsDir: join(root, 'cline-sessions'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type AiVaultScanOptions = {
|
||||
primeAgentSessionsDir?: string
|
||||
droidSessionsDir?: string
|
||||
droidProjectsDir?: string
|
||||
clineSessionsDir?: string
|
||||
kimiSessionsDir?: string
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
|
||||
@@ -647,6 +647,26 @@ describe('scanAiVaultSessions', () => {
|
||||
])
|
||||
)
|
||||
|
||||
const clineSessionId = 'cline-session'
|
||||
const clineSessionDir = join(roots.clineSessionsDir, clineSessionId)
|
||||
await mkdir(clineSessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.json`),
|
||||
JSON.stringify({
|
||||
session_id: clineSessionId,
|
||||
started_at: '2026-05-01T10:10:30.000Z',
|
||||
model: 'cline-model',
|
||||
cwd: '/tmp/cline'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
updated_at: '2026-05-01T10:10:31.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }]
|
||||
})
|
||||
)
|
||||
|
||||
// Kimi: <sessions>/wd_*/session_*/state.json + sibling agents/main/wire.jsonl,
|
||||
// with the work dir resolved from the top-level session_index.jsonl.
|
||||
const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session')
|
||||
@@ -738,6 +758,7 @@ describe('scanAiVaultSessions', () => {
|
||||
expect(commandByAgent.get('prime-agent')).toBe(
|
||||
`cd '/tmp/prime-agent' && prime-agent --resume '${primeAgentSessionFile}'`
|
||||
)
|
||||
expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'")
|
||||
expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'")
|
||||
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
|
||||
expect(commandByAgent.get('kimi')).toBe(
|
||||
|
||||
@@ -210,6 +210,8 @@ function buildAgentResumeInvocation(
|
||||
return `${baseCommand} --session ${sessionArg}`
|
||||
case 'copilot':
|
||||
return `${baseCommand} --resume=${sessionArg}`
|
||||
case 'cline':
|
||||
return `${baseCommand} --id ${sessionArg}`
|
||||
case 'claude':
|
||||
case 'cursor':
|
||||
case 'gemini':
|
||||
|
||||
@@ -41,7 +41,8 @@ export const AI_VAULT_DELETABLE_AGENTS = [
|
||||
'omp',
|
||||
'claude',
|
||||
'rovo',
|
||||
'grok'
|
||||
'grok',
|
||||
'cline'
|
||||
] as const satisfies readonly AiVaultAgent[]
|
||||
|
||||
export type AiVaultDeletableAgent = (typeof AI_VAULT_DELETABLE_AGENTS)[number]
|
||||
|
||||
@@ -18,6 +18,7 @@ export const AI_VAULT_AGENTS = [
|
||||
'openclaw',
|
||||
'devin',
|
||||
'droid',
|
||||
'cline',
|
||||
'kimi'
|
||||
] as const satisfies readonly TuiAgent[]
|
||||
|
||||
@@ -59,6 +60,7 @@ export const AI_VAULT_AGENT_LABELS = {
|
||||
openclaw: 'OpenClaw',
|
||||
devin: 'Devin',
|
||||
droid: 'Droid',
|
||||
cline: 'Cline',
|
||||
kimi: 'Kimi'
|
||||
} as const satisfies Record<AiVaultAgent, string>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user