kanna-code 0.3.0 → 0.4.1

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.
@@ -4,8 +4,6 @@ import path from "node:path"
4
4
  import type { AgentProvider } from "../shared/types"
5
5
  import { resolveLocalPath } from "./paths"
6
6
 
7
- const LOG_PREFIX = "[kanna discovery]"
8
-
9
7
  export interface DiscoveredProject {
10
8
  localPath: string
11
9
  title: string
@@ -91,23 +89,18 @@ export class ClaudeProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
91
89
  scan(homeDir: string = homedir()): ProviderDiscoveredProject[] {
92
90
  const projectsDir = path.join(homeDir, ".claude", "projects")
93
91
  if (!existsSync(projectsDir)) {
94
- console.log(`${LOG_PREFIX} provider=claude status=missing root=${projectsDir}`)
95
92
  return []
96
93
  }
97
94
 
98
95
  const entries = readdirSync(projectsDir, { withFileTypes: true })
99
96
  const projects: ProviderDiscoveredProject[] = []
100
- let directoryEntries = 0
101
- let skippedMissing = 0
102
97
 
103
98
  for (const entry of entries) {
104
99
  if (!entry.isDirectory()) continue
105
- directoryEntries += 1
106
100
 
107
101
  const resolvedPath = resolveEncodedClaudePath(entry.name)
108
102
  const normalizedPath = normalizeExistingDirectory(resolvedPath)
109
103
  if (!normalizedPath) {
110
- skippedMissing += 1
111
104
  continue
112
105
  }
113
106
 
@@ -125,10 +118,6 @@ export class ClaudeProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
125
118
  ...project,
126
119
  }))
127
120
 
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
121
  return mergedProjects
133
122
  }
134
123
  }
@@ -244,36 +233,19 @@ export class CodexProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
244
233
  const metadataById = readCodexSessionMetadata(sessionsDir)
245
234
  const configuredProjects = readCodexConfiguredProjects(configPath)
246
235
  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
236
 
259
237
  for (const [sessionId, metadata] of metadataById.entries()) {
260
238
  const modifiedAt = updatedAtById.get(sessionId) ?? metadata.modifiedAt
261
239
  const cwd = metadata.cwd
262
- if (!updatedAtById.has(sessionId)) {
263
- fallbackSessionTimestamps += 1
264
- }
265
240
  if (!cwd) {
266
- skippedMissingMeta += 1
267
241
  continue
268
242
  }
269
243
  if (!path.isAbsolute(cwd)) {
270
- skippedRelative += 1
271
244
  continue
272
245
  }
273
246
 
274
247
  const normalizedPath = normalizeExistingDirectory(cwd)
275
248
  if (!normalizedPath) {
276
- skippedMissingPath += 1
277
249
  continue
278
250
  }
279
251
 
@@ -287,17 +259,14 @@ export class CodexProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
287
259
 
288
260
  for (const [configuredPath, modifiedAt] of configuredProjects.entries()) {
289
261
  if (!path.isAbsolute(configuredPath)) {
290
- skippedRelative += 1
291
262
  continue
292
263
  }
293
264
 
294
265
  const normalizedPath = normalizeExistingDirectory(configuredPath)
295
266
  if (!normalizedPath) {
296
- skippedMissingPath += 1
297
267
  continue
298
268
  }
299
269
 
300
- configProjectsIncluded += 1
301
270
  projects.push({
302
271
  provider: this.provider,
303
272
  localPath: normalizedPath,
@@ -311,10 +280,6 @@ export class CodexProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
311
280
  ...project,
312
281
  }))
313
282
 
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
283
  return mergedProjects
319
284
  }
320
285
  }
@@ -332,9 +297,5 @@ export function discoverProjects(
332
297
  adapters.flatMap((adapter) => adapter.scan(homeDir).map(({ provider: _provider, ...project }) => project))
333
298
  )
334
299
 
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
300
  return mergedProjects
340
301
  }
@@ -8,10 +8,12 @@ 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()
@@ -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}...`)
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { PROTOCOL_VERSION } from "../shared/types"
3
+ import { createEmptyState } from "./events"
4
+ import { createWsRouter } from "./ws-router"
5
+
6
+ class FakeWebSocket {
7
+ readonly sent: unknown[] = []
8
+ readonly data = {
9
+ subscriptions: new Map(),
10
+ }
11
+
12
+ send(message: string) {
13
+ this.sent.push(JSON.parse(message))
14
+ }
15
+ }
16
+
17
+ describe("ws-router", () => {
18
+ test("acks system.ping without broadcasting snapshots", () => {
19
+ const router = createWsRouter({
20
+ store: { state: createEmptyState() } as never,
21
+ agent: { getActiveStatuses: () => new Map() } as never,
22
+ refreshDiscovery: async () => [],
23
+ getDiscoveredProjects: () => [],
24
+ machineDisplayName: "Local Machine",
25
+ })
26
+ const ws = new FakeWebSocket()
27
+
28
+ ws.data.subscriptions.set("sub-1", { type: "sidebar" })
29
+ router.handleMessage(
30
+ ws as never,
31
+ JSON.stringify({
32
+ v: 1,
33
+ type: "command",
34
+ id: "ping-1",
35
+ command: { type: "system.ping" },
36
+ })
37
+ )
38
+
39
+ expect(ws.sent).toEqual([
40
+ {
41
+ v: PROTOCOL_VERSION,
42
+ type: "ack",
43
+ id: "ping-1",
44
+ },
45
+ ])
46
+ })
47
+ })
@@ -89,6 +89,10 @@ export function createWsRouter({
89
89
  const { command, id } = message
90
90
  try {
91
91
  switch (command.type) {
92
+ case "system.ping": {
93
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
94
+ return
95
+ }
92
96
  case "project.open": {
93
97
  await ensureProjectDirectory(command.localPath)
94
98
  const project = await store.openProject(command.localPath)
@@ -9,6 +9,7 @@ export type ClientCommand =
9
9
  | { type: "project.open"; localPath: string }
10
10
  | { type: "project.create"; localPath: string; title: string }
11
11
  | { type: "project.remove"; projectId: string }
12
+ | { type: "system.ping" }
12
13
  | { type: "system.openExternal"; localPath: string; action: "open_finder" | "open_terminal" | "open_editor" }
13
14
  | { type: "chat.create"; projectId: string }
14
15
  | { type: "chat.rename"; chatId: string; title: string }
@@ -62,7 +62,18 @@ describe("hydrateToolResult", () => {
62
62
  })
63
63
 
64
64
  const result = hydrateToolResult(tool, JSON.stringify({ answers: { runtime: "codex" } }))
65
- expect(result).toEqual({ answers: { runtime: "codex" } })
65
+ expect(result).toEqual({ answers: { runtime: ["codex"] } })
66
+ })
67
+
68
+ test("hydrates AskUserQuestion multi-select answers", () => {
69
+ const tool = normalizeToolCall({
70
+ toolName: "AskUserQuestion",
71
+ toolId: "tool-1",
72
+ input: { questions: [] },
73
+ })
74
+
75
+ const result = hydrateToolResult(tool, JSON.stringify({ answers: { runtime: ["bun", "node"] } }))
76
+ expect(result).toEqual({ answers: { runtime: ["bun", "node"] } })
66
77
  })
67
78
 
68
79
  test("hydrates ExitPlanMode decisions", () => {
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AskUserQuestionItem,
3
+ AskUserQuestionAnswerMap,
3
4
  AskUserQuestionToolResult,
4
5
  ExitPlanModeToolResult,
5
6
  HydratedToolCall,
@@ -209,7 +210,23 @@ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): Hydra
209
210
  case "ask_user_question": {
210
211
  const record = asRecord(parsed)
211
212
  const answers = asRecord(record?.answers) ?? (record ? record : {})
212
- return { answers: Object.fromEntries(Object.entries(answers).map(([key, value]) => [key, String(value)])) } satisfies AskUserQuestionToolResult
213
+ return {
214
+ answers: Object.fromEntries(
215
+ Object.entries(answers).map(([key, value]) => {
216
+ if (Array.isArray(value)) {
217
+ return [key, value.map((entry) => String(entry))]
218
+ }
219
+ if (value && typeof value === "object" && Array.isArray((value as { answers?: unknown }).answers)) {
220
+ return [key, (value as { answers: unknown[] }).answers.map((entry) => String(entry))]
221
+ }
222
+ if (value == null || value === "") {
223
+ return [key, []]
224
+ }
225
+ return [key, [String(value)]]
226
+ })
227
+ ) as AskUserQuestionAnswerMap,
228
+ ...(record?.discarded === true ? { discarded: true } : {}),
229
+ } satisfies AskUserQuestionToolResult
213
230
  }
214
231
  case "exit_plan_mode": {
215
232
  const record = asRecord(parsed)
@@ -217,6 +234,7 @@ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): Hydra
217
234
  confirmed: typeof record?.confirmed === "boolean" ? record.confirmed : undefined,
218
235
  clearContext: typeof record?.clearContext === "boolean" ? record.clearContext : undefined,
219
236
  message: typeof record?.message === "string" ? record.message : undefined,
237
+ ...(record?.discarded === true ? { discarded: true } : {}),
220
238
  } satisfies ExitPlanModeToolResult
221
239
  }
222
240
  case "read_file":
@@ -194,6 +194,8 @@ export interface AskUserQuestionItem {
194
194
  multiSelect?: boolean
195
195
  }
196
196
 
197
+ export type AskUserQuestionAnswerMap = Record<string, string[]>
198
+
197
199
  export interface TodoItem {
198
200
  content: string
199
201
  status: "pending" | "in_progress" | "completed"
@@ -373,13 +375,15 @@ export interface HydratedToolCallBase<TKind extends string, TInput, TResult> {
373
375
  }
374
376
 
375
377
  export interface AskUserQuestionToolResult {
376
- answers: Record<string, string>
378
+ answers: AskUserQuestionAnswerMap
379
+ discarded?: boolean
377
380
  }
378
381
 
379
382
  export interface ExitPlanModeToolResult {
380
383
  confirmed?: boolean
381
384
  clearContext?: boolean
382
385
  message?: string
386
+ discarded?: boolean
383
387
  }
384
388
 
385
389
  export type HydratedAskUserQuestionToolCall =