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.
- package/README.md +137 -0
- package/bin/kanna +2 -0
- package/dist/client/assets/index-ClV0uXCn.css +1 -0
- package/dist/client/assets/index-DSpGrX6x.js +408 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +14 -0
- package/package.json +56 -0
- package/src/server/agent.ts +379 -0
- package/src/server/cli.ts +145 -0
- package/src/server/discovery.ts +65 -0
- package/src/server/event-store.ts +478 -0
- package/src/server/events.ts +134 -0
- package/src/server/external-open.ts +105 -0
- package/src/server/generate-title.ts +42 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +27 -0
- package/src/server/read-models.ts +120 -0
- package/src/server/server.ts +132 -0
- package/src/server/ws-router.ts +208 -0
- package/src/shared/branding.ts +23 -0
- package/src/shared/ports.ts +3 -0
- package/src/shared/protocol.ts +40 -0
- package/src/shared/types.ts +87 -0
|
@@ -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,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
|
+
}
|