kanna-code 0.14.0 → 0.16.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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Kanna</title>
8
- <script type="module" crossorigin src="/assets/index-CVu2H5bX.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-D5tYLJi-.css">
8
+ <script type="module" crossorigin src="/assets/index-DMxCijCf.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BET6YVYr.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.14.0",
4
+ "version": "0.16.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -37,7 +37,8 @@
37
37
  "check": "tsc --noEmit && vite build",
38
38
  "dev": "bun run ./scripts/dev.ts",
39
39
  "dev:client": "vite --host 0.0.0.0 --port 5174",
40
- "dev:server": "bun run ./scripts/dev-server.ts --no-open --port 3211",
40
+ "dev:server": "bun run ./scripts/dev-server.ts --no-open --port 5175",
41
+ "install:dev": "bun install && bun run build && bun install -g .",
41
42
  "start": "bun run ./src/server/cli.ts",
42
43
  "test": "bun test",
43
44
  "prepublishOnly": "vite build"
@@ -19,6 +19,7 @@ import {
19
19
  normalizeCodexModelOptions,
20
20
  normalizeServerModel,
21
21
  } from "./provider-catalog"
22
+ import { resolveClaudeApiModelId } from "../shared/types"
22
23
 
23
24
  const CLAUDE_TOOLSET = [
24
25
  "Skill",
@@ -39,8 +40,6 @@ const CLAUDE_TOOLSET = [
39
40
  "ExitPlanMode",
40
41
  ] as const
41
42
 
42
- const TITLE_LOG_PREFIX = "[kanna:title]"
43
-
44
43
  interface PendingToolRequest {
45
44
  toolUseId: string
46
45
  tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
@@ -367,9 +366,10 @@ export class AgentCoordinator {
367
366
  private getProviderSettings(provider: AgentProvider, command: Extract<ClientCommand, { type: "chat.send" }>) {
368
367
  const catalog = getServerProviderCatalog(provider)
369
368
  if (provider === "claude") {
370
- const modelOptions = normalizeClaudeModelOptions(command.modelOptions, command.effort)
369
+ const model = normalizeServerModel(provider, command.model)
370
+ const modelOptions = normalizeClaudeModelOptions(model, command.modelOptions, command.effort)
371
371
  return {
372
- model: normalizeServerModel(provider, command.model),
372
+ model: resolveClaudeApiModelId(model, modelOptions.contextWindow),
373
373
  effort: modelOptions.reasoningEffort,
374
374
  serviceTier: undefined,
375
375
  planMode: catalog.supportsPlanMode ? Boolean(command.planMode) : false,
@@ -408,14 +408,6 @@ export class AgentCoordinator {
408
408
  const existingMessages = this.store.getMessages(args.chatId)
409
409
  const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
410
410
 
411
- console.warn(`${TITLE_LOG_PREFIX} title generation check`, {
412
- chatId: args.chatId,
413
- appendUserPrompt: args.appendUserPrompt,
414
- currentTitle: chat.title,
415
- existingMessageCount: existingMessages.length,
416
- shouldGenerateTitle,
417
- })
418
-
419
411
  if (args.appendUserPrompt) {
420
412
  await this.store.appendMessage(args.chatId, timestamped({ kind: "user_prompt", content: args.content }, Date.now()))
421
413
  }
@@ -428,10 +420,6 @@ export class AgentCoordinator {
428
420
 
429
421
  if (shouldGenerateTitle) {
430
422
  void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
431
- } else {
432
- console.warn(`${TITLE_LOG_PREFIX} not starting background title generation`, {
433
- chatId: args.chatId,
434
- })
435
423
  }
436
424
 
437
425
  const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
@@ -543,32 +531,16 @@ export class AgentCoordinator {
543
531
 
544
532
  private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string) {
545
533
  try {
546
- console.log(`${TITLE_LOG_PREFIX} background generation started`, {
547
- chatId,
548
- cwd,
549
- messagePreview: messageContent.replace(/\s+/g, " ").trim().slice(0, 120),
550
- })
551
534
  const title = await this.generateTitle(messageContent, cwd)
552
- if (!title) {
553
- console.warn(`${TITLE_LOG_PREFIX} background generation produced no title`, { chatId })
554
- return
555
- }
535
+ if (!title) return
556
536
 
557
537
  const chat = this.store.requireChat(chatId)
558
- if (chat.title !== "New Chat") {
559
- console.log(`${TITLE_LOG_PREFIX} skipping rename because chat title already changed`, {
560
- chatId,
561
- currentTitle: chat.title,
562
- generatedTitle: title,
563
- })
564
- return
565
- }
538
+ if (chat.title !== "New Chat") return
566
539
 
567
540
  await this.store.renameChat(chatId, title)
568
- console.log(`${TITLE_LOG_PREFIX} renamed chat from generated title`, { chatId, title })
569
541
  this.onStateChange()
570
- } catch (error) {
571
- console.warn(`${TITLE_LOG_PREFIX} background title generation failed for chat ${chatId}:`, error)
542
+ } catch {
543
+ // Ignore background title generation failures.
572
544
  }
573
545
  }
574
546
 
@@ -22,6 +22,7 @@ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
22
22
  const calls = {
23
23
  startServer: [] as Array<{
24
24
  port: number
25
+ host: string
25
26
  openBrowser: boolean
26
27
  strictPort: boolean
27
28
  update: {
@@ -81,6 +82,7 @@ describe("parseArgs", () => {
81
82
  kind: "run",
82
83
  options: {
83
84
  port: 4000,
85
+ host: "127.0.0.1",
84
86
  openBrowser: false,
85
87
  strictPort: false,
86
88
  },
@@ -92,12 +94,54 @@ describe("parseArgs", () => {
92
94
  kind: "run",
93
95
  options: {
94
96
  port: 3210,
97
+ host: "127.0.0.1",
95
98
  openBrowser: true,
96
99
  strictPort: true,
97
100
  },
98
101
  })
99
102
  })
100
103
 
104
+ test("--remote without value binds all interfaces", () => {
105
+ expect(parseArgs(["--remote"])).toEqual({
106
+ kind: "run",
107
+ options: {
108
+ port: 3210,
109
+ host: "0.0.0.0",
110
+ openBrowser: true,
111
+ strictPort: false,
112
+ },
113
+ })
114
+ })
115
+
116
+ test("--host with IP binds to that address", () => {
117
+ expect(parseArgs(["--host", "100.64.0.1"])).toEqual({
118
+ kind: "run",
119
+ options: {
120
+ port: 3210,
121
+ host: "100.64.0.1",
122
+ openBrowser: true,
123
+ strictPort: false,
124
+ },
125
+ })
126
+ })
127
+
128
+ test("--host with hostname binds to that name", () => {
129
+ expect(parseArgs(["--host", "dev-box"])).toEqual({
130
+ kind: "run",
131
+ options: {
132
+ port: 3210,
133
+ host: "dev-box",
134
+ openBrowser: true,
135
+ strictPort: false,
136
+ },
137
+ })
138
+ })
139
+
140
+ test("--host without a value throws", () => {
141
+ expect(() => parseArgs(["--host"])).toThrow("Missing value for --host")
142
+ expect(() => parseArgs(["--host", "--no-open"])).toThrow("Missing value for --host")
143
+ })
144
+
101
145
  test("returns version and help actions without running startup", () => {
102
146
  expect(parseArgs(["--version"])).toEqual({ kind: "version" })
103
147
  expect(parseArgs(["--help"])).toEqual({ kind: "help" })
@@ -146,6 +190,7 @@ describe("runCli", () => {
146
190
  expect(calls.startServer).toHaveLength(1)
147
191
  expect(calls.startServer[0]).toMatchObject({
148
192
  port: 4000,
193
+ host: "127.0.0.1",
149
194
  openBrowser: false,
150
195
  strictPort: false,
151
196
  update: {
@@ -180,6 +225,7 @@ describe("runCli", () => {
180
225
  })
181
226
 
182
227
  test("opens the root route in the browser", async () => {
228
+ delete process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]
183
229
  const { calls, deps } = createDeps()
184
230
 
185
231
  await runCli(["--port", "4000"], deps)
@@ -187,6 +233,15 @@ describe("runCli", () => {
187
233
  expect(calls.openUrl).toEqual(["http://localhost:4000"])
188
234
  })
189
235
 
236
+ test("opens browser at hostname when --host <host> is given", async () => {
237
+ delete process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]
238
+ const { calls, deps } = createDeps()
239
+
240
+ await runCli(["--host", "dev-box", "--port", "4000"], deps)
241
+
242
+ expect(calls.openUrl).toEqual(["http://dev-box:4000"])
243
+ })
244
+
190
245
  test("suppresses browser open for a ui-triggered restarted child", async () => {
191
246
  process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] = "1"
192
247
  const { calls, deps } = createDeps()
@@ -8,6 +8,7 @@ import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
8
8
 
9
9
  export interface CliOptions {
10
10
  port: number
11
+ host: string
11
12
  openBrowser: boolean
12
13
  strictPort: boolean
13
14
  }
@@ -40,7 +41,10 @@ export type CliRunResult = StartedCli | RestartingCli | ExitedCli
40
41
  export interface CliRuntimeDeps {
41
42
  version: string
42
43
  bunVersion: string
43
- startServer: (options: CliOptions & { update: CliUpdateOptions }) => Promise<{ port: number; stop: () => Promise<void> }>
44
+ startServer: (options: CliOptions & {
45
+ update: CliUpdateOptions
46
+ onMigrationProgress?: (message: string) => void
47
+ }) => Promise<{ port: number; stop: () => Promise<void> }>
44
48
  fetchLatestVersion: (packageName: string) => Promise<string>
45
49
  installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
46
50
  openUrl: (url: string) => void
@@ -69,15 +73,18 @@ Usage:
69
73
  ${CLI_COMMAND} [options]
70
74
 
71
75
  Options:
72
- --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
73
- --strict-port Fail instead of trying another port
74
- --no-open Don't open browser automatically
75
- --version Print version and exit
76
- --help Show this help message`)
76
+ --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
77
+ --host <host> Bind to a specific host or IP
78
+ --remote Shortcut for --host 0.0.0.0
79
+ --strict-port Fail instead of trying another port
80
+ --no-open Don't open browser automatically
81
+ --version Print version and exit
82
+ --help Show this help message`)
77
83
  }
78
84
 
79
85
  export function parseArgs(argv: string[]): ParsedArgs {
80
86
  let port = PROD_SERVER_PORT
87
+ let host = "127.0.0.1"
81
88
  let openBrowser = true
82
89
  let strictPort = false
83
90
 
@@ -96,6 +103,17 @@ export function parseArgs(argv: string[]): ParsedArgs {
96
103
  index += 1
97
104
  continue
98
105
  }
106
+ if (arg === "--host") {
107
+ const next = argv[index + 1]
108
+ if (!next || next.startsWith("-")) throw new Error("Missing value for --host")
109
+ host = next
110
+ index += 1
111
+ continue
112
+ }
113
+ if (arg === "--remote") {
114
+ host = "0.0.0.0"
115
+ continue
116
+ }
99
117
  if (arg === "--no-open") {
100
118
  openBrowser = false
101
119
  continue
@@ -111,6 +129,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
111
129
  kind: "run",
112
130
  options: {
113
131
  port,
132
+ host,
114
133
  openBrowser,
115
134
  strictPort,
116
135
  },
@@ -202,6 +221,7 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
202
221
 
203
222
  const { port, stop } = await deps.startServer({
204
223
  ...parsedArgs.options,
224
+ onMigrationProgress: deps.log,
205
225
  update: {
206
226
  version: deps.version,
207
227
  fetchLatestVersion: deps.fetchLatestVersion,
@@ -210,10 +230,11 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
210
230
  command: CLI_COMMAND,
211
231
  },
212
232
  })
213
- const url = `http://localhost:${port}`
214
- const launchUrl = url
233
+ const bindHost = parsedArgs.options.host
234
+ const displayHost = bindHost === "127.0.0.1" || bindHost === "0.0.0.0" ? "localhost" : bindHost
235
+ const launchUrl = `http://${displayHost}:${port}`
215
236
 
216
- deps.log(`${LOG_PREFIX} listening on ${url}`)
237
+ deps.log(`${LOG_PREFIX} listening on http://${bindHost}:${port}`)
217
238
  deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
218
239
 
219
240
  const suppressOpenBrowser = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] === "1"
@@ -394,7 +394,14 @@ export interface ItemCompletedNotification {
394
394
  }
395
395
 
396
396
  export interface ErrorNotification {
397
- message: string
397
+ error: {
398
+ message: string
399
+ codexErrorInfo?: string
400
+ additionalDetails?: unknown
401
+ }
402
+ willRetry: boolean
403
+ threadId?: string
404
+ turnId?: string
398
405
  }
399
406
 
400
407
  export type ServerNotification =
@@ -1114,7 +1114,7 @@ export class CodexAppServerManager {
1114
1114
  this.handleContextCompacted(pendingTurn, notification.params)
1115
1115
  return
1116
1116
  case "error":
1117
- this.failContext(context, notification.params.message)
1117
+ this.failContext(context, notification.params.error.message)
1118
1118
  return
1119
1119
  default:
1120
1120
  return
@@ -1,16 +1,39 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3
+ import { existsSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import type { TranscriptEntry } from "../shared/types"
7
+ import type { SnapshotFile } from "./events"
2
8
  import { EventStore } from "./event-store"
3
9
 
4
10
  const originalRuntimeProfile = process.env.KANNA_RUNTIME_PROFILE
11
+ const tempDirs: string[] = []
5
12
 
6
- afterEach(() => {
13
+ afterEach(async () => {
7
14
  if (originalRuntimeProfile === undefined) {
8
15
  delete process.env.KANNA_RUNTIME_PROFILE
9
16
  } else {
10
17
  process.env.KANNA_RUNTIME_PROFILE = originalRuntimeProfile
11
18
  }
19
+
20
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
12
21
  })
13
22
 
23
+ async function createTempDataDir() {
24
+ const dir = await mkdtemp(join(tmpdir(), "kanna-event-store-"))
25
+ tempDirs.push(dir)
26
+ return dir
27
+ }
28
+
29
+ function entry(kind: "user_prompt" | "assistant_text", createdAt: number, extra: Record<string, unknown> = {}): TranscriptEntry {
30
+ const base = { _id: `${kind}-${createdAt}`, createdAt }
31
+ if (kind === "user_prompt") {
32
+ return { ...base, kind, content: String(extra.content ?? "") }
33
+ }
34
+ return { ...base, kind, text: String(extra.content ?? extra.text ?? "") }
35
+ }
36
+
14
37
  describe("EventStore", () => {
15
38
  test("uses the runtime profile for the default data dir", () => {
16
39
  process.env.KANNA_RUNTIME_PROFILE = "dev"
@@ -19,4 +42,92 @@ describe("EventStore", () => {
19
42
 
20
43
  expect(store.dataDir).toEndWith("/.kanna-dev/data")
21
44
  })
45
+
46
+ test("migrates legacy snapshot and messages log transcripts into per-chat files", async () => {
47
+ const dataDir = await createTempDataDir()
48
+ const snapshotPath = join(dataDir, "snapshot.json")
49
+ const messagesLogPath = join(dataDir, "messages.jsonl")
50
+ const chatId = "chat-1"
51
+
52
+ const snapshot: SnapshotFile = {
53
+ v: 2,
54
+ generatedAt: 10,
55
+ projects: [{
56
+ id: "project-1",
57
+ localPath: "/tmp/project",
58
+ title: "Project",
59
+ createdAt: 1,
60
+ updatedAt: 5,
61
+ }],
62
+ chats: [{
63
+ id: chatId,
64
+ projectId: "project-1",
65
+ title: "Chat",
66
+ createdAt: 1,
67
+ updatedAt: 5,
68
+ provider: null,
69
+ planMode: false,
70
+ sessionToken: null,
71
+ lastTurnOutcome: null,
72
+ }],
73
+ messages: [{
74
+ chatId,
75
+ entries: [
76
+ entry("user_prompt", 100, { content: "hello" }),
77
+ ],
78
+ }],
79
+ }
80
+
81
+ await writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf8")
82
+ await writeFile(messagesLogPath, `${JSON.stringify({
83
+ v: 2,
84
+ type: "message_appended",
85
+ timestamp: 101,
86
+ chatId,
87
+ entry: entry("assistant_text", 101, { content: "world" }),
88
+ })}\n`, "utf8")
89
+
90
+ const store = new EventStore(dataDir)
91
+ await store.initialize()
92
+
93
+ const progress: string[] = []
94
+ const migrated = await store.migrateLegacyTranscripts((message) => {
95
+ progress.push(message)
96
+ })
97
+
98
+ expect(migrated).toBe(true)
99
+ expect(progress.some((message) => message.includes("transcript migration detected"))).toBe(true)
100
+ expect(progress.at(-1)).toContain("transcript migration complete")
101
+ expect(store.getMessages(chatId)).toEqual([
102
+ entry("user_prompt", 100, { content: "hello" }),
103
+ entry("assistant_text", 101, { text: "world" }),
104
+ ])
105
+
106
+ const migratedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8")) as SnapshotFile
107
+ expect(migratedSnapshot.messages).toBeUndefined()
108
+ expect(await readFile(messagesLogPath, "utf8")).toBe("")
109
+ expect(await readFile(join(dataDir, "transcripts", `${chatId}.jsonl`), "utf8")).toContain('"kind":"assistant_text"')
110
+ })
111
+
112
+ test("appends new transcript entries only to the per-chat transcript file", async () => {
113
+ const dataDir = await createTempDataDir()
114
+ const store = new EventStore(dataDir)
115
+ await store.initialize()
116
+
117
+ const project = await store.openProject("/tmp/project")
118
+ const chat = await store.createChat(project.id)
119
+ await store.appendMessage(chat.id, entry("user_prompt", 200, { content: "hello" }))
120
+ await store.appendMessage(chat.id, entry("assistant_text", 201, { content: "world" }))
121
+ await store.compact()
122
+
123
+ expect(store.getMessages(chat.id)).toEqual([
124
+ entry("user_prompt", 200, { content: "hello" }),
125
+ entry("assistant_text", 201, { text: "world" }),
126
+ ])
127
+ expect(await readFile(join(dataDir, "messages.jsonl"), "utf8")).toBe("")
128
+
129
+ const snapshot = JSON.parse(await readFile(join(dataDir, "snapshot.json"), "utf8")) as SnapshotFile
130
+ expect(snapshot.messages).toBeUndefined()
131
+ expect(existsSync(join(dataDir, "transcripts", `${chat.id}.jsonl`))).toBe(true)
132
+ })
22
133
  })