@cuongtran001/kanna 1.63.2 → 1.63.3

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.
Files changed (199) hide show
  1. package/README.md +5 -9
  2. package/dist/client/assets/{BoardsPage-BJalB-bc.js → BoardsPage-AckCuswi.js} +1 -1
  3. package/dist/client/assets/BoardsRoutePage-DzKwKy3f.js +1 -0
  4. package/dist/client/assets/ChatPage-DsaH8CTz.js +93 -0
  5. package/dist/client/assets/{CronJobRow-CPbO_jwU.js → CronJobRow-Bx9gRqKW.js} +1 -1
  6. package/dist/client/assets/{CronJobsPage-Ck3SNgGi.js → CronJobsPage-v0-2X1oX.js} +1 -1
  7. package/dist/client/assets/{FilePreviewSheet-XEuUtDPn.js → FilePreviewSheet-B6SDxSD0.js} +2 -2
  8. package/dist/client/assets/{FlowSurface-BCbeMTJe.js → FlowSurface-CLC-ZFar.js} +1 -1
  9. package/dist/client/assets/{GenUIBlock-BJy_dfs-.js → GenUIBlock-CC79Ujro.js} +2 -2
  10. package/dist/client/assets/{KannaSocketProvider-D7Lzlt35.js → KannaSocketProvider-B2sHsWP5.js} +1 -1
  11. package/dist/client/assets/{LocalProjectsPage-9LJkQjhT.js → LocalProjectsPage-DYBkcEAm.js} +1 -1
  12. package/dist/client/assets/SettingsPage-CZqMkMRW.js +9 -0
  13. package/dist/client/assets/{StackBoardsRoutePage-t_EaFNG4.js → StackBoardsRoutePage-Clviz0hX.js} +1 -1
  14. package/dist/client/assets/{ToolCallMessage-RbF9XT2B.js → ToolCallMessage-BfNbWGmc.js} +1 -1
  15. package/dist/client/assets/{VChartSurface-CKuPelsn.js → VChartSurface-CUbxyBo3.js} +1 -1
  16. package/dist/client/assets/{WorkflowsPage-B6qUmQA3.js → WorkflowsPage-rengzb97.js} +1 -1
  17. package/dist/client/assets/{WorkflowsSection-DxO0S4An.js → WorkflowsSection-DuXdLsMO.js} +1 -1
  18. package/dist/client/assets/{chart-renderer-DjYZvNxh.js → chart-renderer-D7ukB2wg.js} +1 -1
  19. package/dist/client/assets/{chatSounds-FD2DQzF6.js → chatSounds-BLg2xPDZ.js} +1 -1
  20. package/dist/client/assets/chevron-left-RbFvmeyK.js +1 -0
  21. package/dist/client/assets/{clipboard.adapter-GN9nnYak.js → clipboard.adapter-CgdNwn-1.js} +1 -1
  22. package/dist/client/assets/{columnStyle-DjA7hA9k.js → columnStyle-CMCF0ds6.js} +1 -1
  23. package/dist/client/assets/{createScopedStore-BoLB9Rhc.js → createScopedStore-BrD5NHWx.js} +1 -1
  24. package/dist/client/assets/{dist-CimtOoGK.js → dist-DNSTHlSo.js} +1 -1
  25. package/dist/client/assets/editor-icons-DY4uKeEY.js +1 -0
  26. package/dist/client/assets/{ellipsis-Cd7sO0LO.js → ellipsis-OHg0x_x1.js} +1 -1
  27. package/dist/client/assets/{flow-kinds-CW2TU_83.js → flow-kinds-Ki2DP9nP.js} +1 -1
  28. package/dist/client/assets/{folder-C7sXCaHj.js → folder-CnWzWIG5.js} +1 -1
  29. package/dist/client/assets/{formatters-wSVyvDFZ.js → formatters-BGORekd-.js} +1 -1
  30. package/dist/client/assets/{genui-EzBIAYFE.js → genui-C9i6j0XZ.js} +1 -1
  31. package/dist/client/assets/{globe-xmRpmpma.js → globe-BjR1tyE4.js} +1 -1
  32. package/dist/client/assets/{host-DCaA9W-Z.js → host-D9tJwZXl.js} +1 -1
  33. package/dist/client/assets/{index-CDqjh7nt.js → index-CesRxZ0X.js} +7 -7
  34. package/dist/client/assets/index-CxDE9VOy.css +1 -0
  35. package/dist/client/assets/{info-D4MMvZFe.js → info-DNYekoE-.js} +1 -1
  36. package/dist/client/assets/{input-C4fvDiQ4.js → input-DgW9uRZN.js} +1 -1
  37. package/dist/client/assets/{lexicalToReact-sPUyOWk6.js → lexicalToReact-CZvp9Pgz.js} +3 -3
  38. package/dist/client/assets/{log-aTaDVF_e.js → log-DnEvbp3E.js} +1 -1
  39. package/dist/client/assets/{monitor-_CRKcV1h.js → monitor-qbWfUdBG.js} +1 -1
  40. package/dist/client/assets/{pencil-fNBeUUCf.js → pencil-C965rrpd.js} +1 -1
  41. package/dist/client/assets/{pendingActionsStore-COTh4Ztq.js → pendingActionsStore-CxhI19V9.js} +1 -1
  42. package/dist/client/assets/{play-BAmkJfn6.js → play-uofLR7yT.js} +1 -1
  43. package/dist/client/assets/{plus-c75CkNEO.js → plus-D4CI6rj1.js} +1 -1
  44. package/dist/client/assets/{popover-CHM4skzh.js → popover-Bc646hKn.js} +1 -1
  45. package/dist/client/assets/{pushClient-BJH_ITRB.js → pushClient-CxnHu9h4.js} +1 -1
  46. package/dist/client/assets/runDetached-DfQXHAaY.js +1 -0
  47. package/dist/client/assets/{segmented-control-DNOwRupT.js → segmented-control-CKGX29Kl.js} +1 -1
  48. package/dist/client/assets/{select-dLHiAls6.js → select-BjeajpVt.js} +1 -1
  49. package/dist/client/assets/{settings-header-button-CGKCzFyk.js → settings-header-button-BYvHR6Xl.js} +1 -1
  50. package/dist/client/assets/{sidebarPendingActions-Dyp80Lv_.js → sidebarPendingActions-xgMTnfRY.js} +1 -1
  51. package/dist/client/assets/{square-pen-B1IxmScM.js → square-pen-DmppQdTd.js} +1 -1
  52. package/dist/client/assets/{state-mark-BhTZfcYe.js → state-mark-C3kEL1hX.js} +1 -1
  53. package/dist/client/assets/{status-pill-CwhJhaMy.js → status-pill-Bo1Nb6HX.js} +1 -1
  54. package/dist/client/assets/{terminal-Bnj7qFGw.js → terminal-DfCF-J4p.js} +1 -1
  55. package/dist/client/assets/{timer.adapter-DHt2zwsL.js → timer.adapter-C2RhP4d6.js} +1 -1
  56. package/dist/client/assets/{tools-3gJcj3hK.js → tools-t5T_3UBg.js} +1 -1
  57. package/dist/client/assets/{triangle-alert-CWrc0CIO.js → triangle-alert-CYQhobyY.js} +1 -1
  58. package/dist/client/assets/useAppGlobalState-DUa3pkCF.js +1 -0
  59. package/dist/client/assets/useKannaState-FcieR5qb.js +5 -0
  60. package/dist/client/assets/useTheme-D9sKDPEm.js +1 -0
  61. package/dist/client/index.html +35 -35
  62. package/package.json +2 -2
  63. package/src/server/agent-coordinator-types.ts +0 -6
  64. package/src/server/agent-coordinator.ts +6 -45
  65. package/src/server/agent.compaction.test.ts +1 -13
  66. package/src/server/agent.oauth-account-info.test.ts +0 -3
  67. package/src/server/agent.oauth-release.test.ts +0 -2
  68. package/src/server/agent.oauth-rotation.test.ts +0 -9
  69. package/src/server/agent.oauth-token-share-cap.test.ts +0 -1
  70. package/src/server/agent.openrouter-model.test.ts +0 -2
  71. package/src/server/agent.openrouter-watchdog.test.ts +0 -3
  72. package/src/server/agent.sdk-workflow-register.test.ts +2 -64
  73. package/src/server/agent.test.ts +1 -319
  74. package/src/server/app-settings.test.ts +14 -17
  75. package/src/server/app-settings.ts +22 -37
  76. package/src/server/auto-continue/e2e.test.ts +0 -1
  77. package/src/server/background-task-output-registry.test.ts +1 -1
  78. package/src/server/background-task-output-registry.ts +1 -1
  79. package/src/server/claude-cancel-handler.test.ts +0 -58
  80. package/src/server/claude-cancel-handler.ts +1 -17
  81. package/src/server/claude-harness-stream.test.ts +134 -0
  82. package/src/server/{claude-pty/jsonl-path.test.ts → claude-projects-path.adapter.test.ts} +1 -19
  83. package/src/server/{claude-pty/jsonl-path.adapter.ts → claude-projects-path.adapter.ts} +0 -11
  84. package/src/server/claude-send-command.test.ts +0 -1
  85. package/src/server/claude-session-config-helpers.test.ts +1 -90
  86. package/src/server/claude-session-config-helpers.ts +1 -38
  87. package/src/server/claude-session-error-handler.test.ts +0 -1
  88. package/src/server/claude-session-lifecycle.test.ts +2 -36
  89. package/src/server/claude-session-lifecycle.ts +2 -8
  90. package/src/server/claude-session-runner.test.ts +8 -36
  91. package/src/server/claude-session-runner.ts +1 -26
  92. package/src/server/claude-session-spawner.test.ts +5 -64
  93. package/src/server/claude-session-spawner.ts +49 -115
  94. package/src/server/claude-session-start.test.ts +1 -14
  95. package/src/server/claude-session-start.ts +0 -8
  96. package/src/server/claude-session-state-queries.test.ts +1 -2
  97. package/src/server/claude-session-state.test.ts +0 -1
  98. package/src/server/claude-session-state.ts +0 -4
  99. package/src/server/claude-subagent-wiring.test.ts +2 -68
  100. package/src/server/claude-subagent-wiring.ts +3 -50
  101. package/src/server/claude-turn-starter-types.ts +0 -3
  102. package/src/server/claude-turn-starter.test.ts +0 -28
  103. package/src/server/claude-turn-starter.ts +0 -5
  104. package/src/server/event-store.test.ts +0 -2
  105. package/src/server/events.ts +1 -2
  106. package/src/server/harness-types.ts +1 -2
  107. package/src/server/import-subagent-drill.e2e.test.ts +1 -1
  108. package/src/server/imported-subagents-dir.test.ts +1 -1
  109. package/src/server/imported-subagents-dir.ts +1 -1
  110. package/src/server/kanna-mcp.test.ts +0 -75
  111. package/src/server/kanna-mcp.ts +1 -47
  112. package/src/server/server.test.ts +5 -7
  113. package/src/server/server.ts +2 -18
  114. package/src/server/subagent-orchestrator.test.ts +0 -1
  115. package/src/server/subagent-provider-run.test.ts +48 -10
  116. package/src/server/subagent-provider-run.ts +12 -3
  117. package/src/server/tool-callback.test.ts +1 -1
  118. package/src/server/ws-router-agent-ctrl.test.ts +0 -45
  119. package/src/server/ws-router-agent-ctrl.ts +1 -31
  120. package/src/server/ws-router-broadcast.ts +0 -27
  121. package/src/server/ws-router-defaults.test.ts +2 -3
  122. package/src/server/ws-router-defaults.ts +2 -3
  123. package/src/server/ws-router-envelope.ts +0 -15
  124. package/src/server/ws-router-observability.test.ts +1 -1
  125. package/src/server/ws-router-settings.test.ts +2 -2
  126. package/src/server/ws-router.test.ts +2 -2
  127. package/src/server/ws-router.ts +0 -8
  128. package/src/shared/app-settings-types.ts +9 -20
  129. package/src/shared/claude-api-error-classification.ts +1 -1
  130. package/src/shared/protocol.ts +1 -11
  131. package/dist/client/assets/BoardsRoutePage-BTTjJX-Y.js +0 -1
  132. package/dist/client/assets/ChatPage-DH8wtQyD.js +0 -93
  133. package/dist/client/assets/SettingsPage-CKrZ6dGQ.js +0 -9
  134. package/dist/client/assets/chevron-left-CJ0qv9kF.js +0 -1
  135. package/dist/client/assets/editor-icons-CONDQZof.js +0 -1
  136. package/dist/client/assets/index-C-UYaDk6.css +0 -1
  137. package/dist/client/assets/runDetached-DqQ3k2ji.js +0 -1
  138. package/dist/client/assets/useAppGlobalState-DKOGrO5l.js +0 -1
  139. package/dist/client/assets/useKannaState-D7R_-dM6.js +0 -5
  140. package/dist/client/assets/useTheme-B57LIKyx.js +0 -1
  141. package/src/server/agent.pty-rotation.test.ts +0 -322
  142. package/src/server/claude-pty/auth.test.ts +0 -38
  143. package/src/server/claude-pty/auth.ts +0 -17
  144. package/src/server/claude-pty/claude-session-registry.adapter.ts +0 -68
  145. package/src/server/claude-pty/claude-session-registry.test.ts +0 -149
  146. package/src/server/claude-pty/driver.test.ts +0 -1153
  147. package/src/server/claude-pty/driver.ts +0 -829
  148. package/src/server/claude-pty/env.test.ts +0 -79
  149. package/src/server/claude-pty/env.ts +0 -20
  150. package/src/server/claude-pty/jsonl-to-event.test.ts +0 -882
  151. package/src/server/claude-pty/jsonl-to-event.ts +0 -357
  152. package/src/server/claude-pty/parity-matrix.test.ts +0 -437
  153. package/src/server/claude-pty/pid-registry.adapter.ts +0 -164
  154. package/src/server/claude-pty/pid-registry.test.ts +0 -178
  155. package/src/server/claude-pty/preflight/binary-fingerprint.adapter.ts +0 -20
  156. package/src/server/claude-pty/preflight/binary-fingerprint.test.ts +0 -32
  157. package/src/server/claude-pty/pty-instance-registry.test.ts +0 -208
  158. package/src/server/claude-pty/pty-instance-registry.ts +0 -172
  159. package/src/server/claude-pty/pty-memory-sampler.adapter.test.ts +0 -103
  160. package/src/server/claude-pty/pty-memory-sampler.adapter.ts +0 -86
  161. package/src/server/claude-pty/pty-process.adapter.ts +0 -60
  162. package/src/server/claude-pty/pty-process.test.ts +0 -49
  163. package/src/server/claude-pty/resolve-binary.adapter.ts +0 -106
  164. package/src/server/claude-pty/resolve-binary.test.ts +0 -118
  165. package/src/server/claude-pty/runtime-dir.adapter.ts +0 -19
  166. package/src/server/claude-pty/sandbox/detect.adapter.ts +0 -17
  167. package/src/server/claude-pty/sandbox/detect.test.ts +0 -17
  168. package/src/server/claude-pty/sandbox/platform.test.ts +0 -35
  169. package/src/server/claude-pty/sandbox/platform.ts +0 -27
  170. package/src/server/claude-pty/sandbox/preflight-io.adapter.ts +0 -14
  171. package/src/server/claude-pty/sandbox/preflight.test.ts +0 -69
  172. package/src/server/claude-pty/sandbox/preflight.ts +0 -44
  173. package/src/server/claude-pty/sandbox/profile-linux.adapter.ts +0 -63
  174. package/src/server/claude-pty/sandbox/profile-linux.test.ts +0 -90
  175. package/src/server/claude-pty/sandbox/profile-macos.adapter.ts +0 -72
  176. package/src/server/claude-pty/sandbox/profile-macos.test.ts +0 -68
  177. package/src/server/claude-pty/sandbox/wrap.adapter.ts +0 -58
  178. package/src/server/claude-pty/sandbox/wrap.test.ts +0 -82
  179. package/src/server/claude-pty/settings-writer.adapter.ts +0 -23
  180. package/src/server/claude-pty/settings-writer.test.ts +0 -22
  181. package/src/server/claude-pty/smoke-test-io.adapter.ts +0 -28
  182. package/src/server/claude-pty/smoke-test.test.ts +0 -199
  183. package/src/server/claude-pty/smoke-test.ts +0 -182
  184. package/src/server/claude-pty/tui-control.test.ts +0 -300
  185. package/src/server/claude-pty/tui-control.ts +0 -174
  186. package/src/server/claude-pty/tui-source.adapter.test.ts +0 -374
  187. package/src/server/claude-pty/tui-source.adapter.ts +0 -294
  188. package/src/server/http-server.adapter.test.ts +0 -18
  189. package/src/server/http-server.adapter.ts +0 -39
  190. package/src/server/kanna-mcp-http.test.ts +0 -274
  191. package/src/server/kanna-mcp-http.ts +0 -194
  192. package/src/server/kanna-mcp-tools/ask-user-question.test.ts +0 -108
  193. package/src/server/kanna-mcp-tools/ask-user-question.ts +0 -55
  194. package/src/server/kanna-mcp-tools/exit-plan-mode.test.ts +0 -61
  195. package/src/server/kanna-mcp-tools/exit-plan-mode.ts +0 -49
  196. package/src/server/mcp-zod-compat.adapter.ts +0 -20
  197. package/src/shared/pty-instance.ts +0 -43
  198. /package/src/server/{claude-pty/output-ring.test.ts → output-ring.test.ts} +0 -0
  199. /package/src/server/{claude-pty/output-ring.ts → output-ring.ts} +0 -0
@@ -1,178 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
- import { mkdtemp, readFile, rm, writeFile, mkdir, stat } from "node:fs/promises"
3
- import os from "node:os"
4
- import path from "node:path"
5
- import { ClaudePtyRegistry, killProcessTree } from "./pid-registry.adapter"
6
-
7
- let tempDir = ""
8
- let registryPath = ""
9
-
10
- beforeEach(async () => {
11
- tempDir = await mkdtemp(path.join(os.tmpdir(), "kanna-claude-pty-registry-"))
12
- registryPath = path.join(tempDir, "claude-pty.json")
13
- })
14
-
15
- afterEach(async () => {
16
- if (tempDir) {
17
- await rm(tempDir, { recursive: true, force: true })
18
- }
19
- })
20
-
21
- describe("ClaudePtyRegistry", () => {
22
- test("register persists entries with sessionId, pid, cwd, runtimeDir", async () => {
23
- const registry = new ClaudePtyRegistry(registryPath)
24
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 12345, cwd: "/tmp/a", runtimeDir: "/tmp/r1" })
25
- await registry.register({ chatId: "c2", sessionId: "s2", pid: 23456, cwd: "/tmp/b", runtimeDir: "/tmp/r2" })
26
-
27
- const raw = JSON.parse(await readFile(registryPath, "utf8")) as {
28
- entries: Array<{ chatId: string; sessionId: string; pid: number; runtimeDir: string }>
29
- }
30
- expect(raw.entries).toHaveLength(2)
31
- expect(raw.entries[0]).toMatchObject({ chatId: "c1", sessionId: "s1", pid: 12345, cwd: "/tmp/a", runtimeDir: "/tmp/r1" })
32
- expect(raw.entries[1]).toMatchObject({ chatId: "c2", sessionId: "s2", pid: 23456, cwd: "/tmp/b", runtimeDir: "/tmp/r2" })
33
- })
34
-
35
- test("re-registering the same pid replaces the prior entry", async () => {
36
- const registry = new ClaudePtyRegistry(registryPath)
37
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 100, cwd: "/tmp/old", runtimeDir: "/tmp/r-old" })
38
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 100, cwd: "/tmp/new", runtimeDir: "/tmp/r-new" })
39
-
40
- const raw = JSON.parse(await readFile(registryPath, "utf8")) as { entries: Array<{ pid: number; runtimeDir: string }> }
41
- expect(raw.entries).toHaveLength(1)
42
- expect(raw.entries[0]?.pid).toBe(100)
43
- expect(raw.entries[0]?.runtimeDir).toBe("/tmp/r-new")
44
- })
45
-
46
- test("re-spawn of the same sessionId with a new pid keeps BOTH entries (identity is pid, not sessionId)", async () => {
47
- const registry = new ClaudePtyRegistry(registryPath)
48
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 38830, cwd: "/tmp/a", runtimeDir: "/tmp/r-old" })
49
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 41506, cwd: "/tmp/a", runtimeDir: "/tmp/r-new" })
50
-
51
- const raw = JSON.parse(await readFile(registryPath, "utf8")) as { entries: Array<{ pid: number }> }
52
- expect(raw.entries.map((e) => e.pid).sort((a, b) => a - b)).toEqual([38830, 41506])
53
- })
54
-
55
- test("unregister(pid) removes only the matching pid", async () => {
56
- const registry = new ClaudePtyRegistry(registryPath)
57
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 1, cwd: "/tmp/a", runtimeDir: "/tmp/r1" })
58
- await registry.register({ chatId: "c2", sessionId: "s2", pid: 2, cwd: "/tmp/b", runtimeDir: "/tmp/r2" })
59
- await registry.unregister(1)
60
-
61
- const raw = JSON.parse(await readFile(registryPath, "utf8")) as { entries: Array<{ pid: number }> }
62
- expect(raw.entries).toHaveLength(1)
63
- expect(raw.entries[0]?.pid).toBe(2)
64
- })
65
-
66
- test("unregister(stale pid) does NOT remove the live re-spawn entry sharing the sessionId", async () => {
67
- const registry = new ClaudePtyRegistry(registryPath)
68
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 38830, cwd: "/tmp/a", runtimeDir: "/tmp/r-old" })
69
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 41506, cwd: "/tmp/a", runtimeDir: "/tmp/r-new" })
70
- await registry.unregister(38830)
71
-
72
- const raw = JSON.parse(await readFile(registryPath, "utf8")) as { entries: Array<{ pid: number }> }
73
- expect(raw.entries.map((e) => e.pid)).toEqual([41506])
74
- })
75
-
76
- test("reapStale kills live process groups, removes runtimeDirs, and clears the file", async () => {
77
- const child = Bun.spawn(
78
- ["python3", "-c", "import os, sys, time; os.setsid(); sys.stdout.write('ready\\n'); sys.stdout.flush(); time.sleep(60)"],
79
- { stdout: "pipe", stderr: "ignore", stdin: "ignore" },
80
- )
81
- const reader = child.stdout.getReader()
82
- const decoded = new TextDecoder().decode((await reader.read()).value ?? new Uint8Array())
83
- expect(decoded).toContain("ready")
84
- reader.releaseLock()
85
- const childPid = child.pid
86
-
87
- const runtimeDir = path.join(tempDir, "spawn-runtime")
88
- await mkdir(runtimeDir, { recursive: true })
89
- await writeFile(path.join(runtimeDir, "mcp-config.json"), "{}", "utf8")
90
-
91
- await writeFile(
92
- registryPath,
93
- JSON.stringify({
94
- entries: [
95
- { chatId: "c1", sessionId: "s1", pid: childPid, cwd: "/tmp/a", runtimeDir, createdAt: Date.now() },
96
- { chatId: "c2", sessionId: "s2", pid: 999_999_999, cwd: "/tmp/b", runtimeDir: "/tmp/nonexistent", createdAt: Date.now() },
97
- ],
98
- }),
99
- "utf8",
100
- )
101
-
102
- const registry = new ClaudePtyRegistry(registryPath)
103
- const reaped = await registry.reapStale()
104
-
105
- expect(reaped.map((entry) => entry.sessionId).sort()).toEqual(["s1", "s2"])
106
-
107
- const exited = await Promise.race([
108
- child.exited,
109
- new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 3_000)),
110
- ])
111
- expect(exited).not.toBe("timeout")
112
- expect(child.signalCode).toBe("SIGKILL")
113
- void childPid
114
-
115
- await expect(stat(runtimeDir)).rejects.toThrow()
116
-
117
- const raw = JSON.parse(await readFile(registryPath, "utf8")) as { entries: unknown[] }
118
- expect(raw.entries).toEqual([])
119
- }, 30_000)
120
-
121
- test("killProcessTree reaps a NON-leader process and its descendants", async () => {
122
- const parent = Bun.spawn(
123
- [
124
- "python3",
125
- "-c",
126
- "import subprocess, sys, time; c = subprocess.Popen(['sleep', '120']); "
127
- + "sys.stdout.write(str(c.pid) + '\\n'); sys.stdout.flush(); time.sleep(120)",
128
- ],
129
- { stdout: "pipe", stderr: "ignore", stdin: "ignore" },
130
- )
131
- const reader = parent.stdout.getReader()
132
- const childLine = new TextDecoder().decode((await reader.read()).value ?? new Uint8Array()).trim()
133
- reader.releaseLock()
134
- const childPid = Number.parseInt(childLine, 10)
135
- expect(Number.isFinite(childPid)).toBe(true)
136
- const parentPid = parent.pid
137
-
138
- await killProcessTree(parentPid)
139
-
140
- const exited = await Promise.race([
141
- parent.exited,
142
- new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
143
- ])
144
- expect(exited).not.toBe("timeout")
145
-
146
- await new Promise((r) => setTimeout(r, 200))
147
- let childAlive = true
148
- try { process.kill(childPid, 0) } catch { childAlive = false }
149
- expect(childAlive).toBe(false)
150
- }, 30_000)
151
-
152
- test("killProcessTree ignores an invalid pid without throwing", async () => {
153
- await killProcessTree(0)
154
- await killProcessTree(-1)
155
- await killProcessTree(Number.NaN)
156
- })
157
-
158
- test("reapStale tolerates a missing registry file", async () => {
159
- const registry = new ClaudePtyRegistry(registryPath)
160
- const reaped = await registry.reapStale()
161
- expect(reaped).toEqual([])
162
- })
163
-
164
- test("reapStale tolerates a malformed registry file", async () => {
165
- await writeFile(registryPath, "not json", "utf8")
166
- const registry = new ClaudePtyRegistry(registryPath)
167
- const reaped = await registry.reapStale()
168
- expect(reaped).toEqual([])
169
- })
170
-
171
- test("register creates the parent directory if missing", async () => {
172
- const nestedPath = path.join(tempDir, "nested", "deep", "claude-pty.json")
173
- const registry = new ClaudePtyRegistry(nestedPath)
174
- await registry.register({ chatId: "c1", sessionId: "s1", pid: 1, cwd: "/tmp/a", runtimeDir: "/tmp/r1" })
175
- const raw = JSON.parse(await readFile(nestedPath, "utf8")) as { entries: unknown[] }
176
- expect(raw.entries).toHaveLength(1)
177
- })
178
- })
@@ -1,20 +0,0 @@
1
- import { createHash } from "node:crypto"
2
- import { open } from "node:fs/promises"
3
-
4
- export async function computeBinarySha256(filePath: string): Promise<string> {
5
- const fd = await open(filePath, "r")
6
- try {
7
- const hash = createHash("sha256")
8
- const buf = Buffer.alloc(64 * 1024)
9
- let pos = 0
10
- while (true) {
11
- const { bytesRead } = await fd.read(buf, 0, buf.length, pos)
12
- if (bytesRead === 0) break
13
- hash.update(buf.subarray(0, bytesRead))
14
- pos += bytesRead
15
- }
16
- return hash.digest("hex")
17
- } finally {
18
- await fd.close()
19
- }
20
- }
@@ -1,32 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { computeBinarySha256 } from "./binary-fingerprint.adapter"
3
- import { mkdtemp, rm, writeFile } from "node:fs/promises"
4
- import { tmpdir } from "node:os"
5
- import path from "node:path"
6
-
7
- describe("computeBinarySha256", () => {
8
- test("returns 64-char hex sha256 of file contents", async () => {
9
- const dir = await mkdtemp(path.join(tmpdir(), "kanna-binsha-"))
10
- try {
11
- const f = path.join(dir, "fake-claude")
12
- await writeFile(f, "hello", "utf8")
13
- const sha = await computeBinarySha256(f)
14
- expect(sha).toMatch(/^[0-9a-f]{64}$/)
15
- } finally { await rm(dir, { recursive: true, force: true }) }
16
- })
17
-
18
- test("identical content → identical sha", async () => {
19
- const dir = await mkdtemp(path.join(tmpdir(), "kanna-binsha-"))
20
- try {
21
- const a = path.join(dir, "a")
22
- const b = path.join(dir, "b")
23
- await writeFile(a, "x", "utf8")
24
- await writeFile(b, "x", "utf8")
25
- expect(await computeBinarySha256(a)).toBe(await computeBinarySha256(b))
26
- } finally { await rm(dir, { recursive: true, force: true }) }
27
- })
28
-
29
- test("throws when file does not exist", async () => {
30
- await expect(computeBinarySha256("/nonexistent/path")).rejects.toThrow()
31
- })
32
- })
@@ -1,208 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import {
3
- createPtyInstanceRegistry,
4
- type PtyInstanceDelta,
5
- type PtyInstanceState,
6
- } from "./pty-instance-registry"
7
-
8
- function baseline(overrides: Partial<PtyInstanceState> = {}): Omit<PtyInstanceState, "chatId"> {
9
- return {
10
- sessionId: null,
11
- pid: null,
12
- cwd: "/tmp",
13
- model: "claude-opus-4-7",
14
- accountLabel: null,
15
- oauthMasked: null,
16
- phase: "spawning",
17
- startedAt: 1_000,
18
- lastEventAt: 1_000,
19
- turnCount: 0,
20
- tokensIn: 0,
21
- tokensOut: 0,
22
- planMode: null,
23
- smokeTest: null,
24
- outputRingTail: null,
25
- exitedAt: null,
26
- exitCode: null,
27
- rssBytes: null,
28
- rssPeakBytes: null,
29
- cpuPercent: null,
30
- cpuPeakPercent: null,
31
- ...overrides,
32
- }
33
- }
34
-
35
- describe("PtyInstanceRegistry", () => {
36
- test("upsert with new chatId fires added", () => {
37
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
38
- const events: PtyInstanceDelta[] = []
39
- registry.subscribe((d) => events.push(d))
40
- registry.upsert("c1", baseline())
41
- expect(events).toHaveLength(1)
42
- expect(events[0]).toMatchObject({ type: "added", instance: { chatId: "c1" } })
43
- })
44
-
45
- test("upsert with existing chatId fires updated and merges patch", () => {
46
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
47
- registry.upsert("c1", baseline())
48
- const events: PtyInstanceDelta[] = []
49
- registry.subscribe((d) => events.push(d))
50
- registry.upsert("c1", { phase: "ready", pid: 42 })
51
- expect(events).toHaveLength(1)
52
- expect(events[0]).toMatchObject({
53
- type: "updated",
54
- instance: { chatId: "c1", phase: "ready", pid: 42, cwd: "/tmp" },
55
- })
56
- })
57
-
58
- test("markExitedIfCurrent applies the patch when the live pid matches", () => {
59
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
60
- registry.upsert("c1", baseline({ pid: 41506, phase: "ready" }))
61
- const events: PtyInstanceDelta[] = []
62
- registry.subscribe((d) => events.push(d))
63
- registry.markExitedIfCurrent("c1", 41506, { phase: "exited", exitedAt: 5_000 })
64
- expect(events).toHaveLength(1)
65
- expect(events[0]).toMatchObject({
66
- type: "updated",
67
- instance: { chatId: "c1", phase: "exited", exitedAt: 5_000 },
68
- })
69
- })
70
-
71
- test("markExitedIfCurrent is a no-op when a newer pid owns the chat entry", () => {
72
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
73
- registry.upsert("c1", baseline({ pid: 41506, phase: "ready" }))
74
- const events: PtyInstanceDelta[] = []
75
- registry.subscribe((d) => events.push(d))
76
- registry.markExitedIfCurrent("c1", 38830, { phase: "exited", exitedAt: 5_000 })
77
- expect(events).toHaveLength(0)
78
- expect(registry.snapshot()[0]).toMatchObject({ chatId: "c1", phase: "ready", pid: 41506 })
79
- })
80
-
81
- test("markExitedIfCurrent is a no-op when the chat has no entry", () => {
82
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
83
- const events: PtyInstanceDelta[] = []
84
- registry.subscribe((d) => events.push(d))
85
- registry.markExitedIfCurrent("missing", 1, { phase: "exited" })
86
- expect(events).toHaveLength(0)
87
- })
88
-
89
- test("remove fires removed event and drops state", () => {
90
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
91
- registry.upsert("c1", baseline())
92
- const events: PtyInstanceDelta[] = []
93
- registry.subscribe((d) => events.push(d))
94
- registry.remove("c1")
95
- expect(events).toEqual([{ type: "removed", chatId: "c1" }])
96
- expect(registry.snapshot()).toEqual([])
97
- })
98
-
99
- test("remove for unknown chatId is a no-op", () => {
100
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
101
- const events: PtyInstanceDelta[] = []
102
- registry.subscribe((d) => events.push(d))
103
- registry.remove("nope")
104
- expect(events).toEqual([])
105
- })
106
-
107
- test("snapshot returns clones, not references", () => {
108
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
109
- registry.upsert("c1", baseline())
110
- const a = registry.snapshot()
111
- const b = registry.snapshot()
112
- expect(a[0]).not.toBe(b[0])
113
- a[0]!.phase = "exited"
114
- expect(registry.snapshot()[0]!.phase).toBe("spawning")
115
- })
116
-
117
- test("unsubscribe stops further events", () => {
118
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
119
- const events: PtyInstanceDelta[] = []
120
- const off = registry.subscribe((d) => events.push(d))
121
- off()
122
- registry.upsert("c1", baseline())
123
- expect(events).toEqual([])
124
- })
125
-
126
- test("coalesce: rapid updates within window emit one trailing delta", async () => {
127
- const registry = createPtyInstanceRegistry({ coalesceMs: 30 })
128
- registry.upsert("c1", baseline())
129
- const events: PtyInstanceDelta[] = []
130
- registry.subscribe((d) => events.push(d))
131
- registry.upsert("c1", { tokensIn: 1 })
132
- registry.upsert("c1", { tokensIn: 2 })
133
- registry.upsert("c1", { tokensIn: 3 })
134
- expect(events).toEqual([])
135
- await new Promise((r) => setTimeout(r, 50))
136
- expect(events).toHaveLength(1)
137
- expect(events[0]).toMatchObject({ type: "updated", instance: { tokensIn: 3 } })
138
- })
139
-
140
- test("coalesce: added events are not delayed", () => {
141
- const registry = createPtyInstanceRegistry({ coalesceMs: 30 })
142
- const events: PtyInstanceDelta[] = []
143
- registry.subscribe((d) => events.push(d))
144
- registry.upsert("c1", baseline())
145
- expect(events).toHaveLength(1)
146
- expect(events[0]!.type).toBe("added")
147
- })
148
-
149
- test("coalesce: removed flushes pending update for same chatId", async () => {
150
- const registry = createPtyInstanceRegistry({ coalesceMs: 30 })
151
- registry.upsert("c1", baseline())
152
- const events: PtyInstanceDelta[] = []
153
- registry.subscribe((d) => events.push(d))
154
- registry.upsert("c1", { phase: "streaming" })
155
- registry.remove("c1")
156
- await new Promise((r) => setTimeout(r, 50))
157
- expect(events.map((e) => e.type)).toEqual(["removed"])
158
- })
159
-
160
- test("exitedTtlMs: entry auto-removed after TTL when phase becomes exited", async () => {
161
- const registry = createPtyInstanceRegistry({ coalesceMs: 0, exitedTtlMs: 30 })
162
- registry.upsert("c1", baseline({ phase: "ready" }))
163
- const events: PtyInstanceDelta[] = []
164
- registry.subscribe((d) => events.push(d))
165
- registry.upsert("c1", { phase: "exited", exitedAt: 2_000 })
166
- expect(events.map((e) => e.type)).toEqual(["updated"])
167
- expect(registry.snapshot()).toHaveLength(1)
168
- await new Promise((r) => setTimeout(r, 60))
169
- expect(events.map((e) => e.type)).toEqual(["updated", "removed"])
170
- expect(registry.snapshot()).toEqual([])
171
- })
172
-
173
- test("exitedTtlMs: prune cancelled if phase moves away from exited before TTL", async () => {
174
- const registry = createPtyInstanceRegistry({ coalesceMs: 0, exitedTtlMs: 30 })
175
- registry.upsert("c1", baseline({ phase: "exited", exitedAt: 1_000 }))
176
- registry.upsert("c1", { phase: "ready", exitedAt: null })
177
- await new Promise((r) => setTimeout(r, 60))
178
- expect(registry.snapshot()).toHaveLength(1)
179
- expect(registry.snapshot()[0]!.phase).toBe("ready")
180
- })
181
-
182
- test("exitedTtlMs: 0 disables auto-prune", async () => {
183
- const registry = createPtyInstanceRegistry({ coalesceMs: 0, exitedTtlMs: 0 })
184
- registry.upsert("c1", baseline({ phase: "exited", exitedAt: 1_000 }))
185
- await new Promise((r) => setTimeout(r, 30))
186
- expect(registry.snapshot()).toHaveLength(1)
187
- })
188
-
189
- test("exitedTtlMs: manual remove cancels pending prune", async () => {
190
- const registry = createPtyInstanceRegistry({ coalesceMs: 0, exitedTtlMs: 30 })
191
- registry.upsert("c1", baseline({ phase: "exited", exitedAt: 1_000 }))
192
- const events: PtyInstanceDelta[] = []
193
- registry.subscribe((d) => events.push(d))
194
- registry.remove("c1")
195
- await new Promise((r) => setTimeout(r, 60))
196
- expect(events.map((e) => e.type)).toEqual(["removed"])
197
- })
198
-
199
- test("subscribe replay seeds listener with current state", () => {
200
- const registry = createPtyInstanceRegistry({ coalesceMs: 0 })
201
- registry.upsert("c1", baseline())
202
- registry.upsert("c2", baseline({ phase: "ready" }))
203
- const events: PtyInstanceDelta[] = []
204
- registry.subscribe((d) => events.push(d), { replay: true })
205
- expect(events).toHaveLength(2)
206
- expect(events.every((e) => e.type === "added")).toBe(true)
207
- })
208
- })
@@ -1,172 +0,0 @@
1
- import type { PtyInstanceDelta, PtyInstanceState } from "../../shared/pty-instance"
2
-
3
- export type { PtyInstanceDelta, PtyInstanceState }
4
-
5
- export type PtyInstanceListener = (delta: PtyInstanceDelta) => void
6
-
7
- export interface PtyInstanceSubscribeOptions {
8
- replay?: boolean
9
- }
10
-
11
- export interface PtyInstanceRegistry {
12
- snapshot(): PtyInstanceState[]
13
- subscribe(listener: PtyInstanceListener, options?: PtyInstanceSubscribeOptions): () => void
14
- upsert(chatId: string, patch: Partial<Omit<PtyInstanceState, "chatId">>): void
15
- markExitedIfCurrent(
16
- chatId: string,
17
- pid: number,
18
- patch: Partial<Omit<PtyInstanceState, "chatId">>,
19
- ): void
20
- remove(chatId: string): void
21
- }
22
-
23
- export interface CreatePtyInstanceRegistryOptions {
24
- coalesceMs?: number
25
- exitedTtlMs?: number
26
- }
27
-
28
- const DEFAULT_COALESCE_MS = 100
29
- const DEFAULT_EXITED_TTL_MS = 60_000
30
-
31
- export function createPtyInstanceRegistry(
32
- options: CreatePtyInstanceRegistryOptions = {},
33
- ): PtyInstanceRegistry {
34
- const coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS
35
- const exitedTtlMs = options.exitedTtlMs ?? DEFAULT_EXITED_TTL_MS
36
- const states = new Map<string, PtyInstanceState>()
37
- const listeners = new Set<PtyInstanceListener>()
38
- const pendingFlushes = new Map<string, ReturnType<typeof setTimeout>>()
39
- const exitedPrunes = new Map<string, ReturnType<typeof setTimeout>>()
40
-
41
- function emit(delta: PtyInstanceDelta): void {
42
- for (const listener of listeners) listener(delta)
43
- }
44
-
45
- function clone(state: PtyInstanceState): PtyInstanceState {
46
- return { ...state }
47
- }
48
-
49
- function cancelPendingFlush(chatId: string): void {
50
- const handle = pendingFlushes.get(chatId)
51
- if (handle) {
52
- clearTimeout(handle)
53
- pendingFlushes.delete(chatId)
54
- }
55
- }
56
-
57
- function cancelExitedPrune(chatId: string): void {
58
- const handle = exitedPrunes.get(chatId)
59
- if (handle) {
60
- clearTimeout(handle)
61
- exitedPrunes.delete(chatId)
62
- }
63
- }
64
-
65
- function scheduleExitedPrune(chatId: string): void {
66
- if (exitedTtlMs <= 0) return
67
- cancelExitedPrune(chatId)
68
- exitedPrunes.set(
69
- chatId,
70
- setTimeout(() => {
71
- exitedPrunes.delete(chatId)
72
- removeInternal(chatId)
73
- }, exitedTtlMs),
74
- )
75
- }
76
-
77
- function reconcileExitedTimer(chatId: string, phase: PtyInstanceState["phase"]): void {
78
- if (phase === "exited") scheduleExitedPrune(chatId)
79
- else cancelExitedPrune(chatId)
80
- }
81
-
82
- function flushUpdate(chatId: string): void {
83
- pendingFlushes.delete(chatId)
84
- const state = states.get(chatId)
85
- if (!state) return
86
- emit({ type: "updated", instance: clone(state) })
87
- }
88
-
89
- function removeInternal(chatId: string): void {
90
- if (!states.has(chatId)) return
91
- cancelPendingFlush(chatId)
92
- cancelExitedPrune(chatId)
93
- states.delete(chatId)
94
- emit({ type: "removed", chatId })
95
- }
96
-
97
- return {
98
- snapshot(): PtyInstanceState[] {
99
- return Array.from(states.values(), clone)
100
- },
101
-
102
- subscribe(listener, opts): () => void {
103
- listeners.add(listener)
104
- if (opts?.replay) {
105
- for (const state of states.values()) {
106
- listener({ type: "added", instance: clone(state) })
107
- }
108
- }
109
- return () => {
110
- listeners.delete(listener)
111
- }
112
- },
113
-
114
- upsert(chatId, patch): void {
115
- const existing = states.get(chatId)
116
- if (existing) {
117
- const next: PtyInstanceState = { ...existing, ...patch, chatId }
118
- states.set(chatId, next)
119
- reconcileExitedTimer(chatId, next.phase)
120
- if (coalesceMs <= 0) {
121
- emit({ type: "updated", instance: clone(next) })
122
- return
123
- }
124
- if (!pendingFlushes.has(chatId)) {
125
- pendingFlushes.set(
126
- chatId,
127
- setTimeout(() => flushUpdate(chatId), coalesceMs),
128
- )
129
- }
130
- return
131
- }
132
- const baseline: PtyInstanceState = {
133
- chatId,
134
- sessionId: null,
135
- pid: null,
136
- cwd: "",
137
- model: "",
138
- accountLabel: null,
139
- oauthMasked: null,
140
- phase: "spawning",
141
- startedAt: 0,
142
- lastEventAt: 0,
143
- turnCount: 0,
144
- tokensIn: 0,
145
- tokensOut: 0,
146
- planMode: null,
147
- smokeTest: null,
148
- outputRingTail: null,
149
- exitedAt: null,
150
- exitCode: null,
151
- rssBytes: null,
152
- rssPeakBytes: null,
153
- cpuPercent: null,
154
- cpuPeakPercent: null,
155
- ...patch,
156
- }
157
- states.set(chatId, baseline)
158
- reconcileExitedTimer(chatId, baseline.phase)
159
- emit({ type: "added", instance: clone(baseline) })
160
- },
161
-
162
- markExitedIfCurrent(chatId, pid, patch): void {
163
- const existing = states.get(chatId)
164
- if (!existing || existing.pid !== pid) return
165
- this.upsert(chatId, patch)
166
- },
167
-
168
- remove(chatId): void {
169
- removeInternal(chatId)
170
- },
171
- }
172
- }