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.
- package/README.md +32 -10
- package/dist/client/assets/index-Byzgv_-q.js +409 -0
- package/dist/client/assets/index-gld9RxCU.css +1 -0
- package/dist/client/index.html +2 -2
- package/package.json +5 -1
- package/src/server/agent.test.ts +541 -0
- package/src/server/agent.ts +498 -193
- package/src/server/codex-app-server-protocol.ts +440 -0
- package/src/server/codex-app-server.test.ts +1353 -0
- package/src/server/codex-app-server.ts +1328 -0
- package/src/server/discovery.test.ts +211 -0
- package/src/server/discovery.ts +292 -17
- package/src/server/event-store.ts +81 -34
- package/src/server/events.ts +25 -17
- package/src/server/generate-title.ts +32 -39
- package/src/server/harness-types.ts +19 -0
- package/src/server/provider-catalog.test.ts +34 -0
- package/src/server/provider-catalog.ts +77 -0
- package/src/server/quick-response.test.ts +86 -0
- package/src/server/quick-response.ts +124 -0
- package/src/server/read-models.test.ts +105 -0
- package/src/server/read-models.ts +5 -1
- package/src/server/server.ts +2 -2
- package/src/shared/protocol.ts +12 -2
- package/src/shared/tools.test.ts +88 -0
- package/src/shared/tools.ts +233 -0
- package/src/shared/types.ts +404 -5
- package/dist/client/assets/index-BRiM6Nxc.css +0 -1
- package/dist/client/assets/index-CjRaPaQM.js +0 -418
|
@@ -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
|
+
})
|
package/src/server/discovery.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
48
|
-
const
|
|
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
|
-
|
|
51
|
-
if (!
|
|
218
|
+
const payload = record.payload
|
|
219
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue
|
|
52
220
|
|
|
53
|
-
const
|
|
54
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
}
|