kanna-code 0.40.1 → 0.41.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.
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { extractHtmlTitle, filterLocalHttpServers, isDescendantPid, isPathWithin, parseLsofListeningEntries, parseLsofListeningPorts } from "./local-http-servers"
3
+
4
+ describe("local http servers", () => {
5
+ test("extracts html titles", () => {
6
+ expect(extractHtmlTitle("<html><head><title> Vite App </title></head></html>")).toBe("Vite App")
7
+ expect(extractHtmlTitle("<html></html>")).toBe("")
8
+ })
9
+
10
+ test("parses listening tcp ports from lsof output", () => {
11
+ const output = `
12
+ COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
13
+ node 12345 jake 23u IPv4 123456 0t0 TCP *:5174 (LISTEN)
14
+ bun 12346 jake 23u IPv4 123457 0t0 TCP localhost:3210 (LISTEN)
15
+ other 12347 jake 23u IPv4 123458 0t0 TCP 127.0.0.1:8080 (LISTEN)
16
+ `
17
+
18
+ expect(parseLsofListeningPorts(output)).toEqual([3210, 5174, 8080])
19
+ expect(parseLsofListeningEntries(output)).toEqual([
20
+ { port: 3210, owners: [{ command: "bun", pid: 12346 }] },
21
+ { port: 5174, owners: [{ command: "node", pid: 12345 }] },
22
+ { port: 8080, owners: [{ command: "other", pid: 12347 }] },
23
+ ])
24
+ })
25
+
26
+ test("detects project paths", () => {
27
+ expect(isPathWithin("/tmp/project", "/tmp/project")).toBe(true)
28
+ expect(isPathWithin("/tmp/project", "/tmp/project/app")).toBe(true)
29
+ expect(isPathWithin("/tmp/project", "/tmp/project-other")).toBe(false)
30
+ })
31
+
32
+ test("detects descendant processes", () => {
33
+ const parentByPid = new Map([
34
+ [20, 10],
35
+ [30, 20],
36
+ [40, 1],
37
+ ])
38
+
39
+ expect(isDescendantPid(30, new Set([10]), parentByPid)).toBe(true)
40
+ expect(isDescendantPid(40, new Set([10]), parentByPid)).toBe(false)
41
+ })
42
+
43
+ test("filters internal responders without collapsing duplicate page titles", () => {
44
+ expect(filterLocalHttpServers([
45
+ { title: "localhost:3211", address: "http://localhost:3211", port: 3211, status: 404, ownerPath: "/tmp/app", processName: "bun", sameProject: true },
46
+ { title: "Superwall Agents", address: "http://localhost:5174", port: 5174, status: 200, ownerPath: "/tmp/app", processName: "node", sameProject: true },
47
+ { title: "Superwall Agents", address: "http://localhost:5175", port: 5175, status: 200, ownerPath: "/tmp/app", processName: "node", sameProject: true },
48
+ { title: "Superwall Agents", address: "http://localhost:8787", port: 8787, status: 200, ownerPath: "/tmp/app", processName: "workerd", sameProject: true },
49
+ { title: "Welcome to nginx!", address: "http://localhost:8080", port: 8080, status: 200, ownerPath: "/opt/homebrew", processName: "nginx", sameProject: false },
50
+ { title: "wterm-demo", address: "http://localhost:5003", port: 5003, status: 200, ownerPath: "/tmp/wterm", processName: "node", sameProject: false },
51
+ ])).toEqual([
52
+ { title: "Superwall Agents", address: "http://localhost:5174", port: 5174, status: 200, ownerPath: "/tmp/app", processName: "node", sameProject: true },
53
+ { title: "Superwall Agents", address: "http://localhost:5175", port: 5175, status: 200, ownerPath: "/tmp/app", processName: "node", sameProject: true },
54
+ { title: "wterm-demo", address: "http://localhost:5003", port: 5003, status: 200, ownerPath: "/tmp/wterm", processName: "node", sameProject: false },
55
+ ])
56
+ })
57
+ })
@@ -0,0 +1,279 @@
1
+ import { execFile } from "node:child_process"
2
+ import path from "node:path"
3
+ import { promisify } from "node:util"
4
+
5
+ const execFileAsync = promisify(execFile)
6
+ const LOCAL_HTTP_SCAN_TIMEOUT_MS = 450
7
+ const LOCAL_HTTP_CACHE_TTL_MS = 30_000
8
+
9
+ interface LocalHttpServerCacheEntry {
10
+ signature: string
11
+ scannedAt: number
12
+ results: LocalHttpServerInfo[]
13
+ }
14
+
15
+ const localHttpServerCache = new Map<string, LocalHttpServerCacheEntry>()
16
+
17
+ export interface LocalHttpServerInfo {
18
+ title: string
19
+ address: string
20
+ port: number
21
+ status: number
22
+ ownerPath?: string
23
+ processName?: string
24
+ sameProject?: boolean
25
+ }
26
+
27
+ interface ListeningPortOwner {
28
+ command: string
29
+ pid: number
30
+ }
31
+
32
+ export interface ListeningPortEntry {
33
+ port: number
34
+ owners: ListeningPortOwner[]
35
+ }
36
+
37
+ export function extractHtmlTitle(html: string) {
38
+ const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
39
+ if (!match) return ""
40
+ return match[1]
41
+ .replace(/<[^>]+>/g, "")
42
+ .replace(/\s+/g, " ")
43
+ .trim()
44
+ }
45
+
46
+ export function parseLsofListeningEntries(output: string) {
47
+ const entriesByPort = new Map<number, ListeningPortEntry>()
48
+
49
+ for (const line of output.split("\n").slice(1)) {
50
+ const match = line.match(/^(\S+)\s+(\d+).*TCP\s+(?:.+?):(\d+)\s+\(LISTEN\)/)
51
+ if (!match) continue
52
+
53
+ const command = match[1]
54
+ const pid = Number(match[2])
55
+ const port = Number(match[3])
56
+ if (!Number.isInteger(pid) || !Number.isInteger(port) || port <= 0 || port > 65535) {
57
+ continue
58
+ }
59
+
60
+ const entry = entriesByPort.get(port) ?? { port, owners: [] }
61
+ if (!entry.owners.some((owner) => owner.pid === pid)) {
62
+ entry.owners.push({ command, pid })
63
+ }
64
+ entriesByPort.set(port, entry)
65
+ }
66
+
67
+ return [...entriesByPort.values()].sort((a, b) => a.port - b.port)
68
+ }
69
+
70
+ export function parseLsofListeningPorts(output: string) {
71
+ return parseLsofListeningEntries(output).map((entry) => entry.port)
72
+ }
73
+
74
+ export function isPathWithin(parentPath: string | undefined, childPath: string | undefined) {
75
+ if (!parentPath || !childPath) return false
76
+ const normalizedParent = path.resolve(parentPath)
77
+ const normalizedChild = path.resolve(childPath)
78
+ return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}${path.sep}`)
79
+ }
80
+
81
+ export function isDescendantPid(pid: number, rootPids: Set<number>, parentByPid: Map<number, number>) {
82
+ let current: number | undefined = pid
83
+ const visited = new Set<number>()
84
+
85
+ while (current !== undefined && current > 0 && !visited.has(current)) {
86
+ if (rootPids.has(current)) return true
87
+ visited.add(current)
88
+ current = parentByPid.get(current)
89
+ }
90
+
91
+ return false
92
+ }
93
+
94
+ function isLikelyInternalResponder(server: LocalHttpServerInfo) {
95
+ if (server.status < 200 || server.status >= 400) return true
96
+ if (/^(localhost:\d+|502 Bad Gateway|Welcome to nginx!)$/i.test(server.title)) return true
97
+ if (/^(nginx|cloudflar|workerd|agent-bro|Google|Cursor|ControlCe|figma_age|Superhuma|Spotify|redis|postgres|mysqld)/i.test(server.processName ?? "")) {
98
+ return true
99
+ }
100
+ return false
101
+ }
102
+
103
+ export function filterLocalHttpServers(servers: LocalHttpServerInfo[]) {
104
+ return servers.filter((server) => !isLikelyInternalResponder(server))
105
+ }
106
+
107
+ async function listListeningTcpEntries() {
108
+ try {
109
+ const { stdout } = await execFileAsync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], {
110
+ timeout: 1_500,
111
+ maxBuffer: 1024 * 1024,
112
+ })
113
+ return parseLsofListeningEntries(stdout)
114
+ } catch {
115
+ return []
116
+ }
117
+ }
118
+
119
+ function getListeningEntriesSignature(entries: ListeningPortEntry) {
120
+ return `${entries.port}:${entries.owners.map((owner) => `${owner.command}:${owner.pid}`).join(",")}`
121
+ }
122
+
123
+ function getListeningEntryListSignature(entries: ListeningPortEntry[]) {
124
+ return entries.map(getListeningEntriesSignature).join("|")
125
+ }
126
+
127
+ async function findListeningTcpEntry(port: number) {
128
+ const entries = await listListeningTcpEntries()
129
+ return entries.find((entry) => entry.port === port)
130
+ }
131
+
132
+ export async function killLocalHttpServer(port: number) {
133
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
134
+ throw new Error("Port is invalid.")
135
+ }
136
+
137
+ const entry = await findListeningTcpEntry(port)
138
+ const owner = entry?.owners[0]
139
+ if (!owner) {
140
+ throw new Error(`No listening process found on port ${port}.`)
141
+ }
142
+
143
+ try {
144
+ process.kill(owner.pid, "SIGTERM")
145
+ } catch (error) {
146
+ throw new Error(error instanceof Error ? error.message : `Unable to kill process on port ${port}.`)
147
+ }
148
+
149
+ return {
150
+ ok: true,
151
+ port,
152
+ processName: owner.command,
153
+ }
154
+ }
155
+
156
+ async function readProcessCwd(pid: number) {
157
+ try {
158
+ const { stdout } = await execFileAsync("lsof", ["-p", String(pid), "-a", "-d", "cwd", "-Fn"], {
159
+ timeout: 500,
160
+ maxBuffer: 128 * 1024,
161
+ })
162
+ return stdout.split("\n").find((line) => line.startsWith("n"))?.slice(1)
163
+ } catch {
164
+ return undefined
165
+ }
166
+ }
167
+
168
+ async function readParentProcessMap() {
169
+ const parentByPid = new Map<number, number>()
170
+ try {
171
+ const { stdout } = await execFileAsync("ps", ["-axo", "pid=", "-o", "ppid="], {
172
+ timeout: 500,
173
+ maxBuffer: 1024 * 1024,
174
+ })
175
+ for (const line of stdout.split("\n")) {
176
+ const [pidText, ppidText] = line.trim().split(/\s+/)
177
+ const pid = Number(pidText)
178
+ const ppid = Number(ppidText)
179
+ if (Number.isInteger(pid) && Number.isInteger(ppid)) {
180
+ parentByPid.set(pid, ppid)
181
+ }
182
+ }
183
+ } catch {
184
+ // Process ancestry is an enrichment only; discovery still works without it.
185
+ }
186
+ return parentByPid
187
+ }
188
+
189
+ async function readHttpServer(
190
+ entry: ListeningPortEntry,
191
+ args: {
192
+ fetchImpl: typeof fetch
193
+ ownerPath?: string
194
+ processName?: string
195
+ projectPath?: string
196
+ projectTerminalRootPids: Set<number>
197
+ parentByPid: Map<number, number>
198
+ }
199
+ ): Promise<LocalHttpServerInfo | null> {
200
+ const address = `http://localhost:${entry.port}`
201
+ const controller = new AbortController()
202
+ const timeout = setTimeout(() => controller.abort(), LOCAL_HTTP_SCAN_TIMEOUT_MS)
203
+
204
+ try {
205
+ const response = await args.fetchImpl(address, {
206
+ signal: controller.signal,
207
+ redirect: "follow",
208
+ })
209
+ const contentType = response.headers.get("content-type") ?? ""
210
+ const text = await response.text().catch(() => "")
211
+ const title = contentType.toLowerCase().includes("text/html")
212
+ ? extractHtmlTitle(text)
213
+ : ""
214
+ const sameProject = isPathWithin(args.projectPath, args.ownerPath)
215
+ || entry.owners.some((owner) => isDescendantPid(owner.pid, args.projectTerminalRootPids, args.parentByPid))
216
+
217
+ return {
218
+ title: title || `localhost:${entry.port}`,
219
+ address,
220
+ port: entry.port,
221
+ status: response.status,
222
+ ownerPath: args.ownerPath,
223
+ processName: args.processName,
224
+ sameProject,
225
+ }
226
+ } catch {
227
+ return null
228
+ } finally {
229
+ clearTimeout(timeout)
230
+ }
231
+ }
232
+
233
+ export async function listLocalHttpServers(options: {
234
+ fetchImpl?: typeof fetch
235
+ projectPath?: string
236
+ projectTerminalRootPids?: number[]
237
+ } = {}) {
238
+ const fetchImpl = options.fetchImpl ?? fetch
239
+ const entries = await listListeningTcpEntries()
240
+ const projectTerminalRootPids = new Set(options.projectTerminalRootPids ?? [])
241
+ const cacheKey = `${options.projectPath ?? ""}\u0000${[...projectTerminalRootPids].sort((a, b) => a - b).join(",")}`
242
+ const signature = getListeningEntryListSignature(entries)
243
+ const cached = localHttpServerCache.get(cacheKey)
244
+ if (cached && cached.signature === signature && Date.now() - cached.scannedAt < LOCAL_HTTP_CACHE_TTL_MS) {
245
+ return cached.results
246
+ }
247
+
248
+ const parentByPid = await readParentProcessMap()
249
+ const ownerCwds = new Map<number, string | undefined>()
250
+
251
+ await Promise.all(entries.flatMap((entry) => entry.owners.map(async (owner) => {
252
+ if (ownerCwds.has(owner.pid)) return
253
+ ownerCwds.set(owner.pid, await readProcessCwd(owner.pid))
254
+ })))
255
+
256
+ const results = await Promise.all(entries.map((entry) => {
257
+ const primaryOwner = entry.owners[0]
258
+ const ownerPath = entry.owners.map((owner) => ownerCwds.get(owner.pid)).find(Boolean)
259
+ return readHttpServer(entry, {
260
+ fetchImpl,
261
+ ownerPath,
262
+ processName: primaryOwner?.command,
263
+ projectPath: options.projectPath,
264
+ projectTerminalRootPids,
265
+ parentByPid,
266
+ })
267
+ }))
268
+
269
+ const filteredResults = filterLocalHttpServers(results
270
+ .filter((result): result is LocalHttpServerInfo => result !== null)
271
+ ).sort((a, b) => Number(b.sameProject) - Number(a.sameProject) || a.port - b.port)
272
+ localHttpServerCache.set(cacheKey, {
273
+ signature,
274
+ scannedAt: Date.now(),
275
+ results: filteredResults,
276
+ })
277
+
278
+ return filteredResults
279
+ }
@@ -0,0 +1,35 @@
1
+ import { mkdtemp } from "node:fs/promises"
2
+ import { tmpdir } from "node:os"
3
+ import path from "node:path"
4
+ import { describe, expect, test } from "bun:test"
5
+ import { normalizeQuickActions, readProjectQuickActions, writeProjectQuickActions } from "./project-quick-actions"
6
+
7
+ describe("project quick actions", () => {
8
+ test("normalizes persisted quick actions", () => {
9
+ expect(normalizeQuickActions({
10
+ quickActions: [
11
+ { id: "dev", label: "Dev", command: "bun run dev" },
12
+ { id: "dev", label: "Duplicate", command: "echo duplicate" },
13
+ { id: "empty", label: "Empty", command: " " },
14
+ { id: "test", command: "bun test" },
15
+ ],
16
+ })).toEqual([
17
+ { id: "dev", label: "Dev", command: "bun run dev" },
18
+ { id: "test", label: "bun test", command: "bun test" },
19
+ ])
20
+ })
21
+
22
+ test("writes quick actions to the project .kanna directory", async () => {
23
+ const projectPath = await mkdtemp(path.join(tmpdir(), "kanna-project-quick-actions-"))
24
+
25
+ const written = await writeProjectQuickActions(projectPath, [
26
+ { id: "dev", label: "Dev", command: "bun run dev" },
27
+ ])
28
+
29
+ expect(written).toEqual([
30
+ { id: "dev", label: "Dev", command: "bun run dev" },
31
+ ])
32
+ await expect(readProjectQuickActions(projectPath)).resolves.toEqual(written)
33
+ await expect(Bun.file(path.join(projectPath, ".kanna", "quick-actions.json")).exists()).resolves.toBe(true)
34
+ })
35
+ })
@@ -0,0 +1,67 @@
1
+ import { mkdir } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import type { ProjectQuickAction } from "../shared/protocol"
4
+ import { resolveLocalPath } from "./paths"
5
+
6
+ const QUICK_ACTIONS_FILE_NAME = "quick-actions.json"
7
+ const MAX_QUICK_ACTIONS = 50
8
+ const MAX_LABEL_LENGTH = 80
9
+ const MAX_COMMAND_LENGTH = 2_000
10
+
11
+ function getProjectQuickActionsPath(projectPath: string) {
12
+ return path.join(resolveLocalPath(projectPath), ".kanna", QUICK_ACTIONS_FILE_NAME)
13
+ }
14
+
15
+ function normalizeQuickAction(value: unknown): ProjectQuickAction | null {
16
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null
17
+ const record = value as Record<string, unknown>
18
+ const id = typeof record.id === "string" ? record.id.trim() : ""
19
+ const label = typeof record.label === "string" ? record.label.trim() : ""
20
+ const command = typeof record.command === "string" ? record.command.trim() : ""
21
+ if (!id || !command) return null
22
+
23
+ return {
24
+ id,
25
+ label: (label || command).slice(0, MAX_LABEL_LENGTH),
26
+ command: command.slice(0, MAX_COMMAND_LENGTH),
27
+ }
28
+ }
29
+
30
+ export function normalizeQuickActions(value: unknown): ProjectQuickAction[] {
31
+ const rawActions = Array.isArray(value)
32
+ ? value
33
+ : value && typeof value === "object" && Array.isArray((value as { quickActions?: unknown }).quickActions)
34
+ ? (value as { quickActions: unknown[] }).quickActions
35
+ : []
36
+ const seenIds = new Set<string>()
37
+ const actions: ProjectQuickAction[] = []
38
+
39
+ for (const rawAction of rawActions) {
40
+ const action = normalizeQuickAction(rawAction)
41
+ if (!action || seenIds.has(action.id)) continue
42
+ seenIds.add(action.id)
43
+ actions.push(action)
44
+ if (actions.length >= MAX_QUICK_ACTIONS) break
45
+ }
46
+
47
+ return actions
48
+ }
49
+
50
+ export async function readProjectQuickActions(projectPath: string): Promise<ProjectQuickAction[]> {
51
+ const file = Bun.file(getProjectQuickActionsPath(projectPath))
52
+ if (!(await file.exists())) return []
53
+
54
+ try {
55
+ return normalizeQuickActions(JSON.parse(await file.text()))
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ export async function writeProjectQuickActions(projectPath: string, quickActions: ProjectQuickAction[]): Promise<ProjectQuickAction[]> {
62
+ const filePath = getProjectQuickActionsPath(projectPath)
63
+ const normalized = normalizeQuickActions(quickActions)
64
+ await mkdir(path.dirname(filePath), { recursive: true })
65
+ await Bun.write(filePath, `${JSON.stringify({ quickActions: normalized }, null, 2)}\n`)
66
+ return normalized
67
+ }
@@ -327,6 +327,18 @@ export class TerminalManager {
327
327
  }
328
328
  }
329
329
 
330
+ getRootPidsByCwd(cwd: string) {
331
+ const pids: number[] = []
332
+ for (const session of this.sessions.values()) {
333
+ if (session.cwd !== cwd || session.status !== "running") continue
334
+ const pid = session.process?.pid
335
+ if (typeof pid === "number") {
336
+ pids.push(pid)
337
+ }
338
+ }
339
+ return pids
340
+ }
341
+
330
342
  private snapshotOf(session: TerminalSession): TerminalSnapshot {
331
343
  return {
332
344
  terminalId: session.terminalId,
@@ -14,7 +14,9 @@ import { DiffStore } from "./diff-store"
14
14
  import { EventStore } from "./event-store"
15
15
  import { openExternal } from "./external-open"
16
16
  import { KeybindingsManager } from "./keybindings"
17
+ import { killLocalHttpServer, listLocalHttpServers } from "./local-http-servers"
17
18
  import { ensureProjectDirectory, resolveLocalPath } from "./paths"
19
+ import { readProjectQuickActions, writeProjectQuickActions } from "./project-quick-actions"
18
20
  import { writeStandaloneTranscriptExport } from "./standalone-export"
19
21
  import { TerminalManager } from "./terminal-manager"
20
22
  import type { UpdateManager } from "./update-manager"
@@ -1037,6 +1039,38 @@ export function createWsRouter({
1037
1039
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1038
1040
  return
1039
1041
  }
1042
+ case "browser.listLocalHttpServers": {
1043
+ const project = command.projectId ? store.getProject(command.projectId) : null
1044
+ const result = await listLocalHttpServers({
1045
+ projectPath: project?.localPath,
1046
+ projectTerminalRootPids: project ? terminals.getRootPidsByCwd(project.localPath) : [],
1047
+ })
1048
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1049
+ return
1050
+ }
1051
+ case "browser.killLocalHttpServer": {
1052
+ const result = await killLocalHttpServer(command.port)
1053
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1054
+ return
1055
+ }
1056
+ case "project.readQuickActions": {
1057
+ const project = store.getProject(command.projectId)
1058
+ if (!project) {
1059
+ throw new Error("Project not found")
1060
+ }
1061
+ const result = await readProjectQuickActions(project.localPath)
1062
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1063
+ return
1064
+ }
1065
+ case "project.writeQuickActions": {
1066
+ const project = store.getProject(command.projectId)
1067
+ if (!project) {
1068
+ throw new Error("Project not found")
1069
+ }
1070
+ const result = await writeProjectQuickActions(project.localPath, command.quickActions)
1071
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1072
+ return
1073
+ }
1040
1074
  case "update.check": {
1041
1075
  const snapshot = updateManager
1042
1076
  ? await updateManager.checkForUpdates({ force: command.force })
@@ -25,6 +25,22 @@ export interface EditorOpenSettings {
25
25
  commandTemplate: string
26
26
  }
27
27
 
28
+ export interface LocalHttpServerInfo {
29
+ title: string
30
+ address: string
31
+ port: number
32
+ status: number
33
+ ownerPath?: string
34
+ processName?: string
35
+ sameProject?: boolean
36
+ }
37
+
38
+ export interface ProjectQuickAction {
39
+ id: string
40
+ label: string
41
+ command: string
42
+ }
43
+
28
44
  export type SubscriptionTopic =
29
45
  | { type: "sidebar" }
30
46
  | { type: "local-projects" }
@@ -61,6 +77,10 @@ export type ClientCommand =
61
77
  | { type: "sidebar.reorderProjectGroups"; projectIds: string[] }
62
78
  | { type: "project.readDiffPatch"; projectId: string; path: string }
63
79
  | { type: "system.ping" }
80
+ | { type: "browser.listLocalHttpServers"; projectId?: string }
81
+ | { type: "browser.killLocalHttpServer"; port: number }
82
+ | { type: "project.readQuickActions"; projectId: string }
83
+ | { type: "project.writeQuickActions"; projectId: string; quickActions: ProjectQuickAction[] }
64
84
  | { type: "update.check"; force?: boolean }
65
85
  | { type: "update.install" }
66
86
  | { type: "settings.readKeybindings" }