kanna-code 0.2.0 → 0.4.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.
@@ -1,6 +1,8 @@
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"
4
6
 
5
7
  export interface DiscoveredProject {
6
8
  localPath: string
@@ -8,6 +10,15 @@ export interface DiscoveredProject {
8
10
  modifiedAt: number
9
11
  }
10
12
 
13
+ export interface ProviderDiscoveredProject extends DiscoveredProject {
14
+ provider: AgentProvider
15
+ }
16
+
17
+ export interface ProjectDiscoveryAdapter {
18
+ provider: AgentProvider
19
+ scan(homeDir?: string): ProviderDiscoveredProject[]
20
+ }
21
+
11
22
  function resolveEncodedClaudePath(folderName: string) {
12
23
  const segments = folderName.replace(/^-/, "").split("-").filter(Boolean)
13
24
  let currentPath = ""
@@ -38,28 +49,253 @@ function resolveEncodedClaudePath(folderName: string) {
38
49
  return currentPath || "/"
39
50
  }
40
51
 
41
- export function discoverClaudeProjects(homeDir: string = homedir()): DiscoveredProject[] {
42
- const projectsDir = path.join(homeDir, ".claude", "projects")
43
- if (!existsSync(projectsDir)) {
52
+ function normalizeExistingDirectory(localPath: string) {
53
+ try {
54
+ const normalized = resolveLocalPath(localPath)
55
+ if (!statSync(normalized).isDirectory()) {
56
+ return null
57
+ }
58
+ return normalized
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ function mergeDiscoveredProjects(projects: Iterable<DiscoveredProject>): DiscoveredProject[] {
65
+ const merged = new Map<string, DiscoveredProject>()
66
+
67
+ for (const project of projects) {
68
+ const existing = merged.get(project.localPath)
69
+ if (!existing || project.modifiedAt > existing.modifiedAt) {
70
+ merged.set(project.localPath, {
71
+ localPath: project.localPath,
72
+ title: project.title || path.basename(project.localPath) || project.localPath,
73
+ modifiedAt: project.modifiedAt,
74
+ })
75
+ continue
76
+ }
77
+
78
+ if (!existing.title && project.title) {
79
+ existing.title = project.title
80
+ }
81
+ }
82
+
83
+ return [...merged.values()].sort((a, b) => b.modifiedAt - a.modifiedAt)
84
+ }
85
+
86
+ export class ClaudeProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
87
+ readonly provider = "claude" as const
88
+
89
+ scan(homeDir: string = homedir()): ProviderDiscoveredProject[] {
90
+ const projectsDir = path.join(homeDir, ".claude", "projects")
91
+ if (!existsSync(projectsDir)) {
92
+ return []
93
+ }
94
+
95
+ const entries = readdirSync(projectsDir, { withFileTypes: true })
96
+ const projects: ProviderDiscoveredProject[] = []
97
+
98
+ for (const entry of entries) {
99
+ if (!entry.isDirectory()) continue
100
+
101
+ const resolvedPath = resolveEncodedClaudePath(entry.name)
102
+ const normalizedPath = normalizeExistingDirectory(resolvedPath)
103
+ if (!normalizedPath) {
104
+ continue
105
+ }
106
+
107
+ const stat = statSync(path.join(projectsDir, entry.name))
108
+ projects.push({
109
+ provider: this.provider,
110
+ localPath: normalizedPath,
111
+ title: path.basename(normalizedPath) || normalizedPath,
112
+ modifiedAt: stat.mtimeMs,
113
+ })
114
+ }
115
+
116
+ const mergedProjects = mergeDiscoveredProjects(projects).map((project) => ({
117
+ provider: this.provider,
118
+ ...project,
119
+ }))
120
+
121
+ return mergedProjects
122
+ }
123
+ }
124
+
125
+ function parseJsonRecord(line: string): Record<string, unknown> | null {
126
+ try {
127
+ const parsed = JSON.parse(line)
128
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
129
+ return null
130
+ }
131
+ return parsed as Record<string, unknown>
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ function readCodexSessionIndex(indexPath: string) {
138
+ const updatedAtById = new Map<string, number>()
139
+ if (!existsSync(indexPath)) {
140
+ return updatedAtById
141
+ }
142
+
143
+ for (const line of readFileSync(indexPath, "utf8").split("\n")) {
144
+ if (!line.trim()) continue
145
+ const record = parseJsonRecord(line)
146
+ if (!record) continue
147
+
148
+ const id = typeof record.id === "string" ? record.id : null
149
+ const updatedAt = typeof record.updated_at === "string" ? Date.parse(record.updated_at) : Number.NaN
150
+ if (!id || Number.isNaN(updatedAt)) continue
151
+
152
+ const existing = updatedAtById.get(id)
153
+ if (existing === undefined || updatedAt > existing) {
154
+ updatedAtById.set(id, updatedAt)
155
+ }
156
+ }
157
+
158
+ return updatedAtById
159
+ }
160
+
161
+ function collectCodexSessionFiles(directory: string): string[] {
162
+ if (!existsSync(directory)) {
44
163
  return []
45
164
  }
46
165
 
47
- const entries = readdirSync(projectsDir, { withFileTypes: true })
48
- const projects: DiscoveredProject[] = []
166
+ const files: string[] = []
167
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
168
+ const fullPath = path.join(directory, entry.name)
169
+ if (entry.isDirectory()) {
170
+ files.push(...collectCodexSessionFiles(fullPath))
171
+ continue
172
+ }
173
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
174
+ files.push(fullPath)
175
+ }
176
+ }
177
+ return files
178
+ }
179
+
180
+ function readCodexConfiguredProjects(configPath: string) {
181
+ const projects = new Map<string, number>()
182
+ if (!existsSync(configPath)) {
183
+ return projects
184
+ }
185
+
186
+ const configMtime = statSync(configPath).mtimeMs
187
+ for (const line of readFileSync(configPath, "utf8").split("\n")) {
188
+ const match = line.match(/^\[projects\."(.+)"\]$/)
189
+ if (!match?.[1]) continue
190
+ projects.set(match[1], configMtime)
191
+ }
192
+
193
+ return projects
194
+ }
195
+
196
+ function readCodexSessionMetadata(sessionsDir: string) {
197
+ const metadataById = new Map<string, { cwd: string; modifiedAt: number }>()
198
+
199
+ for (const sessionFile of collectCodexSessionFiles(sessionsDir)) {
200
+ const fileStat = statSync(sessionFile)
201
+ const firstLine = readFileSync(sessionFile, "utf8").split("\n", 1)[0]
202
+ if (!firstLine?.trim()) continue
203
+
204
+ const record = parseJsonRecord(firstLine)
205
+ if (!record || record.type !== "session_meta") continue
49
206
 
50
- for (const entry of entries) {
51
- if (!entry.isDirectory()) continue
207
+ const payload = record.payload
208
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue
52
209
 
53
- const resolvedPath = resolveEncodedClaudePath(entry.name)
54
- if (!existsSync(resolvedPath)) continue
210
+ const payloadRecord = payload as Record<string, unknown>
211
+ const sessionId = typeof payloadRecord.id === "string" ? payloadRecord.id : null
212
+ const cwd = typeof payloadRecord.cwd === "string" ? payloadRecord.cwd : null
213
+ if (!sessionId || !cwd) continue
55
214
 
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
- })
215
+ const recordTimestamp = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN
216
+ const payloadTimestamp = typeof payloadRecord.timestamp === "string" ? Date.parse(payloadRecord.timestamp) : Number.NaN
217
+ const modifiedAt = [recordTimestamp, payloadTimestamp, fileStat.mtimeMs].find((value) => !Number.isNaN(value)) ?? fileStat.mtimeMs
218
+
219
+ metadataById.set(sessionId, { cwd, modifiedAt })
62
220
  }
63
221
 
64
- return projects.sort((a, b) => b.modifiedAt - a.modifiedAt)
222
+ return metadataById
223
+ }
224
+
225
+ export class CodexProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
226
+ readonly provider = "codex" as const
227
+
228
+ scan(homeDir: string = homedir()): ProviderDiscoveredProject[] {
229
+ const indexPath = path.join(homeDir, ".codex", "session_index.jsonl")
230
+ const sessionsDir = path.join(homeDir, ".codex", "sessions")
231
+ const configPath = path.join(homeDir, ".codex", "config.toml")
232
+ const updatedAtById = readCodexSessionIndex(indexPath)
233
+ const metadataById = readCodexSessionMetadata(sessionsDir)
234
+ const configuredProjects = readCodexConfiguredProjects(configPath)
235
+ const projects: ProviderDiscoveredProject[] = []
236
+
237
+ for (const [sessionId, metadata] of metadataById.entries()) {
238
+ const modifiedAt = updatedAtById.get(sessionId) ?? metadata.modifiedAt
239
+ const cwd = metadata.cwd
240
+ if (!cwd) {
241
+ continue
242
+ }
243
+ if (!path.isAbsolute(cwd)) {
244
+ continue
245
+ }
246
+
247
+ const normalizedPath = normalizeExistingDirectory(cwd)
248
+ if (!normalizedPath) {
249
+ continue
250
+ }
251
+
252
+ projects.push({
253
+ provider: this.provider,
254
+ localPath: normalizedPath,
255
+ title: path.basename(normalizedPath) || normalizedPath,
256
+ modifiedAt,
257
+ })
258
+ }
259
+
260
+ for (const [configuredPath, modifiedAt] of configuredProjects.entries()) {
261
+ if (!path.isAbsolute(configuredPath)) {
262
+ continue
263
+ }
264
+
265
+ const normalizedPath = normalizeExistingDirectory(configuredPath)
266
+ if (!normalizedPath) {
267
+ continue
268
+ }
269
+
270
+ projects.push({
271
+ provider: this.provider,
272
+ localPath: normalizedPath,
273
+ title: path.basename(normalizedPath) || normalizedPath,
274
+ modifiedAt,
275
+ })
276
+ }
277
+
278
+ const mergedProjects = mergeDiscoveredProjects(projects).map((project) => ({
279
+ provider: this.provider,
280
+ ...project,
281
+ }))
282
+
283
+ return mergedProjects
284
+ }
285
+ }
286
+
287
+ export const DEFAULT_PROJECT_DISCOVERY_ADAPTERS: ProjectDiscoveryAdapter[] = [
288
+ new ClaudeProjectDiscoveryAdapter(),
289
+ new CodexProjectDiscoveryAdapter(),
290
+ ]
291
+
292
+ export function discoverProjects(
293
+ homeDir: string = homedir(),
294
+ adapters: ProjectDiscoveryAdapter[] = DEFAULT_PROJECT_DISCOVERY_ADAPTERS
295
+ ): DiscoveredProject[] {
296
+ const mergedProjects = mergeDiscoveredProjects(
297
+ adapters.flatMap((adapter) => adapter.scan(homeDir).map(({ provider: _provider, ...project }) => project))
298
+ )
299
+
300
+ return mergedProjects
65
301
  }
@@ -1,43 +1,36 @@
1
- import { query } from "@anthropic-ai/claude-agent-sdk"
1
+ import { QuickResponseAdapter } from "./quick-response"
2
2
 
3
- export async function generateTitleForChat(messageContent: string): Promise<string | null> {
4
- try {
5
- const q = query({
6
- prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message. Return JSON matching the schema.\n\n${messageContent}`,
7
- options: {
8
- model: "haiku",
9
- tools: [],
10
- systemPrompt: "",
11
- effort: "low",
12
- permissionMode: "bypassPermissions",
13
- outputFormat: {
14
- type: "json_schema",
15
- schema: {
16
- type: "object",
17
- properties: {
18
- title: { type: "string" },
19
- },
20
- required: ["title"],
21
- additionalProperties: false,
22
- },
23
- },
24
- env: { ...process.env },
25
- },
26
- })
3
+ const TITLE_SCHEMA = {
4
+ type: "object",
5
+ properties: {
6
+ title: { type: "string" },
7
+ },
8
+ required: ["title"],
9
+ additionalProperties: false,
10
+ } as const
27
11
 
28
- try {
29
- for await (const message of q) {
30
- if ("result" in message) {
31
- const output = (message as Record<string, unknown>).structured_output as { title?: string } | undefined
32
- return typeof output?.title === "string" ? output.title.slice(0, 80) : null
33
- }
34
- }
35
- } finally {
36
- q.close()
37
- }
12
+ function normalizeGeneratedTitle(value: unknown): string | null {
13
+ if (typeof value !== "string") return null
14
+ const normalized = value.replace(/\s+/g, " ").trim().slice(0, 80)
15
+ if (!normalized || normalized === "New Chat") return null
16
+ return normalized
17
+ }
18
+
19
+ export async function generateTitleForChat(
20
+ messageContent: string,
21
+ cwd: string,
22
+ adapter = new QuickResponseAdapter()
23
+ ): Promise<string | null> {
24
+ const result = await adapter.generateStructured<string>({
25
+ cwd,
26
+ task: "conversation title generation",
27
+ prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message.\n\n${messageContent}`,
28
+ schema: TITLE_SCHEMA,
29
+ parse: (value) => {
30
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
31
+ return normalizeGeneratedTitle(output.title)
32
+ },
33
+ })
38
34
 
39
- return null
40
- } catch {
41
- return null
42
- }
35
+ return result
43
36
  }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { generateTitleForChat } from "./generate-title"
3
+ import { QuickResponseAdapter } from "./quick-response"
4
+
5
+ describe("QuickResponseAdapter", () => {
6
+ test("returns the Claude structured result when it validates", async () => {
7
+ const adapter = new QuickResponseAdapter({
8
+ runClaudeStructured: async () => ({ title: "Claude title" }),
9
+ runCodexStructured: async () => ({ title: "Codex title" }),
10
+ })
11
+
12
+ const result = await adapter.generateStructured({
13
+ cwd: "/tmp/project",
14
+ task: "title generation",
15
+ prompt: "Generate a title",
16
+ schema: {
17
+ type: "object",
18
+ properties: {
19
+ title: { type: "string" },
20
+ },
21
+ required: ["title"],
22
+ additionalProperties: false,
23
+ },
24
+ parse: (value) => {
25
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
26
+ return typeof output.title === "string" ? output.title : null
27
+ },
28
+ })
29
+
30
+ expect(result).toBe("Claude title")
31
+ })
32
+
33
+ test("falls back to Codex when Claude fails validation", async () => {
34
+ const adapter = new QuickResponseAdapter({
35
+ runClaudeStructured: async () => ({ bad: true }),
36
+ runCodexStructured: async () => ({ title: "Codex title" }),
37
+ })
38
+
39
+ const result = await adapter.generateStructured({
40
+ cwd: "/tmp/project",
41
+ task: "title generation",
42
+ prompt: "Generate a title",
43
+ schema: {
44
+ type: "object",
45
+ properties: {
46
+ title: { type: "string" },
47
+ },
48
+ required: ["title"],
49
+ additionalProperties: false,
50
+ },
51
+ parse: (value) => {
52
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
53
+ return typeof output.title === "string" ? output.title : null
54
+ },
55
+ })
56
+
57
+ expect(result).toBe("Codex title")
58
+ })
59
+ })
60
+
61
+ describe("generateTitleForChat", () => {
62
+ test("sanitizes generated titles", async () => {
63
+ const title = await generateTitleForChat(
64
+ "hello",
65
+ "/tmp/project",
66
+ new QuickResponseAdapter({
67
+ runClaudeStructured: async () => ({ title: " Example\nTitle " }),
68
+ })
69
+ )
70
+
71
+ expect(title).toBe("Example Title")
72
+ })
73
+
74
+ test("rejects invalid generated titles", async () => {
75
+ const title = await generateTitleForChat(
76
+ "hello",
77
+ "/tmp/project",
78
+ new QuickResponseAdapter({
79
+ runClaudeStructured: async () => ({ title: " " }),
80
+ runCodexStructured: async () => ({ title: "New Chat" }),
81
+ })
82
+ )
83
+
84
+ expect(title).toBeNull()
85
+ })
86
+ })
@@ -0,0 +1,124 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk"
2
+ import { CodexAppServerManager } from "./codex-app-server"
3
+
4
+ type JsonSchema = {
5
+ type: "object"
6
+ properties: Record<string, unknown>
7
+ required?: readonly string[]
8
+ additionalProperties?: boolean
9
+ }
10
+
11
+ export interface StructuredQuickResponseArgs<T> {
12
+ cwd: string
13
+ task: string
14
+ prompt: string
15
+ schema: JsonSchema
16
+ parse: (value: unknown) => T | null
17
+ }
18
+
19
+ interface QuickResponseAdapterArgs {
20
+ codexManager?: CodexAppServerManager
21
+ runClaudeStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
22
+ runCodexStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
23
+ }
24
+
25
+ function parseJsonText(value: string): unknown | null {
26
+ const trimmed = value.trim()
27
+ if (!trimmed) return null
28
+
29
+ const candidates = [trimmed]
30
+ const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)
31
+ if (fencedMatch?.[1]) {
32
+ candidates.unshift(fencedMatch[1].trim())
33
+ }
34
+
35
+ for (const candidate of candidates) {
36
+ try {
37
+ return JSON.parse(candidate)
38
+ } catch {
39
+ continue
40
+ }
41
+ }
42
+
43
+ return null
44
+ }
45
+
46
+ async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknown>, "parse">): Promise<unknown | null> {
47
+ const q = query({
48
+ prompt: args.prompt,
49
+ options: {
50
+ model: "haiku",
51
+ tools: [],
52
+ systemPrompt: "",
53
+ effort: "low",
54
+ permissionMode: "bypassPermissions",
55
+ outputFormat: {
56
+ type: "json_schema",
57
+ schema: args.schema,
58
+ },
59
+ env: { ...process.env },
60
+ },
61
+ })
62
+
63
+ try {
64
+ for await (const message of q) {
65
+ if ("result" in message) {
66
+ return (message as Record<string, unknown>).structured_output ?? null
67
+ }
68
+ }
69
+ return null
70
+ } finally {
71
+ q.close()
72
+ }
73
+ }
74
+
75
+ async function runCodexStructured(
76
+ codexManager: CodexAppServerManager,
77
+ args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
78
+ ): Promise<unknown | null> {
79
+ const response = await codexManager.generateStructured({
80
+ cwd: args.cwd,
81
+ prompt: `${args.prompt}\n\nReturn JSON only that matches this schema:\n${JSON.stringify(args.schema, null, 2)}`,
82
+ })
83
+ if (typeof response !== "string") return null
84
+ return parseJsonText(response)
85
+ }
86
+
87
+ export class QuickResponseAdapter {
88
+ private readonly codexManager: CodexAppServerManager
89
+ private readonly runClaudeStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
90
+ private readonly runCodexStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
91
+
92
+ constructor(args: QuickResponseAdapterArgs = {}) {
93
+ this.codexManager = args.codexManager ?? new CodexAppServerManager()
94
+ this.runClaudeStructured = args.runClaudeStructured ?? runClaudeStructured
95
+ this.runCodexStructured = args.runCodexStructured ?? ((structuredArgs) =>
96
+ runCodexStructured(this.codexManager, structuredArgs))
97
+ }
98
+
99
+ async generateStructured<T>(args: StructuredQuickResponseArgs<T>): Promise<T | null> {
100
+ const request = {
101
+ cwd: args.cwd,
102
+ task: args.task,
103
+ prompt: args.prompt,
104
+ schema: args.schema,
105
+ }
106
+
107
+ const claudeResult = await this.tryProvider(args.parse, () => this.runClaudeStructured(request))
108
+ if (claudeResult !== null) return claudeResult
109
+
110
+ return await this.tryProvider(args.parse, () => this.runCodexStructured(request))
111
+ }
112
+
113
+ private async tryProvider<T>(
114
+ parse: (value: unknown) => T | null,
115
+ run: () => Promise<unknown | null>
116
+ ): Promise<T | null> {
117
+ try {
118
+ const result = await run()
119
+ return result === null ? null : parse(result)
120
+ } catch {
121
+ return null
122
+ }
123
+ }
124
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { deriveChatSnapshot, deriveSidebarData } from "./read-models"
2
+ import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
3
3
  import { createEmptyState } from "./events"
4
4
 
5
5
  describe("read models", () => {
@@ -60,4 +60,46 @@ describe("read models", () => {
60
60
  "gpt-5.3-codex-spark",
61
61
  ])
62
62
  })
63
+
64
+ test("prefers saved project metadata over discovered entries for the same path", () => {
65
+ const state = createEmptyState()
66
+ state.projectsById.set("project-1", {
67
+ id: "project-1",
68
+ localPath: "/tmp/project",
69
+ title: "Saved Project",
70
+ createdAt: 1,
71
+ updatedAt: 50,
72
+ })
73
+ state.projectIdsByPath.set("/tmp/project", "project-1")
74
+ state.chatsById.set("chat-1", {
75
+ id: "chat-1",
76
+ projectId: "project-1",
77
+ title: "Chat",
78
+ createdAt: 1,
79
+ updatedAt: 75,
80
+ provider: "codex",
81
+ planMode: false,
82
+ sessionToken: null,
83
+ lastMessageAt: 100,
84
+ lastTurnOutcome: null,
85
+ })
86
+
87
+ const snapshot = deriveLocalProjectsSnapshot(state, [
88
+ {
89
+ localPath: "/tmp/project",
90
+ title: "Discovered Project",
91
+ modifiedAt: 10,
92
+ },
93
+ ], "Local Machine")
94
+
95
+ expect(snapshot.projects).toEqual([
96
+ {
97
+ localPath: "/tmp/project",
98
+ title: "Saved Project",
99
+ source: "saved",
100
+ lastOpenedAt: 100,
101
+ chatCount: 1,
102
+ },
103
+ ])
104
+ })
63
105
  })
@@ -2,23 +2,25 @@ import path from "node:path"
2
2
  import { APP_NAME } from "../shared/branding"
3
3
  import { EventStore } from "./event-store"
4
4
  import { AgentCoordinator } from "./agent"
5
- import { discoverClaudeProjects, type DiscoveredProject } from "./discovery"
5
+ import { discoverProjects, type DiscoveredProject } from "./discovery"
6
6
  import { getMachineDisplayName } from "./machine-name"
7
7
  import { createWsRouter, type ClientState } from "./ws-router"
8
8
 
9
9
  export interface StartKannaServerOptions {
10
10
  port?: number
11
+ strictPort?: boolean
11
12
  }
12
13
 
13
14
  export async function startKannaServer(options: StartKannaServerOptions = {}) {
14
15
  const port = options.port ?? 3210
16
+ const strictPort = options.strictPort ?? false
15
17
  const store = new EventStore()
16
18
  const machineDisplayName = getMachineDisplayName()
17
19
  await store.initialize()
18
20
  let discoveredProjects: DiscoveredProject[] = []
19
21
 
20
22
  async function refreshDiscovery() {
21
- discoveredProjects = discoverClaudeProjects()
23
+ discoveredProjects = discoverProjects()
22
24
  return discoveredProjects
23
25
  }
24
26
 
@@ -83,7 +85,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
83
85
  } catch (err: unknown) {
84
86
  const isAddrInUse =
85
87
  err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "EADDRINUSE"
86
- if (!isAddrInUse || attempt === MAX_PORT_ATTEMPTS - 1) {
88
+ if (!isAddrInUse || strictPort || attempt === MAX_PORT_ATTEMPTS - 1) {
87
89
  throw err
88
90
  }
89
91
  console.log(`Port ${actualPort} is in use, trying ${actualPort + 1}...`)