kanna-code 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import {
6
+ ClaudeProjectDiscoveryAdapter,
7
+ CodexProjectDiscoveryAdapter,
8
+ discoverProjects,
9
+ type ProjectDiscoveryAdapter,
10
+ } from "./discovery"
11
+
12
+ const tempDirs: string[] = []
13
+
14
+ function makeTempDir() {
15
+ const directory = mkdtempSync(path.join(tmpdir(), "kanna-discovery-"))
16
+ tempDirs.push(directory)
17
+ return directory
18
+ }
19
+
20
+ function encodeClaudeProjectPath(localPath: string) {
21
+ return `-${localPath.replace(/\//g, "-")}`
22
+ }
23
+
24
+ afterEach(() => {
25
+ for (const directory of tempDirs.splice(0)) {
26
+ rmSync(directory, { recursive: true, force: true })
27
+ }
28
+ })
29
+
30
+ describe("project discovery", () => {
31
+ test("Claude adapter decodes saved project paths", () => {
32
+ const homeDir = makeTempDir()
33
+ const projectDir = path.join(homeDir, "workspace", "alpha-project")
34
+ const claudeProjectsDir = path.join(homeDir, ".claude", "projects")
35
+ const projectMarkerDir = path.join(claudeProjectsDir, encodeClaudeProjectPath(projectDir))
36
+
37
+ mkdirSync(projectDir, { recursive: true })
38
+ mkdirSync(projectMarkerDir, { recursive: true })
39
+ utimesSync(projectMarkerDir, new Date("2026-03-16T10:00:00.000Z"), new Date("2026-03-16T10:00:00.000Z"))
40
+
41
+ const projects = new ClaudeProjectDiscoveryAdapter().scan(homeDir)
42
+
43
+ expect(projects).toEqual([
44
+ {
45
+ provider: "claude",
46
+ localPath: projectDir,
47
+ title: "alpha-project",
48
+ modifiedAt: new Date("2026-03-16T10:00:00.000Z").getTime(),
49
+ },
50
+ ])
51
+ })
52
+
53
+ test("Codex adapter reads cwd from session metadata and ignores stale or invalid entries", () => {
54
+ const homeDir = makeTempDir()
55
+ const sessionsDir = path.join(homeDir, ".codex", "sessions", "2026", "03", "16")
56
+ const liveProjectDir = path.join(homeDir, "workspace", "kanna")
57
+ const missingProjectDir = path.join(homeDir, "workspace", "missing-project")
58
+ mkdirSync(liveProjectDir, { recursive: true })
59
+ mkdirSync(sessionsDir, { recursive: true })
60
+
61
+ writeFileSync(path.join(homeDir, ".codex", "session_index.jsonl"), [
62
+ JSON.stringify({
63
+ id: "session-live",
64
+ updated_at: "2026-03-16T23:05:58.940134Z",
65
+ }),
66
+ JSON.stringify({
67
+ id: "session-missing",
68
+ updated_at: "2026-03-16T20:05:58.940134Z",
69
+ }),
70
+ JSON.stringify({
71
+ id: "session-relative",
72
+ updated_at: "2026-03-16T21:05:58.940134Z",
73
+ }),
74
+ ].join("\n"))
75
+
76
+ writeFileSync(path.join(sessionsDir, "rollout-2026-03-16T23-05-52-session-live.jsonl"), [
77
+ JSON.stringify({
78
+ timestamp: "2026-03-16T23:05:52.000Z",
79
+ type: "session_meta",
80
+ payload: {
81
+ id: "session-live",
82
+ cwd: liveProjectDir,
83
+ },
84
+ }),
85
+ ].join("\n"))
86
+
87
+ writeFileSync(path.join(sessionsDir, "rollout-2026-03-16T20-05-52-session-missing.jsonl"), [
88
+ JSON.stringify({
89
+ timestamp: "2026-03-16T20:05:52.000Z",
90
+ type: "session_meta",
91
+ payload: {
92
+ id: "session-missing",
93
+ cwd: missingProjectDir,
94
+ },
95
+ }),
96
+ ].join("\n"))
97
+
98
+ writeFileSync(path.join(sessionsDir, "rollout-2026-03-16T21-05-52-session-relative.jsonl"), [
99
+ JSON.stringify({
100
+ timestamp: "2026-03-16T21:05:52.000Z",
101
+ type: "session_meta",
102
+ payload: {
103
+ id: "session-relative",
104
+ cwd: "./relative-path",
105
+ },
106
+ }),
107
+ ].join("\n"))
108
+
109
+ const projects = new CodexProjectDiscoveryAdapter().scan(homeDir)
110
+
111
+ expect(projects).toEqual([
112
+ {
113
+ provider: "codex",
114
+ localPath: liveProjectDir,
115
+ title: "kanna",
116
+ modifiedAt: Date.parse("2026-03-16T23:05:58.940134Z"),
117
+ },
118
+ ])
119
+ })
120
+
121
+ test("Codex adapter falls back to session timestamps and config projects when session index misses CLI entries", () => {
122
+ const homeDir = makeTempDir()
123
+ const sessionsDir = path.join(homeDir, ".codex", "sessions", "2026", "03", "16")
124
+ const cliProjectDir = path.join(homeDir, "workspace", "codex-test-2")
125
+ const configOnlyProjectDir = path.join(homeDir, "workspace", "config-only")
126
+ mkdirSync(cliProjectDir, { recursive: true })
127
+ mkdirSync(configOnlyProjectDir, { recursive: true })
128
+ mkdirSync(sessionsDir, { recursive: true })
129
+
130
+ writeFileSync(path.join(homeDir, ".codex", "session_index.jsonl"), "")
131
+ writeFileSync(path.join(homeDir, ".codex", "config.toml"), [
132
+ `personality = "pragmatic"`,
133
+ `[projects."${configOnlyProjectDir}"]`,
134
+ `trust_level = "trusted"`,
135
+ ].join("\n"))
136
+
137
+ writeFileSync(path.join(sessionsDir, "rollout-2026-03-16T23-42-24-cli-session.jsonl"), [
138
+ JSON.stringify({
139
+ timestamp: "2026-03-17T03:42:25.751Z",
140
+ type: "session_meta",
141
+ payload: {
142
+ id: "cli-session",
143
+ timestamp: "2026-03-17T03:42:24.578Z",
144
+ cwd: cliProjectDir,
145
+ originator: "codex-tui",
146
+ source: "cli",
147
+ },
148
+ }),
149
+ ].join("\n"))
150
+
151
+ const projects = new CodexProjectDiscoveryAdapter().scan(homeDir)
152
+
153
+ expect(projects.map((project) => project.localPath).sort()).toEqual([
154
+ cliProjectDir,
155
+ configOnlyProjectDir,
156
+ ].sort())
157
+ expect(projects.find((project) => project.localPath === cliProjectDir)?.modifiedAt).toBe(
158
+ Date.parse("2026-03-17T03:42:25.751Z")
159
+ )
160
+ })
161
+
162
+ test("discoverProjects de-dupes provider results by normalized path and keeps the newest timestamp", () => {
163
+ const adapters: ProjectDiscoveryAdapter[] = [
164
+ {
165
+ provider: "claude",
166
+ scan() {
167
+ return [
168
+ {
169
+ provider: "claude",
170
+ localPath: "/tmp/project",
171
+ title: "Claude Project",
172
+ modifiedAt: 10,
173
+ },
174
+ ]
175
+ },
176
+ },
177
+ {
178
+ provider: "codex",
179
+ scan() {
180
+ return [
181
+ {
182
+ provider: "codex",
183
+ localPath: "/tmp/project",
184
+ title: "Codex Project",
185
+ modifiedAt: 20,
186
+ },
187
+ {
188
+ provider: "codex",
189
+ localPath: "/tmp/other-project",
190
+ title: "Other Project",
191
+ modifiedAt: 15,
192
+ },
193
+ ]
194
+ },
195
+ },
196
+ ]
197
+
198
+ expect(discoverProjects("/unused-home", adapters)).toEqual([
199
+ {
200
+ localPath: "/tmp/project",
201
+ title: "Codex Project",
202
+ modifiedAt: 20,
203
+ },
204
+ {
205
+ localPath: "/tmp/other-project",
206
+ title: "Other Project",
207
+ modifiedAt: 15,
208
+ },
209
+ ])
210
+ })
211
+ })
@@ -1,6 +1,10 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs"
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import path from "node:path"
4
+ import type { AgentProvider } from "../shared/types"
5
+ import { resolveLocalPath } from "./paths"
6
+
7
+ const LOG_PREFIX = "[kanna discovery]"
4
8
 
5
9
  export interface DiscoveredProject {
6
10
  localPath: string
@@ -8,6 +12,15 @@ export interface DiscoveredProject {
8
12
  modifiedAt: number
9
13
  }
10
14
 
15
+ export interface ProviderDiscoveredProject extends DiscoveredProject {
16
+ provider: AgentProvider
17
+ }
18
+
19
+ export interface ProjectDiscoveryAdapter {
20
+ provider: AgentProvider
21
+ scan(homeDir?: string): ProviderDiscoveredProject[]
22
+ }
23
+
11
24
  function resolveEncodedClaudePath(folderName: string) {
12
25
  const segments = folderName.replace(/^-/, "").split("-").filter(Boolean)
13
26
  let currentPath = ""
@@ -38,28 +51,290 @@ function resolveEncodedClaudePath(folderName: string) {
38
51
  return currentPath || "/"
39
52
  }
40
53
 
41
- export function discoverClaudeProjects(homeDir: string = homedir()): DiscoveredProject[] {
42
- const projectsDir = path.join(homeDir, ".claude", "projects")
43
- if (!existsSync(projectsDir)) {
54
+ function normalizeExistingDirectory(localPath: string) {
55
+ try {
56
+ const normalized = resolveLocalPath(localPath)
57
+ if (!statSync(normalized).isDirectory()) {
58
+ return null
59
+ }
60
+ return normalized
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ function mergeDiscoveredProjects(projects: Iterable<DiscoveredProject>): DiscoveredProject[] {
67
+ const merged = new Map<string, DiscoveredProject>()
68
+
69
+ for (const project of projects) {
70
+ const existing = merged.get(project.localPath)
71
+ if (!existing || project.modifiedAt > existing.modifiedAt) {
72
+ merged.set(project.localPath, {
73
+ localPath: project.localPath,
74
+ title: project.title || path.basename(project.localPath) || project.localPath,
75
+ modifiedAt: project.modifiedAt,
76
+ })
77
+ continue
78
+ }
79
+
80
+ if (!existing.title && project.title) {
81
+ existing.title = project.title
82
+ }
83
+ }
84
+
85
+ return [...merged.values()].sort((a, b) => b.modifiedAt - a.modifiedAt)
86
+ }
87
+
88
+ export class ClaudeProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
89
+ readonly provider = "claude" as const
90
+
91
+ scan(homeDir: string = homedir()): ProviderDiscoveredProject[] {
92
+ const projectsDir = path.join(homeDir, ".claude", "projects")
93
+ if (!existsSync(projectsDir)) {
94
+ console.log(`${LOG_PREFIX} provider=claude status=missing root=${projectsDir}`)
95
+ return []
96
+ }
97
+
98
+ const entries = readdirSync(projectsDir, { withFileTypes: true })
99
+ const projects: ProviderDiscoveredProject[] = []
100
+ let directoryEntries = 0
101
+ let skippedMissing = 0
102
+
103
+ for (const entry of entries) {
104
+ if (!entry.isDirectory()) continue
105
+ directoryEntries += 1
106
+
107
+ const resolvedPath = resolveEncodedClaudePath(entry.name)
108
+ const normalizedPath = normalizeExistingDirectory(resolvedPath)
109
+ if (!normalizedPath) {
110
+ skippedMissing += 1
111
+ continue
112
+ }
113
+
114
+ const stat = statSync(path.join(projectsDir, entry.name))
115
+ projects.push({
116
+ provider: this.provider,
117
+ localPath: normalizedPath,
118
+ title: path.basename(normalizedPath) || normalizedPath,
119
+ modifiedAt: stat.mtimeMs,
120
+ })
121
+ }
122
+
123
+ const mergedProjects = mergeDiscoveredProjects(projects).map((project) => ({
124
+ provider: this.provider,
125
+ ...project,
126
+ }))
127
+
128
+ console.log(
129
+ `${LOG_PREFIX} provider=claude scanned=${directoryEntries} valid=${projects.length} deduped=${mergedProjects.length} skipped_missing=${skippedMissing} samples=${mergedProjects.slice(0, 5).map((project) => project.localPath).join(", ") || "-"}`
130
+ )
131
+
132
+ return mergedProjects
133
+ }
134
+ }
135
+
136
+ function parseJsonRecord(line: string): Record<string, unknown> | null {
137
+ try {
138
+ const parsed = JSON.parse(line)
139
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
140
+ return null
141
+ }
142
+ return parsed as Record<string, unknown>
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ function readCodexSessionIndex(indexPath: string) {
149
+ const updatedAtById = new Map<string, number>()
150
+ if (!existsSync(indexPath)) {
151
+ return updatedAtById
152
+ }
153
+
154
+ for (const line of readFileSync(indexPath, "utf8").split("\n")) {
155
+ if (!line.trim()) continue
156
+ const record = parseJsonRecord(line)
157
+ if (!record) continue
158
+
159
+ const id = typeof record.id === "string" ? record.id : null
160
+ const updatedAt = typeof record.updated_at === "string" ? Date.parse(record.updated_at) : Number.NaN
161
+ if (!id || Number.isNaN(updatedAt)) continue
162
+
163
+ const existing = updatedAtById.get(id)
164
+ if (existing === undefined || updatedAt > existing) {
165
+ updatedAtById.set(id, updatedAt)
166
+ }
167
+ }
168
+
169
+ return updatedAtById
170
+ }
171
+
172
+ function collectCodexSessionFiles(directory: string): string[] {
173
+ if (!existsSync(directory)) {
44
174
  return []
45
175
  }
46
176
 
47
- const entries = readdirSync(projectsDir, { withFileTypes: true })
48
- const projects: DiscoveredProject[] = []
177
+ const files: string[] = []
178
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
179
+ const fullPath = path.join(directory, entry.name)
180
+ if (entry.isDirectory()) {
181
+ files.push(...collectCodexSessionFiles(fullPath))
182
+ continue
183
+ }
184
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
185
+ files.push(fullPath)
186
+ }
187
+ }
188
+ return files
189
+ }
190
+
191
+ function readCodexConfiguredProjects(configPath: string) {
192
+ const projects = new Map<string, number>()
193
+ if (!existsSync(configPath)) {
194
+ return projects
195
+ }
196
+
197
+ const configMtime = statSync(configPath).mtimeMs
198
+ for (const line of readFileSync(configPath, "utf8").split("\n")) {
199
+ const match = line.match(/^\[projects\."(.+)"\]$/)
200
+ if (!match?.[1]) continue
201
+ projects.set(match[1], configMtime)
202
+ }
203
+
204
+ return projects
205
+ }
206
+
207
+ function readCodexSessionMetadata(sessionsDir: string) {
208
+ const metadataById = new Map<string, { cwd: string; modifiedAt: number }>()
209
+
210
+ for (const sessionFile of collectCodexSessionFiles(sessionsDir)) {
211
+ const fileStat = statSync(sessionFile)
212
+ const firstLine = readFileSync(sessionFile, "utf8").split("\n", 1)[0]
213
+ if (!firstLine?.trim()) continue
214
+
215
+ const record = parseJsonRecord(firstLine)
216
+ if (!record || record.type !== "session_meta") continue
49
217
 
50
- for (const entry of entries) {
51
- if (!entry.isDirectory()) continue
218
+ const payload = record.payload
219
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue
52
220
 
53
- const resolvedPath = resolveEncodedClaudePath(entry.name)
54
- if (!existsSync(resolvedPath)) continue
221
+ const payloadRecord = payload as Record<string, unknown>
222
+ const sessionId = typeof payloadRecord.id === "string" ? payloadRecord.id : null
223
+ const cwd = typeof payloadRecord.cwd === "string" ? payloadRecord.cwd : null
224
+ if (!sessionId || !cwd) continue
55
225
 
56
- const stat = statSync(path.join(projectsDir, entry.name))
57
- projects.push({
58
- localPath: resolvedPath,
59
- title: path.basename(resolvedPath) || resolvedPath,
60
- modifiedAt: stat.mtimeMs,
61
- })
226
+ const recordTimestamp = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN
227
+ const payloadTimestamp = typeof payloadRecord.timestamp === "string" ? Date.parse(payloadRecord.timestamp) : Number.NaN
228
+ const modifiedAt = [recordTimestamp, payloadTimestamp, fileStat.mtimeMs].find((value) => !Number.isNaN(value)) ?? fileStat.mtimeMs
229
+
230
+ metadataById.set(sessionId, { cwd, modifiedAt })
62
231
  }
63
232
 
64
- return projects.sort((a, b) => b.modifiedAt - a.modifiedAt)
233
+ return metadataById
234
+ }
235
+
236
+ export class CodexProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
237
+ readonly provider = "codex" as const
238
+
239
+ scan(homeDir: string = homedir()): ProviderDiscoveredProject[] {
240
+ const indexPath = path.join(homeDir, ".codex", "session_index.jsonl")
241
+ const sessionsDir = path.join(homeDir, ".codex", "sessions")
242
+ const configPath = path.join(homeDir, ".codex", "config.toml")
243
+ const updatedAtById = readCodexSessionIndex(indexPath)
244
+ const metadataById = readCodexSessionMetadata(sessionsDir)
245
+ const configuredProjects = readCodexConfiguredProjects(configPath)
246
+ const projects: ProviderDiscoveredProject[] = []
247
+ let skippedMissingMeta = 0
248
+ let skippedRelative = 0
249
+ let skippedMissingPath = 0
250
+ let fallbackSessionTimestamps = 0
251
+ let configProjectsIncluded = 0
252
+
253
+ if (!existsSync(indexPath) || !existsSync(sessionsDir) || !existsSync(configPath)) {
254
+ console.log(
255
+ `${LOG_PREFIX} provider=codex status=missing index_exists=${existsSync(indexPath)} sessions_exists=${existsSync(sessionsDir)} config_exists=${existsSync(configPath)}`
256
+ )
257
+ }
258
+
259
+ for (const [sessionId, metadata] of metadataById.entries()) {
260
+ const modifiedAt = updatedAtById.get(sessionId) ?? metadata.modifiedAt
261
+ const cwd = metadata.cwd
262
+ if (!updatedAtById.has(sessionId)) {
263
+ fallbackSessionTimestamps += 1
264
+ }
265
+ if (!cwd) {
266
+ skippedMissingMeta += 1
267
+ continue
268
+ }
269
+ if (!path.isAbsolute(cwd)) {
270
+ skippedRelative += 1
271
+ continue
272
+ }
273
+
274
+ const normalizedPath = normalizeExistingDirectory(cwd)
275
+ if (!normalizedPath) {
276
+ skippedMissingPath += 1
277
+ continue
278
+ }
279
+
280
+ projects.push({
281
+ provider: this.provider,
282
+ localPath: normalizedPath,
283
+ title: path.basename(normalizedPath) || normalizedPath,
284
+ modifiedAt,
285
+ })
286
+ }
287
+
288
+ for (const [configuredPath, modifiedAt] of configuredProjects.entries()) {
289
+ if (!path.isAbsolute(configuredPath)) {
290
+ skippedRelative += 1
291
+ continue
292
+ }
293
+
294
+ const normalizedPath = normalizeExistingDirectory(configuredPath)
295
+ if (!normalizedPath) {
296
+ skippedMissingPath += 1
297
+ continue
298
+ }
299
+
300
+ configProjectsIncluded += 1
301
+ projects.push({
302
+ provider: this.provider,
303
+ localPath: normalizedPath,
304
+ title: path.basename(normalizedPath) || normalizedPath,
305
+ modifiedAt,
306
+ })
307
+ }
308
+
309
+ const mergedProjects = mergeDiscoveredProjects(projects).map((project) => ({
310
+ provider: this.provider,
311
+ ...project,
312
+ }))
313
+
314
+ console.log(
315
+ `${LOG_PREFIX} provider=codex indexed_sessions=${updatedAtById.size} session_meta=${metadataById.size} config_projects=${configuredProjects.size} valid=${projects.length} deduped=${mergedProjects.length} fallback_session_timestamps=${fallbackSessionTimestamps} config_projects_included=${configProjectsIncluded} skipped_missing_meta=${skippedMissingMeta} skipped_relative=${skippedRelative} skipped_missing_path=${skippedMissingPath} samples=${mergedProjects.slice(0, 5).map((project) => project.localPath).join(", ") || "-"}`
316
+ )
317
+
318
+ return mergedProjects
319
+ }
320
+ }
321
+
322
+ export const DEFAULT_PROJECT_DISCOVERY_ADAPTERS: ProjectDiscoveryAdapter[] = [
323
+ new ClaudeProjectDiscoveryAdapter(),
324
+ new CodexProjectDiscoveryAdapter(),
325
+ ]
326
+
327
+ export function discoverProjects(
328
+ homeDir: string = homedir(),
329
+ adapters: ProjectDiscoveryAdapter[] = DEFAULT_PROJECT_DISCOVERY_ADAPTERS
330
+ ): DiscoveredProject[] {
331
+ const mergedProjects = mergeDiscoveredProjects(
332
+ adapters.flatMap((adapter) => adapter.scan(homeDir).map(({ provider: _provider, ...project }) => project))
333
+ )
334
+
335
+ console.log(
336
+ `${LOG_PREFIX} aggregate providers=${adapters.map((adapter) => adapter.provider).join(",")} total=${mergedProjects.length} samples=${mergedProjects.slice(0, 10).map((project) => project.localPath).join(", ") || "-"}`
337
+ )
338
+
339
+ return mergedProjects
65
340
  }