kanna-code 0.1.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,42 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk"
2
+
3
+ export async function generateTitleForChat(messageContent: string): Promise<string | null> {
4
+ try {
5
+ const q = query({
6
+ prompt: `Generate a short, descriptive title (under 60 chars) for a conversation that starts with this message. Return JSON matching the schema.\n\n${messageContent}`,
7
+ options: {
8
+ model: "haiku",
9
+ tools: [],
10
+ systemPrompt: "",
11
+ permissionMode: "bypassPermissions",
12
+ outputFormat: {
13
+ type: "json_schema",
14
+ schema: {
15
+ type: "object",
16
+ properties: {
17
+ title: { type: "string" },
18
+ },
19
+ required: ["title"],
20
+ additionalProperties: false,
21
+ },
22
+ },
23
+ env: { ...process.env },
24
+ },
25
+ })
26
+
27
+ try {
28
+ for await (const message of q) {
29
+ if ("result" in message) {
30
+ const output = (message as Record<string, unknown>).structured_output as { title?: string } | undefined
31
+ return typeof output?.title === "string" ? output.title.slice(0, 80) : null
32
+ }
33
+ }
34
+ } finally {
35
+ q.close()
36
+ }
37
+
38
+ return null
39
+ } catch {
40
+ return null
41
+ }
42
+ }
@@ -0,0 +1,22 @@
1
+ import { hostname } from "node:os"
2
+ import process from "node:process"
3
+ import { spawnSync } from "node:child_process"
4
+
5
+ function runAndRead(command: string, args: string[]) {
6
+ const result = spawnSync(command, args, { encoding: "utf8" })
7
+ if (result.status !== 0) return null
8
+ const value = result.stdout.trim()
9
+ return value || null
10
+ }
11
+
12
+ export function getMachineDisplayName() {
13
+ if (process.platform === "darwin") {
14
+ const computerName = runAndRead("scutil", ["--get", "ComputerName"])
15
+ if (computerName) {
16
+ return computerName
17
+ }
18
+ }
19
+
20
+ const rawHostname = hostname().trim()
21
+ return rawHostname.replace(/\.local$|\.lan$/i, "") || "This Machine"
22
+ }
@@ -0,0 +1,27 @@
1
+ import { mkdir, stat } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import path from "node:path"
4
+
5
+ export function resolveLocalPath(localPath: string) {
6
+ const trimmed = localPath.trim()
7
+ if (!trimmed) {
8
+ throw new Error("Project path is required")
9
+ }
10
+ if (trimmed === "~") {
11
+ return homedir()
12
+ }
13
+ if (trimmed.startsWith("~/")) {
14
+ return path.join(homedir(), trimmed.slice(2))
15
+ }
16
+ return path.resolve(trimmed)
17
+ }
18
+
19
+ export async function ensureProjectDirectory(localPath: string) {
20
+ const resolvedPath = resolveLocalPath(localPath)
21
+
22
+ await mkdir(resolvedPath, { recursive: true })
23
+ const info = await stat(resolvedPath)
24
+ if (!info.isDirectory()) {
25
+ throw new Error("Project path must be a directory")
26
+ }
27
+ }
@@ -0,0 +1,120 @@
1
+ import type {
2
+ ChatRuntime,
3
+ ChatSnapshot,
4
+ KannaStatus,
5
+ LocalProjectsSnapshot,
6
+ SidebarChatRow,
7
+ SidebarData,
8
+ SidebarProjectGroup,
9
+ } from "../shared/types"
10
+ import type { ChatRecord, StoreState } from "./events"
11
+ import { cloneTranscriptEntries } from "./events"
12
+ import { resolveLocalPath } from "./paths"
13
+
14
+ export function deriveStatus(chat: ChatRecord, activeStatus?: KannaStatus): KannaStatus {
15
+ if (activeStatus) return activeStatus
16
+ if (chat.lastTurnOutcome === "failed") return "failed"
17
+ return "idle"
18
+ }
19
+
20
+ export function deriveSidebarData(
21
+ state: StoreState,
22
+ activeStatuses: Map<string, KannaStatus>
23
+ ): SidebarData {
24
+ const projects = [...state.projectsById.values()]
25
+ .filter((project) => !project.deletedAt)
26
+ .sort((a, b) => b.updatedAt - a.updatedAt)
27
+
28
+ const projectGroups: SidebarProjectGroup[] = projects.map((project) => {
29
+ const chats: SidebarChatRow[] = [...state.chatsById.values()]
30
+ .filter((chat) => chat.projectId === project.id && !chat.deletedAt)
31
+ .sort((a, b) => (b.lastMessageAt ?? b.updatedAt) - (a.lastMessageAt ?? a.updatedAt))
32
+ .map((chat) => ({
33
+ _id: chat.id,
34
+ _creationTime: chat.createdAt,
35
+ chatId: chat.id,
36
+ title: chat.title,
37
+ status: deriveStatus(chat, activeStatuses.get(chat.id)),
38
+ localPath: project.localPath,
39
+ lastMessageAt: chat.lastMessageAt,
40
+ hasAutomation: false,
41
+ }))
42
+
43
+ return {
44
+ groupKey: project.id,
45
+ localPath: project.localPath,
46
+ chats,
47
+ }
48
+ }).filter((group) => group.chats.length > 0)
49
+
50
+ return { projectGroups }
51
+ }
52
+
53
+ export function deriveLocalProjectsSnapshot(
54
+ state: StoreState,
55
+ discoveredProjects: Array<{ localPath: string; title: string; modifiedAt: number }>,
56
+ machineName: string
57
+ ): LocalProjectsSnapshot {
58
+ const projects = new Map<string, LocalProjectsSnapshot["projects"][number]>()
59
+
60
+ for (const project of discoveredProjects) {
61
+ const normalizedPath = resolveLocalPath(project.localPath)
62
+ projects.set(normalizedPath, {
63
+ localPath: normalizedPath,
64
+ title: project.title,
65
+ source: "discovered",
66
+ lastOpenedAt: project.modifiedAt,
67
+ chatCount: 0,
68
+ })
69
+ }
70
+
71
+ for (const project of [...state.projectsById.values()].filter((entry) => !entry.deletedAt)) {
72
+ const chats = [...state.chatsById.values()].filter((chat) => chat.projectId === project.id && !chat.deletedAt)
73
+ const lastOpenedAt = chats.reduce(
74
+ (latest, chat) => Math.max(latest, chat.lastMessageAt ?? chat.updatedAt ?? 0),
75
+ project.updatedAt
76
+ )
77
+
78
+ projects.set(project.localPath, {
79
+ localPath: project.localPath,
80
+ title: project.title,
81
+ source: "saved",
82
+ lastOpenedAt,
83
+ chatCount: chats.length,
84
+ })
85
+ }
86
+
87
+ return {
88
+ machine: {
89
+ id: "local",
90
+ displayName: machineName,
91
+ },
92
+ projects: [...projects.values()].sort((a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0)),
93
+ }
94
+ }
95
+
96
+ export function deriveChatSnapshot(
97
+ state: StoreState,
98
+ activeStatuses: Map<string, KannaStatus>,
99
+ chatId: string
100
+ ): ChatSnapshot | null {
101
+ const chat = state.chatsById.get(chatId)
102
+ if (!chat || chat.deletedAt) return null
103
+ const project = state.projectsById.get(chat.projectId)
104
+ if (!project || project.deletedAt) return null
105
+
106
+ const runtime: ChatRuntime = {
107
+ chatId: chat.id,
108
+ projectId: project.id,
109
+ localPath: project.localPath,
110
+ title: chat.title,
111
+ status: deriveStatus(chat, activeStatuses.get(chat.id)),
112
+ planMode: chat.planMode,
113
+ resumeSessionId: chat.resumeSessionId,
114
+ }
115
+
116
+ return {
117
+ runtime,
118
+ messages: cloneTranscriptEntries(state.messagesByChatId.get(chat.id) ?? []),
119
+ }
120
+ }
@@ -0,0 +1,132 @@
1
+ import path from "node:path"
2
+ import { APP_NAME } from "../shared/branding"
3
+ import { EventStore } from "./event-store"
4
+ import { AgentCoordinator } from "./agent"
5
+ import { discoverClaudeProjects, type DiscoveredProject } from "./discovery"
6
+ import { getMachineDisplayName } from "./machine-name"
7
+ import { createWsRouter, type ClientState } from "./ws-router"
8
+
9
+ export interface StartKannaServerOptions {
10
+ port?: number
11
+ }
12
+
13
+ export async function startKannaServer(options: StartKannaServerOptions = {}) {
14
+ const port = options.port ?? 3210
15
+ const store = new EventStore()
16
+ const machineDisplayName = getMachineDisplayName()
17
+ await store.initialize()
18
+ let discoveredProjects: DiscoveredProject[] = []
19
+
20
+ async function refreshDiscovery() {
21
+ discoveredProjects = discoverClaudeProjects()
22
+ return discoveredProjects
23
+ }
24
+
25
+ await refreshDiscovery()
26
+
27
+ let server: ReturnType<typeof Bun.serve<ClientState>>
28
+ let router: ReturnType<typeof createWsRouter>
29
+ const agent = new AgentCoordinator({
30
+ store,
31
+ onStateChange: () => {
32
+ router.broadcastSnapshots()
33
+ },
34
+ })
35
+ router = createWsRouter({
36
+ store,
37
+ agent,
38
+ refreshDiscovery,
39
+ getDiscoveredProjects: () => discoveredProjects,
40
+ machineDisplayName,
41
+ })
42
+
43
+ const distDir = path.join(import.meta.dir, "..", "..", "dist", "client")
44
+
45
+ const MAX_PORT_ATTEMPTS = 20
46
+ let actualPort = port
47
+
48
+ for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
49
+ try {
50
+ server = Bun.serve<ClientState>({
51
+ port: actualPort,
52
+ fetch(req, serverInstance) {
53
+ const url = new URL(req.url)
54
+
55
+ if (url.pathname === "/ws") {
56
+ const upgraded = serverInstance.upgrade(req, {
57
+ data: {
58
+ subscriptions: new Map(),
59
+ },
60
+ })
61
+ return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 })
62
+ }
63
+
64
+ if (url.pathname === "/health") {
65
+ return Response.json({ ok: true, port: actualPort })
66
+ }
67
+
68
+ return serveStatic(distDir, url.pathname)
69
+ },
70
+ websocket: {
71
+ open(ws) {
72
+ router.handleOpen(ws)
73
+ },
74
+ message(ws, raw) {
75
+ router.handleMessage(ws, raw)
76
+ },
77
+ close(ws) {
78
+ router.handleClose(ws)
79
+ },
80
+ },
81
+ })
82
+ break
83
+ } catch (err: unknown) {
84
+ const isAddrInUse =
85
+ err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "EADDRINUSE"
86
+ if (!isAddrInUse || attempt === MAX_PORT_ATTEMPTS - 1) {
87
+ throw err
88
+ }
89
+ console.log(`Port ${actualPort} is in use, trying ${actualPort + 1}...`)
90
+ actualPort++
91
+ }
92
+ }
93
+
94
+ const shutdown = async () => {
95
+ for (const chatId of [...agent.activeTurns.keys()]) {
96
+ await agent.cancel(chatId)
97
+ }
98
+ await store.compact()
99
+ server.stop(true)
100
+ }
101
+
102
+ return {
103
+ port: actualPort,
104
+ store,
105
+ stop: shutdown,
106
+ }
107
+ }
108
+
109
+ async function serveStatic(distDir: string, pathname: string) {
110
+ const requestedPath = pathname === "/" ? "/index.html" : pathname
111
+ const filePath = path.join(distDir, requestedPath)
112
+ const indexPath = path.join(distDir, "index.html")
113
+
114
+ const file = Bun.file(filePath)
115
+ if (await file.exists()) {
116
+ return new Response(file)
117
+ }
118
+
119
+ const indexFile = Bun.file(indexPath)
120
+ if (await indexFile.exists()) {
121
+ return new Response(indexFile, {
122
+ headers: {
123
+ "Content-Type": "text/html; charset=utf-8",
124
+ },
125
+ })
126
+ }
127
+
128
+ return new Response(
129
+ `${APP_NAME} client bundle not found. Run \`bun run build\` inside workbench/ first.`,
130
+ { status: 503 }
131
+ )
132
+ }
@@ -0,0 +1,208 @@
1
+ import type { ServerWebSocket } from "bun"
2
+ import { PROTOCOL_VERSION } from "../shared/types"
3
+ import type { ClientEnvelope, ServerEnvelope, SubscriptionTopic } from "../shared/protocol"
4
+ import { isClientEnvelope } from "../shared/protocol"
5
+ import type { AgentCoordinator } from "./agent"
6
+ import type { DiscoveredProject } from "./discovery"
7
+ import { EventStore } from "./event-store"
8
+ import { openExternal } from "./external-open"
9
+ import { ensureProjectDirectory } from "./paths"
10
+ import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
11
+
12
+ export interface ClientState {
13
+ subscriptions: Map<string, SubscriptionTopic>
14
+ }
15
+
16
+ interface CreateWsRouterArgs {
17
+ store: EventStore
18
+ agent: AgentCoordinator
19
+ refreshDiscovery: () => Promise<DiscoveredProject[]>
20
+ getDiscoveredProjects: () => DiscoveredProject[]
21
+ machineDisplayName: string
22
+ }
23
+
24
+ function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
25
+ ws.send(JSON.stringify(message))
26
+ }
27
+
28
+ export function createWsRouter({
29
+ store,
30
+ agent,
31
+ refreshDiscovery,
32
+ getDiscoveredProjects,
33
+ machineDisplayName,
34
+ }: CreateWsRouterArgs) {
35
+ const sockets = new Set<ServerWebSocket<ClientState>>()
36
+
37
+ function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
38
+ if (topic.type === "sidebar") {
39
+ return {
40
+ v: PROTOCOL_VERSION,
41
+ type: "snapshot",
42
+ id,
43
+ snapshot: {
44
+ type: "sidebar",
45
+ data: deriveSidebarData(store.state, agent.getActiveStatuses()),
46
+ },
47
+ }
48
+ }
49
+
50
+ if (topic.type === "local-projects") {
51
+ const discoveredProjects = getDiscoveredProjects()
52
+ const data = deriveLocalProjectsSnapshot(store.state, discoveredProjects, machineDisplayName)
53
+
54
+ return {
55
+ v: PROTOCOL_VERSION,
56
+ type: "snapshot",
57
+ id,
58
+ snapshot: {
59
+ type: "local-projects",
60
+ data,
61
+ },
62
+ }
63
+ }
64
+
65
+ return {
66
+ v: PROTOCOL_VERSION,
67
+ type: "snapshot",
68
+ id,
69
+ snapshot: {
70
+ type: "chat",
71
+ data: deriveChatSnapshot(store.state, agent.getActiveStatuses(), topic.chatId),
72
+ },
73
+ }
74
+ }
75
+
76
+ function pushSnapshots(ws: ServerWebSocket<ClientState>) {
77
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
78
+ send(ws, createEnvelope(id, topic))
79
+ }
80
+ }
81
+
82
+ function broadcastSnapshots() {
83
+ for (const ws of sockets) {
84
+ pushSnapshots(ws)
85
+ }
86
+ }
87
+
88
+ async function handleCommand(ws: ServerWebSocket<ClientState>, message: Extract<ClientEnvelope, { type: "command" }>) {
89
+ const { command, id } = message
90
+ try {
91
+ switch (command.type) {
92
+ case "project.open": {
93
+ await ensureProjectDirectory(command.localPath)
94
+ const project = await store.openProject(command.localPath)
95
+ await refreshDiscovery()
96
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id } })
97
+ break
98
+ }
99
+ case "project.create": {
100
+ await ensureProjectDirectory(command.localPath)
101
+ const project = await store.openProject(command.localPath, command.title)
102
+ await refreshDiscovery()
103
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id } })
104
+ break
105
+ }
106
+ case "project.remove": {
107
+ for (const chat of store.listChatsByProject(command.projectId)) {
108
+ await agent.cancel(chat.id)
109
+ }
110
+ await store.removeProject(command.projectId)
111
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
112
+ break
113
+ }
114
+ case "system.openExternal": {
115
+ openExternal(command.localPath, command.action)
116
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
117
+ break
118
+ }
119
+ case "chat.create": {
120
+ const chat = await store.createChat(command.projectId)
121
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
122
+ break
123
+ }
124
+ case "chat.rename": {
125
+ await store.renameChat(command.chatId, command.title)
126
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
127
+ break
128
+ }
129
+ case "chat.delete": {
130
+ await agent.cancel(command.chatId)
131
+ await store.deleteChat(command.chatId)
132
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
133
+ break
134
+ }
135
+ case "chat.send": {
136
+ const result = await agent.send(command)
137
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
138
+ break
139
+ }
140
+ case "chat.cancel": {
141
+ await agent.cancel(command.chatId)
142
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
143
+ break
144
+ }
145
+ case "chat.setPlanMode": {
146
+ await store.setPlanMode(command.chatId, command.planMode)
147
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
148
+ break
149
+ }
150
+ case "chat.respondTool": {
151
+ await agent.respondTool(command)
152
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
153
+ break
154
+ }
155
+ }
156
+
157
+ broadcastSnapshots()
158
+ } catch (error) {
159
+ const messageText = error instanceof Error ? error.message : String(error)
160
+ send(ws, { v: PROTOCOL_VERSION, type: "error", id, message: messageText })
161
+ }
162
+ }
163
+
164
+ return {
165
+ handleOpen(ws: ServerWebSocket<ClientState>) {
166
+ sockets.add(ws)
167
+ },
168
+ handleClose(ws: ServerWebSocket<ClientState>) {
169
+ sockets.delete(ws)
170
+ },
171
+ broadcastSnapshots,
172
+ handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
173
+ let parsed: unknown
174
+ try {
175
+ parsed = JSON.parse(String(raw))
176
+ } catch {
177
+ send(ws, { v: PROTOCOL_VERSION, type: "error", message: "Invalid JSON" })
178
+ return
179
+ }
180
+
181
+ if (!isClientEnvelope(parsed)) {
182
+ send(ws, { v: PROTOCOL_VERSION, type: "error", message: "Invalid envelope" })
183
+ return
184
+ }
185
+
186
+ if (parsed.type === "subscribe") {
187
+ ws.data.subscriptions.set(parsed.id, parsed.topic)
188
+ if (parsed.topic.type === "local-projects") {
189
+ void refreshDiscovery().then(() => {
190
+ if (ws.data.subscriptions.has(parsed.id)) {
191
+ send(ws, createEnvelope(parsed.id, parsed.topic))
192
+ }
193
+ })
194
+ }
195
+ send(ws, createEnvelope(parsed.id, parsed.topic))
196
+ return
197
+ }
198
+
199
+ if (parsed.type === "unsubscribe") {
200
+ ws.data.subscriptions.delete(parsed.id)
201
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id: parsed.id })
202
+ return
203
+ }
204
+
205
+ void handleCommand(ws, parsed)
206
+ },
207
+ }
208
+ }
@@ -0,0 +1,23 @@
1
+ export const APP_NAME = "Kanna"
2
+ export const CLI_COMMAND = "kanna"
3
+ export const DATA_ROOT_NAME = ".kanna"
4
+ export const PACKAGE_NAME = "kanna-code"
5
+ export const SDK_CLIENT_APP = "kanna/0.1.0"
6
+ export const LOG_PREFIX = "[kanna]"
7
+ export const DEFAULT_NEW_PROJECT_ROOT = `~/${APP_NAME}`
8
+
9
+ export function getDataRootName() {
10
+ return DATA_ROOT_NAME
11
+ }
12
+
13
+ export function getDataDir(homeDir: string) {
14
+ return `${homeDir}/${DATA_ROOT_NAME}/data`
15
+ }
16
+
17
+ export function getDataDirDisplay() {
18
+ return `~/${DATA_ROOT_NAME.slice(1)}/data`
19
+ }
20
+
21
+ export function getCliInvocation(arg?: string) {
22
+ return arg ? `${CLI_COMMAND} ${arg}` : CLI_COMMAND
23
+ }
@@ -0,0 +1,3 @@
1
+ export const PROD_SERVER_PORT = 3210
2
+ export const DEV_SERVER_PORT = 3211
3
+ export const DEV_CLIENT_PORT = 5174
@@ -0,0 +1,40 @@
1
+ import type { ChatSnapshot, LocalProjectsSnapshot, SidebarData } from "./types"
2
+
3
+ export type SubscriptionTopic =
4
+ | { type: "sidebar" }
5
+ | { type: "local-projects" }
6
+ | { type: "chat"; chatId: string }
7
+
8
+ export type ClientCommand =
9
+ | { type: "project.open"; localPath: string }
10
+ | { type: "project.create"; localPath: string; title: string }
11
+ | { type: "project.remove"; projectId: string }
12
+ | { type: "system.openExternal"; localPath: string; action: "open_finder" | "open_terminal" | "open_editor" }
13
+ | { type: "chat.create"; projectId: string }
14
+ | { type: "chat.rename"; chatId: string; title: string }
15
+ | { type: "chat.delete"; chatId: string }
16
+ | { type: "chat.send"; chatId?: string; projectId?: string; content: string }
17
+ | { type: "chat.cancel"; chatId: string }
18
+ | { type: "chat.setPlanMode"; chatId: string; planMode: boolean }
19
+ | { type: "chat.respondTool"; chatId: string; toolUseId: string; result: unknown }
20
+
21
+ export type ClientEnvelope =
22
+ | { v: 1; type: "subscribe"; id: string; topic: SubscriptionTopic }
23
+ | { v: 1; type: "unsubscribe"; id: string }
24
+ | { v: 1; type: "command"; id: string; command: ClientCommand }
25
+
26
+ export type ServerSnapshot =
27
+ | { type: "sidebar"; data: SidebarData }
28
+ | { type: "local-projects"; data: LocalProjectsSnapshot }
29
+ | { type: "chat"; data: ChatSnapshot | null }
30
+
31
+ export type ServerEnvelope =
32
+ | { v: 1; type: "snapshot"; id: string; snapshot: ServerSnapshot }
33
+ | { v: 1; type: "ack"; id: string; result?: unknown }
34
+ | { v: 1; type: "error"; id?: string; message: string }
35
+
36
+ export function isClientEnvelope(value: unknown): value is ClientEnvelope {
37
+ if (!value || typeof value !== "object") return false
38
+ const candidate = value as Partial<ClientEnvelope>
39
+ return candidate.v === 1 && typeof candidate.type === "string"
40
+ }