kanna-code 0.1.4 → 0.3.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.
@@ -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
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
3
+ import { createEmptyState } from "./events"
4
+
5
+ describe("read models", () => {
6
+ test("include provider data in sidebar rows", () => {
7
+ const state = createEmptyState()
8
+ state.projectsById.set("project-1", {
9
+ id: "project-1",
10
+ localPath: "/tmp/project",
11
+ title: "Project",
12
+ createdAt: 1,
13
+ updatedAt: 1,
14
+ })
15
+ state.projectIdsByPath.set("/tmp/project", "project-1")
16
+ state.chatsById.set("chat-1", {
17
+ id: "chat-1",
18
+ projectId: "project-1",
19
+ title: "Chat",
20
+ createdAt: 1,
21
+ updatedAt: 1,
22
+ provider: "codex",
23
+ planMode: false,
24
+ sessionToken: "thread-1",
25
+ lastTurnOutcome: null,
26
+ })
27
+
28
+ const sidebar = deriveSidebarData(state, new Map())
29
+ expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
30
+ })
31
+
32
+ test("includes available providers in chat snapshots", () => {
33
+ const state = createEmptyState()
34
+ state.projectsById.set("project-1", {
35
+ id: "project-1",
36
+ localPath: "/tmp/project",
37
+ title: "Project",
38
+ createdAt: 1,
39
+ updatedAt: 1,
40
+ })
41
+ state.projectIdsByPath.set("/tmp/project", "project-1")
42
+ state.chatsById.set("chat-1", {
43
+ id: "chat-1",
44
+ projectId: "project-1",
45
+ title: "Chat",
46
+ createdAt: 1,
47
+ updatedAt: 1,
48
+ provider: "claude",
49
+ planMode: true,
50
+ sessionToken: "session-1",
51
+ lastTurnOutcome: null,
52
+ })
53
+
54
+ const chat = deriveChatSnapshot(state, new Map(), "chat-1")
55
+ expect(chat?.runtime.provider).toBe("claude")
56
+ expect(chat?.availableProviders.length).toBeGreaterThan(1)
57
+ expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
58
+ "gpt-5.4",
59
+ "gpt-5.3-codex",
60
+ "gpt-5.3-codex-spark",
61
+ ])
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
+ })
105
+ })
@@ -10,6 +10,7 @@ import type {
10
10
  import type { ChatRecord, StoreState } from "./events"
11
11
  import { cloneTranscriptEntries } from "./events"
12
12
  import { resolveLocalPath } from "./paths"
13
+ import { SERVER_PROVIDERS } from "./provider-catalog"
13
14
 
14
15
  export function deriveStatus(chat: ChatRecord, activeStatus?: KannaStatus): KannaStatus {
15
16
  if (activeStatus) return activeStatus
@@ -36,6 +37,7 @@ export function deriveSidebarData(
36
37
  title: chat.title,
37
38
  status: deriveStatus(chat, activeStatuses.get(chat.id)),
38
39
  localPath: project.localPath,
40
+ provider: chat.provider,
39
41
  lastMessageAt: chat.lastMessageAt,
40
42
  hasAutomation: false,
41
43
  }))
@@ -109,12 +111,14 @@ export function deriveChatSnapshot(
109
111
  localPath: project.localPath,
110
112
  title: chat.title,
111
113
  status: deriveStatus(chat, activeStatuses.get(chat.id)),
114
+ provider: chat.provider,
112
115
  planMode: chat.planMode,
113
- resumeSessionId: chat.resumeSessionId,
116
+ sessionToken: chat.sessionToken,
114
117
  }
115
118
 
116
119
  return {
117
120
  runtime,
118
121
  messages: cloneTranscriptEntries(state.messagesByChatId.get(chat.id) ?? []),
122
+ availableProviders: [...SERVER_PROVIDERS],
119
123
  }
120
124
  }
@@ -2,7 +2,7 @@ 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
 
@@ -18,7 +18,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
18
18
  let discoveredProjects: DiscoveredProject[] = []
19
19
 
20
20
  async function refreshDiscovery() {
21
- discoveredProjects = discoverClaudeProjects()
21
+ discoveredProjects = discoverProjects()
22
22
  return discoveredProjects
23
23
  }
24
24
 
@@ -1,4 +1,4 @@
1
- import type { ChatSnapshot, LocalProjectsSnapshot, SidebarData } from "./types"
1
+ import type { AgentProvider, ChatSnapshot, LocalProjectsSnapshot, ModelOptions, SidebarData } from "./types"
2
2
 
3
3
  export type SubscriptionTopic =
4
4
  | { type: "sidebar" }
@@ -13,7 +13,17 @@ export type ClientCommand =
13
13
  | { type: "chat.create"; projectId: string }
14
14
  | { type: "chat.rename"; chatId: string; title: string }
15
15
  | { type: "chat.delete"; chatId: string }
16
- | { type: "chat.send"; chatId?: string; projectId?: string; content: string; model?: string; effort?: string; planMode?: boolean }
16
+ | {
17
+ type: "chat.send"
18
+ chatId?: string
19
+ projectId?: string
20
+ provider?: AgentProvider
21
+ content: string
22
+ model?: string
23
+ modelOptions?: ModelOptions
24
+ effort?: string
25
+ planMode?: boolean
26
+ }
17
27
  | { type: "chat.cancel"; chatId: string }
18
28
  | { type: "chat.respondTool"; chatId: string; toolUseId: string; result: unknown }
19
29
 
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { hydrateToolResult, normalizeToolCall } from "./tools"
3
+
4
+ describe("normalizeToolCall", () => {
5
+ test("maps AskUserQuestion input to typed questions", () => {
6
+ const tool = normalizeToolCall({
7
+ toolName: "AskUserQuestion",
8
+ toolId: "tool-1",
9
+ input: {
10
+ questions: [
11
+ {
12
+ question: "Which runtime?",
13
+ header: "Runtime",
14
+ options: [{ label: "Codex", description: "Use Codex" }],
15
+ },
16
+ ],
17
+ },
18
+ })
19
+
20
+ expect(tool.toolKind).toBe("ask_user_question")
21
+ if (tool.toolKind !== "ask_user_question") throw new Error("unexpected tool kind")
22
+ expect(tool.input.questions[0]?.question).toBe("Which runtime?")
23
+ })
24
+
25
+ test("maps Bash snake_case input to camelCase", () => {
26
+ const tool = normalizeToolCall({
27
+ toolName: "Bash",
28
+ toolId: "tool-2",
29
+ input: {
30
+ command: "pwd",
31
+ timeout: 5000,
32
+ run_in_background: true,
33
+ },
34
+ })
35
+
36
+ expect(tool.toolKind).toBe("bash")
37
+ if (tool.toolKind !== "bash") throw new Error("unexpected tool kind")
38
+ expect(tool.input.timeoutMs).toBe(5000)
39
+ expect(tool.input.runInBackground).toBe(true)
40
+ })
41
+
42
+ test("maps unknown MCP tools to mcp_generic", () => {
43
+ const tool = normalizeToolCall({
44
+ toolName: "mcp__sentry__search_issues",
45
+ toolId: "tool-3",
46
+ input: { query: "regression" },
47
+ })
48
+
49
+ expect(tool.toolKind).toBe("mcp_generic")
50
+ if (tool.toolKind !== "mcp_generic") throw new Error("unexpected tool kind")
51
+ expect(tool.input.server).toBe("sentry")
52
+ expect(tool.input.tool).toBe("search_issues")
53
+ })
54
+ })
55
+
56
+ describe("hydrateToolResult", () => {
57
+ test("hydrates AskUserQuestion answers", () => {
58
+ const tool = normalizeToolCall({
59
+ toolName: "AskUserQuestion",
60
+ toolId: "tool-1",
61
+ input: { questions: [] },
62
+ })
63
+
64
+ const result = hydrateToolResult(tool, JSON.stringify({ answers: { runtime: "codex" } }))
65
+ expect(result).toEqual({ answers: { runtime: "codex" } })
66
+ })
67
+
68
+ test("hydrates ExitPlanMode decisions", () => {
69
+ const tool = normalizeToolCall({
70
+ toolName: "ExitPlanMode",
71
+ toolId: "tool-2",
72
+ input: { plan: "Do the thing" },
73
+ })
74
+
75
+ const result = hydrateToolResult(tool, { confirmed: true, clearContext: true })
76
+ expect(result).toEqual({ confirmed: true, clearContext: true, message: undefined })
77
+ })
78
+
79
+ test("hydrates Read file text results", () => {
80
+ const tool = normalizeToolCall({
81
+ toolName: "Read",
82
+ toolId: "tool-3",
83
+ input: { file_path: "/tmp/example.ts" },
84
+ })
85
+
86
+ expect(hydrateToolResult(tool, "line 1\nline 2")).toBe("line 1\nline 2")
87
+ })
88
+ })
@@ -0,0 +1,233 @@
1
+ import type {
2
+ AskUserQuestionItem,
3
+ AskUserQuestionToolResult,
4
+ ExitPlanModeToolResult,
5
+ HydratedToolCall,
6
+ NormalizedToolCall,
7
+ ReadFileToolResult,
8
+ TodoItem,
9
+ } from "./types"
10
+
11
+ function asRecord(value: unknown): Record<string, unknown> | null {
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null
13
+ return value as Record<string, unknown>
14
+ }
15
+
16
+ export function normalizeToolCall(args: {
17
+ toolName: string
18
+ toolId: string
19
+ input: Record<string, unknown>
20
+ }): NormalizedToolCall {
21
+ const { toolName, toolId, input } = args
22
+
23
+ switch (toolName) {
24
+ case "AskUserQuestion":
25
+ return {
26
+ kind: "tool",
27
+ toolKind: "ask_user_question",
28
+ toolName,
29
+ toolId,
30
+ input: {
31
+ questions: Array.isArray(input.questions) ? (input.questions as AskUserQuestionItem[]) : [],
32
+ },
33
+ rawInput: input,
34
+ }
35
+ case "ExitPlanMode":
36
+ return {
37
+ kind: "tool",
38
+ toolKind: "exit_plan_mode",
39
+ toolName,
40
+ toolId,
41
+ input: {
42
+ plan: typeof input.plan === "string" ? input.plan : undefined,
43
+ summary: typeof input.summary === "string" ? input.summary : undefined,
44
+ },
45
+ rawInput: input,
46
+ }
47
+ case "TodoWrite":
48
+ return {
49
+ kind: "tool",
50
+ toolKind: "todo_write",
51
+ toolName,
52
+ toolId,
53
+ input: {
54
+ todos: Array.isArray(input.todos) ? (input.todos as TodoItem[]) : [],
55
+ },
56
+ rawInput: input,
57
+ }
58
+ case "Skill":
59
+ return {
60
+ kind: "tool",
61
+ toolKind: "skill",
62
+ toolName,
63
+ toolId,
64
+ input: {
65
+ skill: typeof input.skill === "string" ? input.skill : "",
66
+ },
67
+ rawInput: input,
68
+ }
69
+ case "Glob":
70
+ return {
71
+ kind: "tool",
72
+ toolKind: "glob",
73
+ toolName,
74
+ toolId,
75
+ input: {
76
+ pattern: typeof input.pattern === "string" ? input.pattern : "",
77
+ },
78
+ rawInput: input,
79
+ }
80
+ case "Grep":
81
+ return {
82
+ kind: "tool",
83
+ toolKind: "grep",
84
+ toolName,
85
+ toolId,
86
+ input: {
87
+ pattern: typeof input.pattern === "string" ? input.pattern : "",
88
+ outputMode: typeof input.output_mode === "string" ? input.output_mode : undefined,
89
+ },
90
+ rawInput: input,
91
+ }
92
+ case "Bash":
93
+ return {
94
+ kind: "tool",
95
+ toolKind: "bash",
96
+ toolName,
97
+ toolId,
98
+ input: {
99
+ command: typeof input.command === "string" ? input.command : "",
100
+ description: typeof input.description === "string" ? input.description : undefined,
101
+ timeoutMs: typeof input.timeout === "number" ? input.timeout : undefined,
102
+ runInBackground: Boolean(input.run_in_background),
103
+ },
104
+ rawInput: input,
105
+ }
106
+ case "WebSearch":
107
+ return {
108
+ kind: "tool",
109
+ toolKind: "web_search",
110
+ toolName,
111
+ toolId,
112
+ input: {
113
+ query: typeof input.query === "string" ? input.query : "",
114
+ },
115
+ rawInput: input,
116
+ }
117
+ case "Read":
118
+ return {
119
+ kind: "tool",
120
+ toolKind: "read_file",
121
+ toolName,
122
+ toolId,
123
+ input: {
124
+ filePath: typeof input.file_path === "string" ? input.file_path : "",
125
+ },
126
+ rawInput: input,
127
+ }
128
+ case "Write":
129
+ return {
130
+ kind: "tool",
131
+ toolKind: "write_file",
132
+ toolName,
133
+ toolId,
134
+ input: {
135
+ filePath: typeof input.file_path === "string" ? input.file_path : "",
136
+ content: typeof input.content === "string" ? input.content : "",
137
+ },
138
+ rawInput: input,
139
+ }
140
+ case "Edit":
141
+ return {
142
+ kind: "tool",
143
+ toolKind: "edit_file",
144
+ toolName,
145
+ toolId,
146
+ input: {
147
+ filePath: typeof input.file_path === "string" ? input.file_path : "",
148
+ oldString: typeof input.old_string === "string" ? input.old_string : "",
149
+ newString: typeof input.new_string === "string" ? input.new_string : "",
150
+ },
151
+ rawInput: input,
152
+ }
153
+ }
154
+
155
+ const mcpMatch = toolName.match(/^mcp__(.+?)__(.+)$/)
156
+ if (mcpMatch) {
157
+ return {
158
+ kind: "tool",
159
+ toolKind: "mcp_generic",
160
+ toolName,
161
+ toolId,
162
+ input: {
163
+ server: mcpMatch[1],
164
+ tool: mcpMatch[2],
165
+ payload: input,
166
+ },
167
+ rawInput: input,
168
+ }
169
+ }
170
+
171
+ if (typeof input.subagent_type === "string") {
172
+ return {
173
+ kind: "tool",
174
+ toolKind: "subagent_task",
175
+ toolName,
176
+ toolId,
177
+ input: {
178
+ subagentType: input.subagent_type,
179
+ },
180
+ rawInput: input,
181
+ }
182
+ }
183
+
184
+ return {
185
+ kind: "tool",
186
+ toolKind: "unknown_tool",
187
+ toolName,
188
+ toolId,
189
+ input: {
190
+ payload: input,
191
+ },
192
+ rawInput: input,
193
+ }
194
+ }
195
+
196
+ function parseJsonValue(value: unknown): unknown {
197
+ if (typeof value !== "string") return value
198
+ try {
199
+ return JSON.parse(value)
200
+ } catch {
201
+ return value
202
+ }
203
+ }
204
+
205
+ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): HydratedToolCall["result"] {
206
+ const parsed = parseJsonValue(raw)
207
+
208
+ switch (tool.toolKind) {
209
+ case "ask_user_question": {
210
+ const record = asRecord(parsed)
211
+ const answers = asRecord(record?.answers) ?? (record ? record : {})
212
+ return { answers: Object.fromEntries(Object.entries(answers).map(([key, value]) => [key, String(value)])) } satisfies AskUserQuestionToolResult
213
+ }
214
+ case "exit_plan_mode": {
215
+ const record = asRecord(parsed)
216
+ return {
217
+ confirmed: typeof record?.confirmed === "boolean" ? record.confirmed : undefined,
218
+ clearContext: typeof record?.clearContext === "boolean" ? record.clearContext : undefined,
219
+ message: typeof record?.message === "string" ? record.message : undefined,
220
+ } satisfies ExitPlanModeToolResult
221
+ }
222
+ case "read_file":
223
+ if (typeof parsed === "string") {
224
+ return parsed
225
+ }
226
+ const record = asRecord(parsed)
227
+ return {
228
+ content: typeof record?.content === "string" ? record.content : JSON.stringify(parsed, null, 2),
229
+ } satisfies ReadFileToolResult
230
+ default:
231
+ return parsed
232
+ }
233
+ }