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.
- package/dist/client/assets/index-BXpHm8Wa.css +1 -0
- package/dist/client/assets/index-CcZLosQ7.js +419 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/server/agent.test.ts +170 -0
- package/src/server/agent.ts +32 -0
- package/src/server/cli-runtime.test.ts +188 -0
- package/src/server/cli-runtime.ts +274 -0
- package/src/server/cli.ts +20 -127
- package/src/server/codex-app-server.test.ts +186 -0
- package/src/server/codex-app-server.ts +17 -2
- package/src/server/discovery.ts +0 -39
- package/src/server/server.ts +3 -1
- package/src/server/ws-router.test.ts +47 -0
- package/src/server/ws-router.ts +4 -0
- package/src/shared/protocol.ts +1 -0
- package/src/shared/tools.test.ts +12 -1
- package/src/shared/tools.ts +19 -1
- package/src/shared/types.ts +5 -1
- package/dist/client/assets/index-Byzgv_-q.js +0 -409
- package/dist/client/assets/index-gld9RxCU.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CcZLosQ7.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BXpHm8Wa.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.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"description": "A beautiful web UI for Claude Code",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"prepublishOnly": "vite build"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.39"
|
|
45
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.39",
|
|
46
|
+
"@radix-ui/react-context-menu": "^2.2.16"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@dnd-kit/core": "^6.3.1",
|
package/src/server/agent.test.ts
CHANGED
|
@@ -481,6 +481,176 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
481
481
|
expect(store.messages.some((entry) => entry.kind === "context_cleared")).toBe(true)
|
|
482
482
|
expect(store.chat.sessionToken).toBe("thread-2")
|
|
483
483
|
})
|
|
484
|
+
|
|
485
|
+
test("cancelling a waiting ask-user-question records a discarded tool result", async () => {
|
|
486
|
+
let releaseInterrupt!: () => void
|
|
487
|
+
const interrupted = new Promise<void>((resolve) => {
|
|
488
|
+
releaseInterrupt = resolve
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
const fakeCodexManager = {
|
|
492
|
+
async startSession() {},
|
|
493
|
+
async startTurn(args: {
|
|
494
|
+
onToolRequest: (request: any) => Promise<unknown>
|
|
495
|
+
}): Promise<HarnessTurn> {
|
|
496
|
+
async function* stream() {
|
|
497
|
+
yield {
|
|
498
|
+
type: "transcript" as const,
|
|
499
|
+
entry: timestamped({
|
|
500
|
+
kind: "system_init",
|
|
501
|
+
provider: "codex",
|
|
502
|
+
model: "gpt-5.4",
|
|
503
|
+
tools: [],
|
|
504
|
+
agents: [],
|
|
505
|
+
slashCommands: [],
|
|
506
|
+
mcpServers: [],
|
|
507
|
+
}),
|
|
508
|
+
}
|
|
509
|
+
void args.onToolRequest({
|
|
510
|
+
tool: {
|
|
511
|
+
kind: "tool",
|
|
512
|
+
toolKind: "ask_user_question",
|
|
513
|
+
toolName: "AskUserQuestion",
|
|
514
|
+
toolId: "question-1",
|
|
515
|
+
input: {
|
|
516
|
+
questions: [{ question: "Provider?" }],
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
})
|
|
520
|
+
await interrupted
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
provider: "codex",
|
|
525
|
+
stream: stream(),
|
|
526
|
+
interrupt: async () => {
|
|
527
|
+
releaseInterrupt()
|
|
528
|
+
},
|
|
529
|
+
close: () => {},
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const store = createFakeStore()
|
|
535
|
+
const coordinator = new AgentCoordinator({
|
|
536
|
+
store: store as never,
|
|
537
|
+
onStateChange: () => {},
|
|
538
|
+
codexManager: fakeCodexManager as never,
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
await coordinator.send({
|
|
542
|
+
type: "chat.send",
|
|
543
|
+
chatId: "chat-1",
|
|
544
|
+
provider: "codex",
|
|
545
|
+
content: "ask me something",
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
await waitFor(() => coordinator.getPendingTool("chat-1")?.toolKind === "ask_user_question")
|
|
549
|
+
await coordinator.cancel("chat-1")
|
|
550
|
+
|
|
551
|
+
const discardedResult = store.messages.find((entry) => entry.kind === "tool_result" && entry.toolId === "question-1")
|
|
552
|
+
expect(discardedResult).toBeDefined()
|
|
553
|
+
if (!discardedResult || discardedResult.kind !== "tool_result") {
|
|
554
|
+
throw new Error("missing discarded ask-user-question result")
|
|
555
|
+
}
|
|
556
|
+
expect(discardedResult.content).toEqual({ discarded: true, answers: {} })
|
|
557
|
+
expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(true)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test("cancelling a waiting codex exit-plan prompt discards it without starting a follow-up turn", async () => {
|
|
561
|
+
let releaseInterrupt!: () => void
|
|
562
|
+
const interrupted = new Promise<void>((resolve) => {
|
|
563
|
+
releaseInterrupt = resolve
|
|
564
|
+
})
|
|
565
|
+
const startTurnCalls: string[] = []
|
|
566
|
+
|
|
567
|
+
const fakeCodexManager = {
|
|
568
|
+
async startSession() {},
|
|
569
|
+
async startTurn(args: {
|
|
570
|
+
content: string
|
|
571
|
+
onToolRequest: (request: any) => Promise<unknown>
|
|
572
|
+
}): Promise<HarnessTurn> {
|
|
573
|
+
startTurnCalls.push(args.content)
|
|
574
|
+
|
|
575
|
+
async function* stream() {
|
|
576
|
+
yield {
|
|
577
|
+
type: "transcript" as const,
|
|
578
|
+
entry: timestamped({
|
|
579
|
+
kind: "system_init",
|
|
580
|
+
provider: "codex",
|
|
581
|
+
model: "gpt-5.4",
|
|
582
|
+
tools: [],
|
|
583
|
+
agents: [],
|
|
584
|
+
slashCommands: [],
|
|
585
|
+
mcpServers: [],
|
|
586
|
+
}),
|
|
587
|
+
}
|
|
588
|
+
yield {
|
|
589
|
+
type: "transcript" as const,
|
|
590
|
+
entry: timestamped({
|
|
591
|
+
kind: "tool_call",
|
|
592
|
+
tool: {
|
|
593
|
+
kind: "tool",
|
|
594
|
+
toolKind: "exit_plan_mode",
|
|
595
|
+
toolName: "ExitPlanMode",
|
|
596
|
+
toolId: "exit-1",
|
|
597
|
+
input: {
|
|
598
|
+
plan: "## Plan",
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
}),
|
|
602
|
+
}
|
|
603
|
+
await args.onToolRequest({
|
|
604
|
+
tool: {
|
|
605
|
+
kind: "tool",
|
|
606
|
+
toolKind: "exit_plan_mode",
|
|
607
|
+
toolName: "ExitPlanMode",
|
|
608
|
+
toolId: "exit-1",
|
|
609
|
+
input: {
|
|
610
|
+
plan: "## Plan",
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
})
|
|
614
|
+
await interrupted
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
provider: "codex",
|
|
619
|
+
stream: stream(),
|
|
620
|
+
interrupt: async () => {
|
|
621
|
+
releaseInterrupt()
|
|
622
|
+
},
|
|
623
|
+
close: () => {},
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const store = createFakeStore()
|
|
629
|
+
const coordinator = new AgentCoordinator({
|
|
630
|
+
store: store as never,
|
|
631
|
+
onStateChange: () => {},
|
|
632
|
+
codexManager: fakeCodexManager as never,
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
await coordinator.send({
|
|
636
|
+
type: "chat.send",
|
|
637
|
+
chatId: "chat-1",
|
|
638
|
+
provider: "codex",
|
|
639
|
+
content: "plan this",
|
|
640
|
+
planMode: true,
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
await waitFor(() => coordinator.getPendingTool("chat-1")?.toolKind === "exit_plan_mode")
|
|
644
|
+
await coordinator.cancel("chat-1")
|
|
645
|
+
|
|
646
|
+
const discardedResult = store.messages.find((entry) => entry.kind === "tool_result" && entry.toolId === "exit-1")
|
|
647
|
+
expect(discardedResult).toBeDefined()
|
|
648
|
+
if (!discardedResult || discardedResult.kind !== "tool_result") {
|
|
649
|
+
throw new Error("missing discarded exit-plan result")
|
|
650
|
+
}
|
|
651
|
+
expect(discardedResult.content).toEqual({ discarded: true })
|
|
652
|
+
expect(startTurnCalls).toEqual(["plan this"])
|
|
653
|
+
})
|
|
484
654
|
})
|
|
485
655
|
|
|
486
656
|
function createFakeStore() {
|
package/src/server/agent.ts
CHANGED
|
@@ -88,6 +88,21 @@ function stringFromUnknown(value: unknown) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function discardedToolResult(
|
|
92
|
+
tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
|
|
93
|
+
) {
|
|
94
|
+
if (tool.toolKind === "ask_user_question") {
|
|
95
|
+
return {
|
|
96
|
+
discarded: true,
|
|
97
|
+
answers: {},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
discarded: true,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
export function normalizeClaudeStreamMessage(message: any): TranscriptEntry[] {
|
|
92
107
|
const debugRaw = JSON.stringify(message)
|
|
93
108
|
const messageId = typeof message.uuid === "string" ? message.uuid : undefined
|
|
@@ -613,8 +628,25 @@ export class AgentCoordinator {
|
|
|
613
628
|
if (!active) return
|
|
614
629
|
|
|
615
630
|
active.cancelRequested = true
|
|
631
|
+
|
|
632
|
+
const pendingTool = active.pendingTool
|
|
616
633
|
active.pendingTool = null
|
|
617
634
|
|
|
635
|
+
if (pendingTool) {
|
|
636
|
+
const result = discardedToolResult(pendingTool.tool)
|
|
637
|
+
await this.store.appendMessage(
|
|
638
|
+
chatId,
|
|
639
|
+
timestamped({
|
|
640
|
+
kind: "tool_result",
|
|
641
|
+
toolId: pendingTool.toolUseId,
|
|
642
|
+
content: result,
|
|
643
|
+
})
|
|
644
|
+
)
|
|
645
|
+
if (active.provider === "codex" && pendingTool.tool.toolKind === "exit_plan_mode") {
|
|
646
|
+
pendingTool.resolve(result)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
618
650
|
await this.store.appendMessage(chatId, timestamped({ kind: "interrupted" }))
|
|
619
651
|
await this.store.recordTurnCancelled(chatId)
|
|
620
652
|
active.cancelRecorded = true
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { compareVersions, parseArgs, runCli } from "./cli-runtime"
|
|
3
|
+
|
|
4
|
+
function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
|
|
5
|
+
const calls = {
|
|
6
|
+
startServer: [] as Array<{ port: number; openBrowser: boolean; strictPort: boolean }>,
|
|
7
|
+
fetchLatestVersion: [] as string[],
|
|
8
|
+
installLatest: [] as string[],
|
|
9
|
+
relaunch: [] as Array<{ command: string; args: string[] }>,
|
|
10
|
+
openUrl: [] as string[],
|
|
11
|
+
log: [] as string[],
|
|
12
|
+
warn: [] as string[],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const deps: Parameters<typeof runCli>[1] = {
|
|
16
|
+
version: "0.3.0",
|
|
17
|
+
startServer: async (options) => {
|
|
18
|
+
calls.startServer.push(options)
|
|
19
|
+
return {
|
|
20
|
+
port: options.port,
|
|
21
|
+
stop: async () => {},
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
fetchLatestVersion: async (packageName) => {
|
|
25
|
+
calls.fetchLatestVersion.push(packageName)
|
|
26
|
+
return "0.3.0"
|
|
27
|
+
},
|
|
28
|
+
installLatest: (packageName) => {
|
|
29
|
+
calls.installLatest.push(packageName)
|
|
30
|
+
return true
|
|
31
|
+
},
|
|
32
|
+
relaunch: (command, args) => {
|
|
33
|
+
calls.relaunch.push({ command, args })
|
|
34
|
+
return 0
|
|
35
|
+
},
|
|
36
|
+
openUrl: (url) => {
|
|
37
|
+
calls.openUrl.push(url)
|
|
38
|
+
},
|
|
39
|
+
log: (message) => {
|
|
40
|
+
calls.log.push(message)
|
|
41
|
+
},
|
|
42
|
+
warn: (message) => {
|
|
43
|
+
calls.warn.push(message)
|
|
44
|
+
},
|
|
45
|
+
...overrides,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { calls, deps }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("parseArgs", () => {
|
|
52
|
+
test("parses runtime options", () => {
|
|
53
|
+
expect(parseArgs(["--port", "4000", "--no-open"])).toEqual({
|
|
54
|
+
kind: "run",
|
|
55
|
+
options: {
|
|
56
|
+
port: 4000,
|
|
57
|
+
openBrowser: false,
|
|
58
|
+
strictPort: false,
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("parses strict port mode", () => {
|
|
64
|
+
expect(parseArgs(["--strict-port"])).toEqual({
|
|
65
|
+
kind: "run",
|
|
66
|
+
options: {
|
|
67
|
+
port: 3210,
|
|
68
|
+
openBrowser: true,
|
|
69
|
+
strictPort: true,
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("returns version and help actions without running startup", () => {
|
|
75
|
+
expect(parseArgs(["--version"])).toEqual({ kind: "version" })
|
|
76
|
+
expect(parseArgs(["--help"])).toEqual({ kind: "help" })
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("compareVersions", () => {
|
|
81
|
+
test("orders semver-like versions", () => {
|
|
82
|
+
expect(compareVersions("0.3.0", "0.3.0")).toBe(0)
|
|
83
|
+
expect(compareVersions("0.3.0", "0.3.1")).toBe(-1)
|
|
84
|
+
expect(compareVersions("1.0.0", "0.9.9")).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("runCli", () => {
|
|
89
|
+
test("skips update checks for --version", async () => {
|
|
90
|
+
const { calls, deps } = createDeps()
|
|
91
|
+
|
|
92
|
+
const result = await runCli(["--version"], deps)
|
|
93
|
+
|
|
94
|
+
expect(result).toEqual({ kind: "exited", code: 0 })
|
|
95
|
+
expect(calls.fetchLatestVersion).toEqual([])
|
|
96
|
+
expect(calls.startServer).toEqual([])
|
|
97
|
+
expect(calls.log).toEqual(["0.3.0"])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("starts normally when no newer version exists", async () => {
|
|
101
|
+
const { calls, deps } = createDeps()
|
|
102
|
+
|
|
103
|
+
const result = await runCli(["--port", "4000", "--no-open"], deps)
|
|
104
|
+
|
|
105
|
+
expect(result.kind).toBe("started")
|
|
106
|
+
expect(calls.fetchLatestVersion).toEqual(["kanna-code"])
|
|
107
|
+
expect(calls.installLatest).toEqual([])
|
|
108
|
+
expect(calls.relaunch).toEqual([])
|
|
109
|
+
expect(calls.startServer).toEqual([{ port: 4000, openBrowser: false, strictPort: false }])
|
|
110
|
+
expect(calls.openUrl).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("opens the root route in the browser", async () => {
|
|
114
|
+
const { calls, deps } = createDeps()
|
|
115
|
+
|
|
116
|
+
await runCli(["--port", "4000"], deps)
|
|
117
|
+
|
|
118
|
+
expect(calls.openUrl).toEqual(["http://localhost:4000"])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("installs and relaunches when a newer version is available", async () => {
|
|
122
|
+
const { calls, deps } = createDeps({
|
|
123
|
+
fetchLatestVersion: async (packageName) => {
|
|
124
|
+
calls.fetchLatestVersion.push(packageName)
|
|
125
|
+
return "0.4.0"
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const result = await runCli(["--port", "4000", "--no-open"], deps)
|
|
130
|
+
|
|
131
|
+
expect(result).toEqual({ kind: "exited", code: 0 })
|
|
132
|
+
expect(calls.installLatest).toEqual(["kanna-code"])
|
|
133
|
+
expect(calls.relaunch).toEqual([{ command: "kanna", args: ["--port", "4000", "--no-open"] }])
|
|
134
|
+
expect(calls.startServer).toEqual([])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("falls back to current version when install fails", async () => {
|
|
138
|
+
const { calls, deps } = createDeps({
|
|
139
|
+
fetchLatestVersion: async (packageName) => {
|
|
140
|
+
calls.fetchLatestVersion.push(packageName)
|
|
141
|
+
return "0.4.0"
|
|
142
|
+
},
|
|
143
|
+
installLatest: (packageName) => {
|
|
144
|
+
calls.installLatest.push(packageName)
|
|
145
|
+
return false
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const result = await runCli(["--no-open"], deps)
|
|
150
|
+
|
|
151
|
+
expect(result.kind).toBe("started")
|
|
152
|
+
expect(calls.installLatest).toEqual(["kanna-code"])
|
|
153
|
+
expect(calls.relaunch).toEqual([])
|
|
154
|
+
expect(calls.warn).toContain("[kanna] update failed, continuing current version")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("falls back to current version when the registry check fails", async () => {
|
|
158
|
+
const { calls, deps } = createDeps({
|
|
159
|
+
fetchLatestVersion: async (packageName) => {
|
|
160
|
+
calls.fetchLatestVersion.push(packageName)
|
|
161
|
+
throw new Error("network unavailable")
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const result = await runCli(["--no-open"], deps)
|
|
166
|
+
|
|
167
|
+
expect(result.kind).toBe("started")
|
|
168
|
+
expect(calls.installLatest).toEqual([])
|
|
169
|
+
expect(calls.relaunch).toEqual([])
|
|
170
|
+
expect(calls.warn).toContain("[kanna] update check failed, continuing current version")
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("preserves original argv when relaunching", async () => {
|
|
174
|
+
const { calls, deps } = createDeps({
|
|
175
|
+
fetchLatestVersion: async (packageName) => {
|
|
176
|
+
calls.fetchLatestVersion.push(packageName)
|
|
177
|
+
return "0.4.0"
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
await runCli(["--port", "4567", "--no-open"], deps)
|
|
182
|
+
|
|
183
|
+
expect(calls.relaunch[0]).toEqual({
|
|
184
|
+
command: "kanna",
|
|
185
|
+
args: ["--port", "4567", "--no-open"],
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
})
|