kanna-code 0.13.9 → 0.15.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,7 +5,7 @@
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-BypLQCc0.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-C6R1P_HL.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-CA-kJR8F.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.13.9",
4
+ "version": "0.15.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"
@@ -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
  }
@@ -69,15 +70,18 @@ Usage:
69
70
  ${CLI_COMMAND} [options]
70
71
 
71
72
  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`)
73
+ --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
74
+ --host <host> Bind to a specific host or IP
75
+ --remote Shortcut for --host 0.0.0.0
76
+ --strict-port Fail instead of trying another port
77
+ --no-open Don't open browser automatically
78
+ --version Print version and exit
79
+ --help Show this help message`)
77
80
  }
78
81
 
79
82
  export function parseArgs(argv: string[]): ParsedArgs {
80
83
  let port = PROD_SERVER_PORT
84
+ let host = "127.0.0.1"
81
85
  let openBrowser = true
82
86
  let strictPort = false
83
87
 
@@ -96,6 +100,17 @@ export function parseArgs(argv: string[]): ParsedArgs {
96
100
  index += 1
97
101
  continue
98
102
  }
103
+ if (arg === "--host") {
104
+ const next = argv[index + 1]
105
+ if (!next || next.startsWith("-")) throw new Error("Missing value for --host")
106
+ host = next
107
+ index += 1
108
+ continue
109
+ }
110
+ if (arg === "--remote") {
111
+ host = "0.0.0.0"
112
+ continue
113
+ }
99
114
  if (arg === "--no-open") {
100
115
  openBrowser = false
101
116
  continue
@@ -111,6 +126,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
111
126
  kind: "run",
112
127
  options: {
113
128
  port,
129
+ host,
114
130
  openBrowser,
115
131
  strictPort,
116
132
  },
@@ -210,10 +226,11 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
210
226
  command: CLI_COMMAND,
211
227
  },
212
228
  })
213
- const url = `http://localhost:${port}`
214
- const launchUrl = url
229
+ const bindHost = parsedArgs.options.host
230
+ const displayHost = bindHost === "127.0.0.1" || bindHost === "0.0.0.0" ? "localhost" : bindHost
231
+ const launchUrl = `http://${displayHost}:${port}`
215
232
 
216
- deps.log(`${LOG_PREFIX} listening on ${url}`)
233
+ deps.log(`${LOG_PREFIX} listening on http://${bindHost}:${port}`)
217
234
  deps.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
218
235
 
219
236
  const suppressOpenBrowser = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] === "1"
@@ -56,6 +56,35 @@ describe("QuickResponseAdapter", () => {
56
56
 
57
57
  expect(result).toBe("Codex title")
58
58
  })
59
+
60
+ test("falls back to Codex when Claude throws", async () => {
61
+ const adapter = new QuickResponseAdapter({
62
+ runClaudeStructured: async () => {
63
+ throw new Error("Not authenticated")
64
+ },
65
+ runCodexStructured: async () => ({ title: "Codex title" }),
66
+ })
67
+
68
+ const result = await adapter.generateStructured({
69
+ cwd: "/tmp/project",
70
+ task: "title generation",
71
+ prompt: "Generate a title",
72
+ schema: {
73
+ type: "object",
74
+ properties: {
75
+ title: { type: "string" },
76
+ },
77
+ required: ["title"],
78
+ additionalProperties: false,
79
+ },
80
+ parse: (value) => {
81
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
82
+ return typeof output.title === "string" ? output.title : null
83
+ },
84
+ })
85
+
86
+ expect(result).toBe("Codex title")
87
+ })
59
88
  })
60
89
 
61
90
  describe("generateTitleForChat", () => {
@@ -1,6 +1,8 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk"
2
2
  import { CodexAppServerManager } from "./codex-app-server"
3
3
 
4
+ const CLAUDE_STRUCTURED_TIMEOUT_MS = 5_000
5
+
4
6
  type JsonSchema = {
5
7
  type: "object"
6
8
  properties: Record<string, unknown>
@@ -61,14 +63,31 @@ async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknow
61
63
  })
62
64
 
63
65
  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
- }
66
+ const result = await Promise.race<unknown | null>([
67
+ (async () => {
68
+ for await (const message of q) {
69
+ if ("result" in message) {
70
+ return (message as Record<string, unknown>).structured_output ?? null
71
+ }
72
+ }
73
+ return null
74
+ })(),
75
+ new Promise<null>((_, reject) => {
76
+ setTimeout(() => {
77
+ reject(new Error(`Claude structured response timed out after ${CLAUDE_STRUCTURED_TIMEOUT_MS}ms`))
78
+ }, CLAUDE_STRUCTURED_TIMEOUT_MS)
79
+ }),
80
+ ])
81
+
82
+ return result
83
+ } catch (error) {
69
84
  return null
70
85
  } finally {
71
- q.close()
86
+ try {
87
+ q.close()
88
+ } catch {
89
+ // Ignore close failures on timed-out or failed quick responses.
90
+ }
72
91
  }
73
92
  }
74
93
 
@@ -95,7 +114,6 @@ export class QuickResponseAdapter {
95
114
  this.runCodexStructured = args.runCodexStructured ?? ((structuredArgs) =>
96
115
  runCodexStructured(this.codexManager, structuredArgs))
97
116
  }
98
-
99
117
  async generateStructured<T>(args: StructuredQuickResponseArgs<T>): Promise<T | null> {
100
118
  const request = {
101
119
  cwd: args.cwd,
@@ -104,20 +122,31 @@ export class QuickResponseAdapter {
104
122
  schema: args.schema,
105
123
  }
106
124
 
107
- const claudeResult = await this.tryProvider(args.parse, () => this.runClaudeStructured(request))
125
+ const claudeResult = await this.tryProvider("claude", args.task, args.parse, () => this.runClaudeStructured(request))
108
126
  if (claudeResult !== null) return claudeResult
109
127
 
110
- return await this.tryProvider(args.parse, () => this.runCodexStructured(request))
128
+ return await this.tryProvider("codex", args.task, args.parse, () => this.runCodexStructured(request))
111
129
  }
112
130
 
113
131
  private async tryProvider<T>(
132
+ provider: "claude" | "codex",
133
+ task: string,
114
134
  parse: (value: unknown) => T | null,
115
135
  run: () => Promise<unknown | null>
116
136
  ): Promise<T | null> {
117
137
  try {
118
138
  const result = await run()
119
- return result === null ? null : parse(result)
120
- } catch {
139
+ if (result === null) {
140
+ return null
141
+ }
142
+
143
+ const parsed = parse(result)
144
+ if (parsed === null) {
145
+ return null
146
+ }
147
+
148
+ return parsed
149
+ } catch (error) {
121
150
  return null
122
151
  }
123
152
  }
@@ -12,6 +12,7 @@ import { createWsRouter, type ClientState } from "./ws-router"
12
12
 
13
13
  export interface StartKannaServerOptions {
14
14
  port?: number
15
+ host?: string
15
16
  strictPort?: boolean
16
17
  update?: {
17
18
  version: string
@@ -22,6 +23,7 @@ export interface StartKannaServerOptions {
22
23
 
23
24
  export async function startKannaServer(options: StartKannaServerOptions = {}) {
24
25
  const port = options.port ?? 3210
26
+ const hostname = options.host ?? "127.0.0.1"
25
27
  const strictPort = options.strictPort ?? false
26
28
  const store = new EventStore()
27
29
  const machineDisplayName = getMachineDisplayName()
@@ -74,6 +76,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
74
76
  try {
75
77
  server = Bun.serve<ClientState>({
76
78
  port: actualPort,
79
+ hostname,
77
80
  fetch(req, serverInstance) {
78
81
  const url = new URL(req.url)
79
82
 
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ DEFAULT_DEV_CLIENT_PORT,
4
+ getDefaultDevServerPort,
5
+ resolveDevPorts,
6
+ stripPortArg,
7
+ } from "./dev-ports"
8
+
9
+ describe("getDefaultDevServerPort", () => {
10
+ test("derives the default backend port from the client port", () => {
11
+ expect(getDefaultDevServerPort()).toBe(DEFAULT_DEV_CLIENT_PORT + 1)
12
+ expect(getDefaultDevServerPort(4000)).toBe(4001)
13
+ })
14
+ })
15
+
16
+ describe("resolveDevPorts", () => {
17
+ test("uses default dev ports when no port override is provided", () => {
18
+ expect(resolveDevPorts([])).toEqual({
19
+ clientPort: DEFAULT_DEV_CLIENT_PORT,
20
+ serverPort: DEFAULT_DEV_CLIENT_PORT + 1,
21
+ })
22
+ })
23
+
24
+ test("treats --port as the client port and derives the backend port", () => {
25
+ expect(resolveDevPorts(["--remote", "--port", "4000"])).toEqual({
26
+ clientPort: 4000,
27
+ serverPort: 4001,
28
+ })
29
+ })
30
+
31
+ test("uses the last provided --port value", () => {
32
+ expect(resolveDevPorts(["--port", "4000", "--port", "4100"])).toEqual({
33
+ clientPort: 4100,
34
+ serverPort: 4101,
35
+ })
36
+ })
37
+
38
+ test("throws when --port is missing a value", () => {
39
+ expect(() => resolveDevPorts(["--port"])).toThrow("Missing value for --port")
40
+ expect(() => resolveDevPorts(["--port", "--remote"])).toThrow("Missing value for --port")
41
+ })
42
+ })
43
+
44
+ describe("stripPortArg", () => {
45
+ test("removes --port and its value while preserving other args", () => {
46
+ expect(stripPortArg(["--remote", "--port", "4000", "--host", "dev-box"])).toEqual([
47
+ "--remote",
48
+ "--host",
49
+ "dev-box",
50
+ ])
51
+ })
52
+ })
@@ -0,0 +1,43 @@
1
+ export const DEFAULT_DEV_CLIENT_PORT = 5174
2
+
3
+ export function getDefaultDevServerPort(clientPort = DEFAULT_DEV_CLIENT_PORT) {
4
+ return clientPort + 1
5
+ }
6
+
7
+ export function resolveDevPorts(args: string[]) {
8
+ let clientPort = DEFAULT_DEV_CLIENT_PORT
9
+
10
+ for (let index = 0; index < args.length; index += 1) {
11
+ const arg = args[index]
12
+ if (arg !== "--port") continue
13
+
14
+ const next = args[index + 1]
15
+ if (!next || next.startsWith("-")) {
16
+ throw new Error("Missing value for --port")
17
+ }
18
+
19
+ clientPort = Number(next)
20
+ index += 1
21
+ }
22
+
23
+ return {
24
+ clientPort,
25
+ serverPort: getDefaultDevServerPort(clientPort),
26
+ }
27
+ }
28
+
29
+ export function stripPortArg(args: string[]) {
30
+ const stripped: string[] = []
31
+
32
+ for (let index = 0; index < args.length; index += 1) {
33
+ const arg = args[index]
34
+ if (arg === "--port") {
35
+ index += 1
36
+ continue
37
+ }
38
+
39
+ stripped.push(arg)
40
+ }
41
+
42
+ return stripped
43
+ }
@@ -1,3 +1,2 @@
1
1
  export const PROD_SERVER_PORT = 3210
2
- export const DEV_SERVER_PORT = 3211
3
2
  export const DEV_CLIENT_PORT = 5174
@@ -82,7 +82,7 @@ export const PROVIDERS: ProviderCatalogEntry[] = [
82
82
  {
83
83
  id: "claude",
84
84
  label: "Claude",
85
- defaultModel: "opus",
85
+ defaultModel: "sonnet",
86
86
  defaultEffort: "high",
87
87
  supportsPlanMode: true,
88
88
  models: [