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:
Brennan Benson
2026-08-27 15:33:26 -07:00
committed by GitHub
parent 419e3b4496
commit efb4050f4d
22 changed files with 528 additions and 17 deletions
@@ -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',
+5 -1
View File
@@ -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
}
}
+23 -3
View File
@@ -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
+21
View File
@@ -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(
+2
View File
@@ -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':
+2 -1
View File
@@ -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]
+2
View File
@@ -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>