@cuongtran001/kanna 1.3.0 → 1.4.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.
Files changed (200) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-BHj370W7.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
  5. package/dist/client/assets/channel-BP9IMEvd.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
  27. package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
  28. package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
  29. package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
  34. package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.test.ts +22 -6
  55. package/src/server/agent.ts +35 -4987
  56. package/src/server/chat-op-log.test.ts +63 -0
  57. package/src/server/chat-op-log.ts +71 -0
  58. package/src/server/chat-ops-diff.test.ts +120 -0
  59. package/src/server/chat-ops-diff.ts +97 -0
  60. package/src/server/chat-ops-parity.test.ts +140 -0
  61. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  62. package/src/server/claude-autocontinue-commands.ts +256 -0
  63. package/src/server/claude-cancel-handler.test.ts +472 -0
  64. package/src/server/claude-cancel-handler.ts +241 -0
  65. package/src/server/claude-chat-management.test.ts +395 -0
  66. package/src/server/claude-chat-management.ts +251 -0
  67. package/src/server/claude-harness-stream.test.ts +273 -0
  68. package/src/server/claude-harness-stream.ts +158 -0
  69. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  70. package/src/server/claude-loop-orch-commands.ts +466 -0
  71. package/src/server/claude-message-normalizer.test.ts +213 -0
  72. package/src/server/claude-message-normalizer.ts +436 -0
  73. package/src/server/claude-prompt-helpers.test.ts +248 -0
  74. package/src/server/claude-prompt-helpers.ts +139 -0
  75. package/src/server/claude-sdk-queue.test.ts +169 -0
  76. package/src/server/claude-sdk-queue.ts +82 -0
  77. package/src/server/claude-send-command.test.ts +414 -0
  78. package/src/server/claude-send-command.ts +420 -0
  79. package/src/server/claude-session-config-helpers.test.ts +279 -0
  80. package/src/server/claude-session-config-helpers.ts +151 -0
  81. package/src/server/claude-session-config.test.ts +250 -0
  82. package/src/server/claude-session-config.ts +159 -0
  83. package/src/server/claude-session-error-handler.test.ts +498 -0
  84. package/src/server/claude-session-error-handler.ts +361 -0
  85. package/src/server/claude-session-lifecycle.test.ts +669 -0
  86. package/src/server/claude-session-lifecycle.ts +283 -0
  87. package/src/server/claude-session-rebuild.test.ts +224 -0
  88. package/src/server/claude-session-rebuild.ts +101 -0
  89. package/src/server/claude-session-runner.test.ts +523 -0
  90. package/src/server/claude-session-runner.ts +521 -0
  91. package/src/server/claude-session-spawner.test.ts +401 -0
  92. package/src/server/claude-session-spawner.ts +401 -0
  93. package/src/server/claude-session-start.test.ts +205 -0
  94. package/src/server/claude-session-start.ts +277 -0
  95. package/src/server/claude-session-state-queries.test.ts +344 -0
  96. package/src/server/claude-session-state-queries.ts +160 -0
  97. package/src/server/claude-session-state.ts +104 -0
  98. package/src/server/claude-slash-commands.test.ts +277 -0
  99. package/src/server/claude-slash-commands.ts +234 -0
  100. package/src/server/claude-spawn-helpers.test.ts +221 -0
  101. package/src/server/claude-spawn-helpers.ts +179 -0
  102. package/src/server/claude-steer-log.test.ts +243 -0
  103. package/src/server/claude-steer-log.ts +58 -0
  104. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  105. package/src/server/claude-subagent-tool-response.ts +165 -0
  106. package/src/server/claude-subagent-wiring.test.ts +263 -0
  107. package/src/server/claude-subagent-wiring.ts +324 -0
  108. package/src/server/claude-tool-respond.test.ts +321 -0
  109. package/src/server/claude-tool-respond.ts +142 -0
  110. package/src/server/claude-turn-runner.test.ts +310 -0
  111. package/src/server/claude-turn-runner.ts +228 -0
  112. package/src/server/claude-turn-starter.test.ts +266 -0
  113. package/src/server/claude-turn-starter.ts +566 -0
  114. package/src/server/claude-usage-math.test.ts +169 -0
  115. package/src/server/claude-usage-math.ts +132 -0
  116. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  117. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  118. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  119. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  120. package/src/server/diff-store-errors.ts +85 -0
  121. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  122. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  123. package/src/server/diff-store-io.adapter.ts +27 -0
  124. package/src/server/diff-store-parse.test.ts +212 -0
  125. package/src/server/diff-store-parse.ts +122 -0
  126. package/src/server/diff-store-state.ts +82 -0
  127. package/src/server/diff-store.ts +76 -1854
  128. package/src/server/event-store-apply.test.ts +280 -0
  129. package/src/server/event-store-apply.ts +132 -0
  130. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  131. package/src/server/event-store-chat-lifecycle.ts +441 -0
  132. package/src/server/event-store-entity-write.ts +356 -0
  133. package/src/server/event-store-helpers.test.ts +230 -0
  134. package/src/server/event-store-helpers.ts +235 -0
  135. package/src/server/event-store-init.ts +276 -0
  136. package/src/server/event-store-messages.adapter.test.ts +532 -0
  137. package/src/server/event-store-messages.adapter.ts +455 -0
  138. package/src/server/event-store-orch-subscription.ts +124 -0
  139. package/src/server/event-store-orch.test.ts +462 -0
  140. package/src/server/event-store-orch.ts +312 -0
  141. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  142. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  143. package/src/server/event-store-snapshot.test.ts +597 -0
  144. package/src/server/event-store-snapshot.ts +658 -0
  145. package/src/server/event-store-subagent.test.ts +408 -0
  146. package/src/server/event-store-subagent.ts +198 -0
  147. package/src/server/event-store-tool-requests.test.ts +210 -0
  148. package/src/server/event-store-tool-requests.ts +131 -0
  149. package/src/server/event-store-transcript-write.adapter.ts +291 -0
  150. package/src/server/event-store-write-ops.test.ts +554 -0
  151. package/src/server/event-store-write-ops.ts +529 -0
  152. package/src/server/event-store.test.ts +75 -0
  153. package/src/server/event-store.ts +257 -2285
  154. package/src/server/events.ts +1 -1
  155. package/src/server/harness-types.ts +15 -1
  156. package/src/server/oauth-errors.ts +19 -0
  157. package/src/server/storage/backend.ts +7 -0
  158. package/src/server/storage/fs-storage.adapter.ts +21 -1
  159. package/src/server/storage/in-memory-storage.ts +12 -0
  160. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  161. package/src/server/ws-router-agent-ctrl.ts +137 -0
  162. package/src/server/ws-router-broadcast.ts +560 -0
  163. package/src/server/ws-router-chat.test.ts +461 -0
  164. package/src/server/ws-router-chat.ts +279 -0
  165. package/src/server/ws-router-defaults.test.ts +283 -0
  166. package/src/server/ws-router-defaults.ts +434 -0
  167. package/src/server/ws-router-diff.test.ts +167 -0
  168. package/src/server/ws-router-diff.ts +250 -0
  169. package/src/server/ws-router-envelope.ts +360 -0
  170. package/src/server/ws-router-misc.test.ts +422 -0
  171. package/src/server/ws-router-misc.ts +265 -0
  172. package/src/server/ws-router-orch.test.ts +190 -0
  173. package/src/server/ws-router-orch.ts +93 -0
  174. package/src/server/ws-router-project.test.ts +403 -0
  175. package/src/server/ws-router-project.ts +256 -0
  176. package/src/server/ws-router-push.test.ts +193 -0
  177. package/src/server/ws-router-push.ts +120 -0
  178. package/src/server/ws-router-settings.test.ts +173 -0
  179. package/src/server/ws-router-settings.ts +416 -0
  180. package/src/server/ws-router-skills.test.ts +194 -0
  181. package/src/server/ws-router-skills.ts +204 -0
  182. package/src/server/ws-router-utils.ts +238 -0
  183. package/src/server/ws-router.test.ts +90 -0
  184. package/src/server/ws-router.ts +340 -2254
  185. package/src/shared/app-settings-types.ts +365 -0
  186. package/src/shared/chat-ops.test.ts +138 -0
  187. package/src/shared/chat-ops.ts +71 -0
  188. package/src/shared/core-types.ts +27 -0
  189. package/src/shared/git-diff-types.ts +165 -0
  190. package/src/shared/mcp-types.ts +105 -0
  191. package/src/shared/protocol.ts +3 -2
  192. package/src/shared/provider-model-types.ts +437 -0
  193. package/src/shared/subagent-types.ts +198 -0
  194. package/src/shared/tool-call-types.ts +314 -0
  195. package/src/shared/transcript-types.ts +270 -0
  196. package/src/shared/types.ts +31 -1711
  197. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  198. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  199. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  200. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,173 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import { handleSettingsCommand, isSubagentValidationError } from "./ws-router-settings"
3
+ import type { SettingsCommandDeps } from "./ws-router-settings"
4
+ import type { AppSettingsSnapshot, Subagent, SubagentValidationError } from "../shared/types"
5
+ import type { ClientCommand } from "../shared/protocol"
6
+ import {
7
+ AUTH_DEFAULTS,
8
+ CLAUDE_AUTH_DEFAULTS,
9
+ CLAUDE_DRIVER_DEFAULTS,
10
+ CLAUDE_PTY_LIFECYCLE_DEFAULTS,
11
+ CLOUDFLARE_TUNNEL_DEFAULTS,
12
+ DEFAULT_OPENROUTER_SDK_MODEL,
13
+ UPLOAD_DEFAULTS,
14
+ } from "../shared/types"
15
+ import { KeybindingsManager } from "./keybindings"
16
+ import { mkdtemp, rm } from "node:fs/promises"
17
+ import { tmpdir } from "node:os"
18
+ import path from "node:path"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function makeSnapshot(): AppSettingsSnapshot {
25
+ return {
26
+ analyticsEnabled: true,
27
+ browserSettingsMigrated: false,
28
+ theme: "system",
29
+ chatSoundPreference: "always",
30
+ chatSoundId: "funk",
31
+ terminal: { scrollbackLines: 1_000, minColumnWidth: 450 },
32
+ editor: { preset: "cursor", commandTemplate: "cursor {path}" },
33
+ defaultProvider: "last_used",
34
+ providerDefaults: {
35
+ claude: { model: "claude-opus-4-7", modelOptions: { reasoningEffort: "high", contextWindow: "200k" }, planMode: false },
36
+ codex: { model: "gpt-5.5", modelOptions: { reasoningEffort: "high", fastMode: false }, planMode: false },
37
+ openrouter: { model: DEFAULT_OPENROUTER_SDK_MODEL, modelOptions: {}, planMode: false },
38
+ },
39
+ warning: null,
40
+ filePathDisplay: "~/.kanna/data/settings.json",
41
+ cloudflareTunnel: CLOUDFLARE_TUNNEL_DEFAULTS,
42
+ auth: AUTH_DEFAULTS,
43
+ claudeAuth: CLAUDE_AUTH_DEFAULTS,
44
+ uploads: UPLOAD_DEFAULTS,
45
+ subagents: [],
46
+ customMcpServers: [],
47
+ customModels: [],
48
+ textSnippets: [],
49
+ claudeDriver: { ...CLAUDE_DRIVER_DEFAULTS, lifecycle: { ...CLAUDE_PTY_LIFECYCLE_DEFAULTS } },
50
+ globalPromptAppend: "",
51
+ shareDefaultTtlHours: 24,
52
+ subagentRuntime: { runTimeoutMs: 600_000, defaultLoopSubagentId: null },
53
+ }
54
+ }
55
+
56
+ async function makeDeps(tmpDir: string): Promise<SettingsCommandDeps & { sent: unknown[] }> {
57
+ const kb = new KeybindingsManager(path.join(tmpDir, "keybindings.json"))
58
+ const snapshot = makeSnapshot()
59
+ const sent: unknown[] = []
60
+
61
+ return {
62
+ keybindings: kb,
63
+ resolvedAppSettings: {
64
+ getSnapshot: () => snapshot,
65
+ write: async (v) => ({ ...snapshot, analyticsEnabled: v.analyticsEnabled }),
66
+ writePatch: async () => snapshot,
67
+ setCloudflareTunnel: async () => snapshot,
68
+ setClaudeAuth: async () => snapshot,
69
+ createSubagent: async () => ({ id: "s1", name: "Test" }) as unknown as Subagent,
70
+ updateSubagent: async () => ({ id: "s1", name: "Test" }) as unknown as Subagent,
71
+ deleteSubagent: async () => {},
72
+ },
73
+ resolvedAnalytics: { track: mock(() => {}) },
74
+ resolvedLlmProvider: {
75
+ read: async () => ({ provider: "openai" as const, apiKey: "", model: "gpt-5.4-mini", baseUrl: "", resolvedBaseUrl: "https://api.openai.com/v1", enabled: false, warning: null, filePathDisplay: "~/.kanna/llm-provider.json" }),
76
+ write: async (v) => ({ ...v, resolvedBaseUrl: "https://api.openai.com/v1", enabled: false, warning: null, filePathDisplay: "~/.kanna/llm-provider.json" }),
77
+ validate: async () => ({ ok: false, error: { type: "config_error" as const, message: "stub" } }),
78
+ },
79
+ listOpenRouterModels: undefined,
80
+ send: (envelope) => { sent.push(envelope) },
81
+ sent,
82
+ }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // isSubagentValidationError
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("isSubagentValidationError", () => {
90
+ test("returns true for validation error shape", () => {
91
+ const err: SubagentValidationError = { code: "EMPTY_NAME", message: "name is empty" }
92
+ expect(isSubagentValidationError(err)).toBe(true)
93
+ })
94
+
95
+ test("returns false for Subagent shape", () => {
96
+ const subagent = { id: "s1", name: "Test", instructions: "" } as unknown as Subagent
97
+ expect(isSubagentValidationError(subagent)).toBe(false)
98
+ })
99
+ })
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // handleSettingsCommand — selected cases
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("handleSettingsCommand", () => {
106
+ test("settings.readKeybindings → acks with keybindings snapshot", async () => {
107
+ const tmpD = await mkdtemp(path.join(tmpdir(), "ws-settings-test-"))
108
+ try {
109
+ const deps = await makeDeps(tmpD)
110
+ const handled = await handleSettingsCommand(deps, { type: "settings.readKeybindings" }, "req-1")
111
+ expect(handled).toBe(true)
112
+ expect(deps.sent).toHaveLength(1)
113
+ const ack = deps.sent[0] as { type: string; id: string }
114
+ expect(ack.type).toBe("ack")
115
+ expect(ack.id).toBe("req-1")
116
+ } finally {
117
+ await rm(tmpD, { recursive: true, force: true })
118
+ }
119
+ })
120
+
121
+ test("settings.readAppSettings → acks with app-settings snapshot", async () => {
122
+ const tmpD = await mkdtemp(path.join(tmpdir(), "ws-settings-test-"))
123
+ try {
124
+ const deps = await makeDeps(tmpD)
125
+ const handled = await handleSettingsCommand(deps, { type: "settings.readAppSettings" }, "req-2")
126
+ expect(handled).toBe(true)
127
+ expect(deps.sent).toHaveLength(1)
128
+ const ack = deps.sent[0] as { type: string; result: AppSettingsSnapshot }
129
+ expect(ack.result.analyticsEnabled).toBe(true)
130
+ } finally {
131
+ await rm(tmpD, { recursive: true, force: true })
132
+ }
133
+ })
134
+
135
+ test("subagent.delete → acks ok", async () => {
136
+ const tmpD = await mkdtemp(path.join(tmpdir(), "ws-settings-test-"))
137
+ try {
138
+ const deps = await makeDeps(tmpD)
139
+ const handled = await handleSettingsCommand(deps, { type: "subagent.delete", id: "s1" }, "req-3")
140
+ expect(handled).toBe(true)
141
+ const ack = deps.sent[0] as { result: { ok: boolean } }
142
+ expect(ack.result.ok).toBe(true)
143
+ } finally {
144
+ await rm(tmpD, { recursive: true, force: true })
145
+ }
146
+ })
147
+
148
+ test("skills.listInstalled → acks with installed-skills snapshot", async () => {
149
+ const tmpD = await mkdtemp(path.join(tmpdir(), "ws-settings-test-"))
150
+ try {
151
+ const deps = await makeDeps(tmpD)
152
+ const handled = await handleSettingsCommand(deps, { type: "skills.listInstalled" }, "req-4")
153
+ expect(handled).toBe(true)
154
+ const ack = deps.sent[0] as { result: { skills: unknown[] } }
155
+ expect(Array.isArray(ack.result.skills)).toBe(true)
156
+ } finally {
157
+ await rm(tmpD, { recursive: true, force: true })
158
+ }
159
+ })
160
+
161
+ test("unrecognized command type → returns false", async () => {
162
+ const tmpD = await mkdtemp(path.join(tmpdir(), "ws-settings-test-"))
163
+ try {
164
+ const deps = await makeDeps(tmpD)
165
+ // Cast via unknown to bypass TS narrowing — simulates an unknown command type
166
+ const handled = await handleSettingsCommand(deps, { type: "chat.create" } as unknown as ClientCommand, "req-5")
167
+ expect(handled).toBe(false)
168
+ expect(deps.sent).toHaveLength(0)
169
+ } finally {
170
+ await rm(tmpD, { recursive: true, force: true })
171
+ }
172
+ })
173
+ })
@@ -0,0 +1,416 @@
1
+ /**
2
+ * ws-router-settings.ts
3
+ *
4
+ * Command handlers for the settings, subagent, MCP, LLM-provider, and
5
+ * skills command groups extracted from ws-router.ts. No closure
6
+ * dependencies on createWsRouter's local variables — every dep is injected
7
+ * via SettingsCommandDeps, making these handlers testable in isolation.
8
+ *
9
+ * Covers: settings.*, appSettings.*, subagent.*, settings.testMcpServer,
10
+ * settings.startMcpOAuth, settings.completeMcpOAuth, settings.*LlmProvider,
11
+ * settings.getChangelog, skills.*
12
+ */
13
+ import { PROTOCOL_VERSION } from "../shared/types"
14
+ import type {
15
+ AppSettingsPatch,
16
+ AppSettingsSnapshot,
17
+ LlmProviderSnapshot,
18
+ LlmProviderValidationResult,
19
+ McpServerConfig,
20
+ OpenRouterModel,
21
+ Subagent,
22
+ SubagentInput,
23
+ SubagentPatch,
24
+ SubagentValidationError,
25
+ } from "../shared/types"
26
+ import type { ClientCommand, ServerEnvelope } from "../shared/protocol"
27
+ import type { AnalyticsReporter } from "./analytics"
28
+ import { KeybindingsManager } from "./keybindings"
29
+ import { validateMcpServer } from "./mcp-validator"
30
+ import { startMcpOAuth, completeMcpOAuth, ensureFreshMcpToken } from "./mcp-oauth.adapter"
31
+ import { fetchGitHubReleases } from "./diff-store"
32
+ import { log } from "../shared/log"
33
+ import {
34
+ searchSkills,
35
+ installSkill,
36
+ uninstallSkill,
37
+ listInstalledSkills,
38
+ } from "./ws-router-skills"
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Dep interfaces (duck-typed; avoids circular imports with ws-router.ts)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export interface ResolvedAppSettings {
45
+ getSnapshot(): AppSettingsSnapshot
46
+ write(value: { analyticsEnabled: boolean }): Promise<AppSettingsSnapshot>
47
+ writePatch(patch: AppSettingsPatch): Promise<AppSettingsSnapshot>
48
+ setCloudflareTunnel(patch: Partial<AppSettingsSnapshot["cloudflareTunnel"]>): Promise<AppSettingsSnapshot>
49
+ setClaudeAuth(patch: Partial<AppSettingsSnapshot["claudeAuth"]>): Promise<AppSettingsSnapshot>
50
+ createSubagent(input: SubagentInput): Promise<Subagent | SubagentValidationError>
51
+ updateSubagent(id: string, patch: SubagentPatch): Promise<Subagent | SubagentValidationError>
52
+ deleteSubagent(id: string): Promise<void>
53
+ }
54
+
55
+ export interface ResolvedLlmProvider {
56
+ read(): Promise<LlmProviderSnapshot>
57
+ write(value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">): Promise<LlmProviderSnapshot>
58
+ validate(value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">): Promise<LlmProviderValidationResult>
59
+ }
60
+
61
+ export interface SettingsCommandDeps {
62
+ keybindings: KeybindingsManager
63
+ resolvedAppSettings: ResolvedAppSettings
64
+ resolvedAnalytics: Pick<AnalyticsReporter, "track">
65
+ resolvedLlmProvider: ResolvedLlmProvider
66
+ listOpenRouterModels: (() => Promise<OpenRouterModel[]>) | undefined
67
+ /** Pre-bound to the current WebSocket; called to ack the command. */
68
+ send: (envelope: ServerEnvelope) => void
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Internal helpers (previously at the bottom of ws-router.ts)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export function isSubagentValidationError(
76
+ value: Subagent | SubagentValidationError,
77
+ ): value is SubagentValidationError {
78
+ return "code" in value && "message" in value
79
+ }
80
+
81
+ export async function testOAuthToken(token: string): Promise<{ ok: boolean; error: string | null }> {
82
+ const trimmed = typeof token === "string" ? token.trim() : ""
83
+ if (!trimmed) return { ok: false, error: "Token is empty" }
84
+ try {
85
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
86
+ method: "POST",
87
+ headers: {
88
+ "anthropic-version": "2023-06-01",
89
+ "content-type": "application/json",
90
+ "authorization": `Bearer ${trimmed}`,
91
+ },
92
+ body: JSON.stringify({
93
+ model: "claude-haiku-4-5-20251001",
94
+ max_tokens: 1,
95
+ messages: [{ role: "user", content: "ok" }],
96
+ }),
97
+ signal: AbortSignal.timeout(10_000),
98
+ })
99
+ if (res.status === 401 || res.status === 403) return { ok: false, error: "Unauthorized" }
100
+ if (res.status === 429) return { ok: true, error: "Token valid but currently rate-limited" }
101
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
102
+ return { ok: true, error: null }
103
+ } catch (err) {
104
+ if (err instanceof Error && err.name === "TimeoutError") {
105
+ return { ok: false, error: "Request timed out after 10s" }
106
+ }
107
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * For an OAuth-authenticated network server, resolve a fresh access token to
113
+ * inject as a Bearer when probing it — the manual "Test" / auto-test path is
114
+ * otherwise tokenless and a healthy OAuth server 401s. Returns undefined for
115
+ * stdio, static-header, or not-yet-authenticated servers (the probe runs with
116
+ * stored headers only). A refresh failure also yields undefined, so the probe
117
+ * surfaces the unauthorized error that correctly signals re-auth is needed.
118
+ */
119
+ export async function resolveMcpTestBearer(
120
+ entry: McpServerConfig,
121
+ appSettings: { writePatch(p: AppSettingsPatch): Promise<unknown> },
122
+ ): Promise<string | undefined> {
123
+ if (entry.transport === "stdio" || entry.oauth?.status !== "authenticated") return undefined
124
+ try {
125
+ return await ensureFreshMcpToken(entry, {
126
+ persist: (oauth) =>
127
+ void appSettings.writePatch({ customMcpServers: { setOAuthState: { id: entry.id, oauth } } }),
128
+ })
129
+ } catch {
130
+ return undefined
131
+ }
132
+ }
133
+
134
+ export async function runMcpAutoTest(
135
+ id: string,
136
+ appSettings: { getSnapshot(): AppSettingsSnapshot; writePatch(p: AppSettingsPatch): Promise<unknown> },
137
+ ): Promise<void> {
138
+ try {
139
+ const entry = appSettings.getSnapshot().customMcpServers.find((s) => s.id === id)
140
+ if (!entry) return
141
+ await appSettings.writePatch({
142
+ customMcpServers: {
143
+ setTestResult: { id, result: { status: "pending", startedAt: new Date().toISOString() } },
144
+ },
145
+ })
146
+ const bearer = await resolveMcpTestBearer(entry, appSettings)
147
+ const result = await validateMcpServer(entry, bearer ? { bearer } : {})
148
+ await appSettings.writePatch({ customMcpServers: { setTestResult: { id, result } } })
149
+ } catch (err) {
150
+ // Auto-test must never throw; log + swallow.
151
+ log.warn("[kanna/ws-router] runMcpAutoTest failed", String(err))
152
+ }
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Command dispatcher
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Handle one settings-domain WS command.
161
+ *
162
+ * Returns `true` when the command was handled (caller should `return`).
163
+ * Returns `false` when the command type falls outside this module's scope
164
+ * (should not happen in practice once all cases are wired up, but keeps the
165
+ * type-system honest).
166
+ */
167
+ export async function handleSettingsCommand(
168
+ deps: SettingsCommandDeps,
169
+ command: ClientCommand,
170
+ id: string,
171
+ ): Promise<boolean> {
172
+ const { keybindings, resolvedAppSettings, resolvedAnalytics, resolvedLlmProvider, listOpenRouterModels, send } = deps
173
+
174
+ switch (command.type) {
175
+ case "settings.readKeybindings": {
176
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: keybindings.getSnapshot() })
177
+ return true
178
+ }
179
+ case "settings.writeKeybindings": {
180
+ const snapshot = await keybindings.write(command.bindings)
181
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
182
+ return true
183
+ }
184
+ case "settings.readAppSettings": {
185
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: resolvedAppSettings.getSnapshot() })
186
+ return true
187
+ }
188
+ case "settings.writeAppSettings": {
189
+ const previousAnalyticsEnabled = resolvedAppSettings.getSnapshot().analyticsEnabled
190
+ if (previousAnalyticsEnabled && !command.analyticsEnabled) {
191
+ resolvedAnalytics.track("analytics_disabled")
192
+ }
193
+ const snapshot = await resolvedAppSettings.write({ analyticsEnabled: command.analyticsEnabled })
194
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
195
+ if (!previousAnalyticsEnabled && command.analyticsEnabled) {
196
+ resolvedAnalytics.track("analytics_enabled")
197
+ }
198
+ return true
199
+ }
200
+ case "appSettings.setCloudflareTunnel": {
201
+ await resolvedAppSettings.setCloudflareTunnel(command.patch)
202
+ const snapshot = resolvedAppSettings.getSnapshot()
203
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
204
+ return true
205
+ }
206
+ case "appSettings.setClaudeAuth": {
207
+ await resolvedAppSettings.setClaudeAuth(command.patch)
208
+ const snapshot = resolvedAppSettings.getSnapshot()
209
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
210
+ return true
211
+ }
212
+ case "appSettings.testOAuthToken": {
213
+ const result = await testOAuthToken(command.token)
214
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
215
+ return true
216
+ }
217
+ case "settings.writeAppSettingsPatch": {
218
+ const previousAnalyticsEnabled = resolvedAppSettings.getSnapshot().analyticsEnabled
219
+ const snapshot = await resolvedAppSettings.writePatch(command.patch)
220
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
221
+
222
+ // Fire-and-forget auto-test for newly created or updated MCP server.
223
+ const targetId = (() => {
224
+ const ops = command.patch.customMcpServers
225
+ if (!ops) return null
226
+ if (ops.update) return ops.update.id
227
+ if (ops.create) {
228
+ // The created entry is the one with no prior match by name —
229
+ // simplest: pick the entry with the latest createdAt.
230
+ const list = snapshot.customMcpServers
231
+ if (list.length === 0) return null
232
+ return list.reduce((latest, e) => (e.createdAt > latest.createdAt ? e : latest), list[0]!).id
233
+ }
234
+ return null
235
+ })()
236
+ if (targetId) {
237
+ void runMcpAutoTest(targetId, resolvedAppSettings)
238
+ }
239
+
240
+ if (command.patch.analyticsEnabled !== undefined && previousAnalyticsEnabled && !snapshot.analyticsEnabled) {
241
+ resolvedAnalytics.track("analytics_disabled")
242
+ }
243
+ if (command.patch.analyticsEnabled !== undefined && !previousAnalyticsEnabled && snapshot.analyticsEnabled) {
244
+ resolvedAnalytics.track("analytics_enabled")
245
+ }
246
+ return true
247
+ }
248
+ case "subagent.create": {
249
+ const result = await resolvedAppSettings.createSubagent(command.input)
250
+ send({
251
+ v: PROTOCOL_VERSION,
252
+ type: "ack",
253
+ id,
254
+ result: isSubagentValidationError(result)
255
+ ? { ok: false, error: result }
256
+ : { ok: true, subagent: result },
257
+ })
258
+ return true
259
+ }
260
+ case "subagent.update": {
261
+ const result = await resolvedAppSettings.updateSubagent(command.id, command.patch)
262
+ send({
263
+ v: PROTOCOL_VERSION,
264
+ type: "ack",
265
+ id,
266
+ result: isSubagentValidationError(result)
267
+ ? { ok: false, error: result }
268
+ : { ok: true, subagent: result },
269
+ })
270
+ return true
271
+ }
272
+ case "subagent.delete": {
273
+ await resolvedAppSettings.deleteSubagent(command.id)
274
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { ok: true } })
275
+ return true
276
+ }
277
+ case "settings.testMcpServer": {
278
+ const snapshot = resolvedAppSettings.getSnapshot()
279
+ const entry = snapshot.customMcpServers.find((s) => s.id === command.id)
280
+ if (!entry) {
281
+ send({
282
+ v: PROTOCOL_VERSION,
283
+ type: "ack",
284
+ id,
285
+ result: {
286
+ ok: false,
287
+ message: "MCP server not found",
288
+ lastTest: { status: "error", testedAt: new Date().toISOString(), message: "not found" } as const,
289
+ },
290
+ })
291
+ return true
292
+ }
293
+ // Mark pending so the UI sees a spinner while we connect.
294
+ await resolvedAppSettings.writePatch({
295
+ customMcpServers: {
296
+ setTestResult: { id: entry.id, result: { status: "pending", startedAt: new Date().toISOString() } },
297
+ },
298
+ })
299
+ const testBearer = await resolveMcpTestBearer(entry, resolvedAppSettings)
300
+ const lastTest = await validateMcpServer(entry, testBearer ? { bearer: testBearer } : {})
301
+ await resolvedAppSettings.writePatch({
302
+ customMcpServers: { setTestResult: { id: entry.id, result: lastTest } },
303
+ })
304
+ send({
305
+ v: PROTOCOL_VERSION,
306
+ type: "ack",
307
+ id,
308
+ result: {
309
+ ok: lastTest.status === "ok",
310
+ message: lastTest.status === "error" ? lastTest.message : undefined,
311
+ lastTest,
312
+ },
313
+ })
314
+ return true
315
+ }
316
+ case "settings.startMcpOAuth": {
317
+ const snapshot = resolvedAppSettings.getSnapshot()
318
+ const entry = snapshot.customMcpServers.find((s) => s.id === command.id)
319
+ if (!entry || entry.transport === "stdio") {
320
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: "not found or unsupported transport" } })
321
+ return true
322
+ }
323
+ try {
324
+ const result = await startMcpOAuth(entry, {
325
+ persist: (oauth) => void resolvedAppSettings.writePatch({ customMcpServers: { setOAuthState: { id: entry.id, oauth } } }),
326
+ })
327
+ send({
328
+ v: PROTOCOL_VERSION, type: "ack", id,
329
+ result: result.kind === "authorizationUrl"
330
+ ? { ok: true, authorizationUrl: result.authorizationUrl }
331
+ : { ok: true, alreadyAuthenticated: true },
332
+ })
333
+ } catch (err) {
334
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: err instanceof Error ? err.message : "oauth start failed" } })
335
+ }
336
+ return true
337
+ }
338
+ case "settings.completeMcpOAuth": {
339
+ const snapshot = resolvedAppSettings.getSnapshot()
340
+ const entry = snapshot.customMcpServers.find((s) => s.id === command.id)
341
+ if (!entry || entry.transport === "stdio") {
342
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: "not found" } })
343
+ return true
344
+ }
345
+ try {
346
+ const result = await completeMcpOAuth(entry, command.callbackUrl, {
347
+ persist: (oauth) => void resolvedAppSettings.writePatch({ customMcpServers: { setOAuthState: { id: entry.id, oauth } } }),
348
+ listTools: async (_serverUrl, accessToken) => {
349
+ const r = await validateMcpServer(entry, { bearer: accessToken })
350
+ return r.status === "ok" ? r.toolCount : 0
351
+ },
352
+ })
353
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { ok: true, testResult: result } })
354
+ } catch (err) {
355
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: err instanceof Error ? err.message : "oauth complete failed" } })
356
+ }
357
+ return true
358
+ }
359
+ case "settings.readLlmProvider": {
360
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: await resolvedLlmProvider.read() })
361
+ return true
362
+ }
363
+ case "settings.listOpenRouterModels": {
364
+ const models = listOpenRouterModels ? await listOpenRouterModels() : []
365
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: models })
366
+ return true
367
+ }
368
+ case "settings.getChangelog": {
369
+ const releases = await fetchGitHubReleases("cuongtranba/kanna")
370
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: releases })
371
+ return true
372
+ }
373
+ case "settings.writeLlmProvider": {
374
+ const snapshot = await resolvedLlmProvider.write({
375
+ provider: command.provider,
376
+ apiKey: command.apiKey,
377
+ model: command.model,
378
+ baseUrl: command.baseUrl,
379
+ })
380
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
381
+ return true
382
+ }
383
+ case "settings.validateLlmProvider": {
384
+ const result = await resolvedLlmProvider.validate({
385
+ provider: command.provider,
386
+ apiKey: command.apiKey,
387
+ model: command.model,
388
+ baseUrl: command.baseUrl,
389
+ })
390
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
391
+ return true
392
+ }
393
+ case "skills.search": {
394
+ const snapshot = await searchSkills(command.query, command.limit)
395
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
396
+ return true
397
+ }
398
+ case "skills.install": {
399
+ const result = await installSkill(command.source, command.skillId)
400
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
401
+ return true
402
+ }
403
+ case "skills.uninstall": {
404
+ const result = await uninstallSkill(command.skillId)
405
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
406
+ return true
407
+ }
408
+ case "skills.listInstalled": {
409
+ const result = await listInstalledSkills()
410
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
411
+ return true
412
+ }
413
+ default:
414
+ return false
415
+ }
416
+ }