@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,256 @@
1
+ /**
2
+ * ws-router-project.ts
3
+ *
4
+ * WS command handlers for project management, session import, sidebar ordering,
5
+ * system utilities, and update management:
6
+ * system.ping, system.openExternal,
7
+ * update.check, update.install, update.reload,
8
+ * project.open, project.create, project.remove, project.setStar,
9
+ * project.readDiffPatch,
10
+ * sessions.importClaude,
11
+ * sidebar.reorderProjectGroups
12
+ *
13
+ * Extracted from ws-router.ts.
14
+ */
15
+ import { PROTOCOL_VERSION } from "../shared/types"
16
+ import type { UpdateInstallResult, UpdateSnapshot } from "../shared/types"
17
+ import type { ClientCommand, ServerEnvelope } from "../shared/protocol"
18
+ import type { ImportClaudeSessionsResult } from "./claude-session-importer.adapter"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Dep interfaces (duck-typed; avoids circular imports with ws-router.ts)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** The subset of EventStore consumed by project WS commands. */
25
+ export interface ProjectStoreDep {
26
+ getProject(projectId: string): { id: string; localPath: string } | null | undefined
27
+ openProject(localPath: string, title?: string): Promise<{ id: string }>
28
+ removeProject(projectId: string): Promise<void>
29
+ setProjectStar(projectId: string, starred: boolean): Promise<void>
30
+ setSidebarProjectOrder(projectIds: string[]): Promise<void>
31
+ state: { projectIdsByPath: ReadonlyMap<string, string> }
32
+ }
33
+
34
+ /** The subset of UpdateManager consumed by update WS commands. */
35
+ export interface ProjectUpdateManagerDep {
36
+ checkForUpdates(opts?: { force?: boolean }): Promise<UpdateSnapshot>
37
+ installUpdate(opts?: { version?: string }): Promise<UpdateInstallResult>
38
+ forceReload(): Promise<UpdateInstallResult>
39
+ }
40
+
41
+ /** The subset of DiffStore consumed by project.readDiffPatch. */
42
+ export interface ProjectDiffStoreDep {
43
+ readPatch(args: { projectPath: string; path: string }): Promise<unknown>
44
+ }
45
+
46
+ /** Analytics reporter subset. */
47
+ export interface ProjectAnalyticsDep {
48
+ track(event: string): void
49
+ }
50
+
51
+ /** Terminal manager subset needed to clean up on project removal. */
52
+ export interface ProjectTerminalsDep {
53
+ closeByCwd(cwd: string): void
54
+ }
55
+
56
+ export interface ProjectCommandDeps {
57
+ /** Project-related store methods. */
58
+ store: ProjectStoreDep
59
+ /** Update manager — optional; commands fail gracefully when absent. */
60
+ updateManager?: ProjectUpdateManagerDep | null
61
+ /** Diff store for readPatch. */
62
+ diffStore: ProjectDiffStoreDep
63
+ /** Analytics reporter. */
64
+ analytics: ProjectAnalyticsDep
65
+ /** Re-scans the workspace for new/removed projects. */
66
+ refreshDiscovery: () => Promise<unknown>
67
+ /** Ensures the target directory exists (creates it if needed). */
68
+ ensureProjectDirectory: (path: string) => Promise<void>
69
+ /** Normalizes / resolves a local path string. */
70
+ resolveLocalPath: (path: string) => string
71
+ /**
72
+ * Imports Claude sessions from disk.
73
+ * Caller pre-binds the store so the function signature is simple.
74
+ */
75
+ importClaudeSessionsFn: () => Promise<ImportClaudeSessionsResult>
76
+ /**
77
+ * Opens an external application (editor, Finder, browser …).
78
+ * Caller pre-binds any internal deps.
79
+ */
80
+ openExternalFn: (command: Extract<ClientCommand, { type: "system.openExternal" }>) => Promise<void>
81
+ /** Terminal manager — used to close terminals on project removal. */
82
+ terminals: ProjectTerminalsDep
83
+ /** Pre-bound to the current WebSocket; called to send an ack or push envelope. */
84
+ send: (envelope: ServerEnvelope) => void
85
+ /**
86
+ * Broadcast sidebar + project-list snapshots to all connected clients.
87
+ * Corresponds to `broadcastFilteredSnapshots({ includeSidebar: true })`.
88
+ */
89
+ broadcastSidebar: () => Promise<void>
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Command dispatcher
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Handle one project / session / sidebar / system / update WS command.
98
+ *
99
+ * Returns `true` when the command was handled (caller should `return`).
100
+ * Returns `false` when the command type is outside this module's scope.
101
+ */
102
+ export async function handleProjectCommand(
103
+ deps: ProjectCommandDeps,
104
+ command: ClientCommand,
105
+ id: string,
106
+ ): Promise<boolean> {
107
+ const {
108
+ store,
109
+ updateManager,
110
+ diffStore,
111
+ analytics,
112
+ refreshDiscovery,
113
+ ensureProjectDirectory,
114
+ resolveLocalPath,
115
+ importClaudeSessionsFn,
116
+ openExternalFn,
117
+ terminals,
118
+ send,
119
+ broadcastSidebar,
120
+ } = deps
121
+
122
+ switch (command.type) {
123
+ // -----------------------------------------------------------------------
124
+ // system
125
+ // -----------------------------------------------------------------------
126
+ case "system.ping": {
127
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
128
+ return true
129
+ }
130
+ case "system.openExternal": {
131
+ await openExternalFn(command)
132
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
133
+ return true
134
+ }
135
+
136
+ // -----------------------------------------------------------------------
137
+ // update
138
+ // -----------------------------------------------------------------------
139
+ case "update.check": {
140
+ const unavailableSnapshot: UpdateSnapshot = {
141
+ currentVersion: "unknown",
142
+ latestVersion: null,
143
+ status: "error",
144
+ updateAvailable: false,
145
+ lastCheckedAt: Date.now(),
146
+ error: "Update manager unavailable.",
147
+ installAction: "restart",
148
+ reloadRequestedAt: null,
149
+ }
150
+ const snapshot = updateManager
151
+ ? await updateManager.checkForUpdates({ force: command.force })
152
+ : unavailableSnapshot
153
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
154
+ return true
155
+ }
156
+ case "update.install": {
157
+ if (!updateManager) {
158
+ throw new Error("Update manager unavailable.")
159
+ }
160
+ const result = await updateManager.installUpdate({ version: command.version })
161
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
162
+ return true
163
+ }
164
+ case "update.reload": {
165
+ if (!updateManager) {
166
+ throw new Error("Update manager unavailable.")
167
+ }
168
+ const result = await updateManager.forceReload()
169
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
170
+ return true
171
+ }
172
+
173
+ // -----------------------------------------------------------------------
174
+ // project
175
+ // -----------------------------------------------------------------------
176
+ case "project.open": {
177
+ await ensureProjectDirectory(command.localPath)
178
+ const normalizedPath = resolveLocalPath(command.localPath)
179
+ const existingProjectId = store.state.projectIdsByPath.get(normalizedPath)
180
+ const project = await store.openProject(command.localPath)
181
+ await refreshDiscovery()
182
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id } })
183
+ if (!existingProjectId) {
184
+ analytics.track("project_opened")
185
+ }
186
+ return true
187
+ }
188
+ case "project.create": {
189
+ await ensureProjectDirectory(command.localPath)
190
+ const normalizedPath = resolveLocalPath(command.localPath)
191
+ const existingProjectId = store.state.projectIdsByPath.get(normalizedPath)
192
+ const project = await store.openProject(command.localPath, command.title)
193
+ await refreshDiscovery()
194
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id } })
195
+ if (!existingProjectId) {
196
+ analytics.track("project_opened")
197
+ analytics.track("project_created")
198
+ }
199
+ return true
200
+ }
201
+ case "project.remove": {
202
+ const project = store.getProject(command.projectId)
203
+ await store.removeProject(command.projectId)
204
+ if (project) {
205
+ terminals.closeByCwd(project.localPath)
206
+ }
207
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
208
+ analytics.track("project_removed")
209
+ return true
210
+ }
211
+ case "project.setStar": {
212
+ await store.setProjectStar(command.projectId, command.starred)
213
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
214
+ await broadcastSidebar()
215
+ return true
216
+ }
217
+ case "project.readDiffPatch": {
218
+ const project = store.getProject(command.projectId)
219
+ if (!project) {
220
+ throw new Error("Project not found")
221
+ }
222
+ const result = await diffStore.readPatch({
223
+ projectPath: project.localPath,
224
+ path: command.path,
225
+ })
226
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
227
+ return true
228
+ }
229
+
230
+ // -----------------------------------------------------------------------
231
+ // sessions
232
+ // -----------------------------------------------------------------------
233
+ case "sessions.importClaude": {
234
+ const result = await importClaudeSessionsFn()
235
+ if (result.newProjects > 0) {
236
+ await refreshDiscovery()
237
+ }
238
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
239
+ await broadcastSidebar()
240
+ return true
241
+ }
242
+
243
+ // -----------------------------------------------------------------------
244
+ // sidebar
245
+ // -----------------------------------------------------------------------
246
+ case "sidebar.reorderProjectGroups": {
247
+ await store.setSidebarProjectOrder(command.projectIds)
248
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
249
+ await broadcastSidebar()
250
+ return true
251
+ }
252
+
253
+ default:
254
+ return false
255
+ }
256
+ }
@@ -0,0 +1,193 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import type { PushCommandDeps, PushManagerDep } from "./ws-router-push"
3
+ import { handlePushCommand } from "./ws-router-push"
4
+ import type { ClientCommand } from "../shared/protocol"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function makePushManager(overrides: Partial<PushManagerDep> = {}): PushManagerDep {
11
+ return {
12
+ recordDeviceSeen: mock(async () => {}),
13
+ addSubscription: mock(async () => ({ id: "sub-1" })),
14
+ removeSubscription: mock(async () => {}),
15
+ sendTest: mock(async () => {}),
16
+ setProjectMute: mock(async () => {}),
17
+ setFocusedChat: mock(() => {}),
18
+ ...overrides,
19
+ }
20
+ }
21
+
22
+ interface TestDeps extends PushCommandDeps {
23
+ sent: unknown[]
24
+ broadcasts: Array<{ includePushConfig: boolean }>
25
+ _deviceId: string | null | undefined
26
+ }
27
+
28
+ function makeDeps(
29
+ pushManagerOverrides?: Partial<PushManagerDep>,
30
+ initialDeviceId?: string | null,
31
+ ): TestDeps {
32
+ const sent: unknown[] = []
33
+ const broadcasts: Array<{ includePushConfig: boolean }> = []
34
+ let deviceId: string | null | undefined = initialDeviceId
35
+
36
+ return {
37
+ pushManager: makePushManager(pushManagerOverrides),
38
+ getPushDeviceId: () => deviceId,
39
+ setPushDeviceId: (id) => { deviceId = id },
40
+ send: (envelope) => { sent.push(envelope) },
41
+ broadcastPushConfig: async () => { broadcasts.push({ includePushConfig: true }) },
42
+ sent,
43
+ broadcasts,
44
+ get _deviceId() { return deviceId },
45
+ }
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Unknown command
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe("handlePushCommand", () => {
53
+ test("returns false for a non-push command", async () => {
54
+ const deps = makeDeps()
55
+ const handled = await handlePushCommand(
56
+ deps,
57
+ { type: "settings.readAppSettings" } as unknown as ClientCommand,
58
+ "r0",
59
+ )
60
+ expect(handled).toBe(false)
61
+ expect(deps.sent).toHaveLength(0)
62
+ expect(deps.broadcasts).toHaveLength(0)
63
+ })
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // push.identifyDevice
67
+ // ---------------------------------------------------------------------------
68
+
69
+ test("push.identifyDevice — sets deviceId, calls recordDeviceSeen, broadcasts, acks", async () => {
70
+ const deps = makeDeps()
71
+ const cmd: ClientCommand = { type: "push.identifyDevice", pushDeviceId: "dev-1" }
72
+ const handled = await handlePushCommand(deps, cmd, "r1")
73
+ expect(handled).toBe(true)
74
+ expect(deps._deviceId).toBe("dev-1")
75
+ expect(deps.pushManager.recordDeviceSeen as ReturnType<typeof mock>).toHaveBeenCalledWith("dev-1")
76
+ expect(deps.broadcasts).toHaveLength(1)
77
+ expect(deps.broadcasts[0]).toEqual({ includePushConfig: true })
78
+ expect((deps.sent[0] as { type: string }).type).toBe("ack")
79
+ })
80
+
81
+ test("push.identifyDevice with null — sets null, skips recordDeviceSeen, no broadcast", async () => {
82
+ const deps = makeDeps()
83
+ const cmd: ClientCommand = { type: "push.identifyDevice", pushDeviceId: null }
84
+ const handled = await handlePushCommand(deps, cmd, "r2")
85
+ expect(handled).toBe(true)
86
+ expect(deps._deviceId).toBeNull()
87
+ expect(deps.pushManager.recordDeviceSeen as ReturnType<typeof mock>).not.toHaveBeenCalled()
88
+ expect(deps.broadcasts).toHaveLength(0)
89
+ expect((deps.sent[0] as { type: string }).type).toBe("ack")
90
+ })
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // push.subscribe
94
+ // ---------------------------------------------------------------------------
95
+
96
+ test("push.subscribe — calls addSubscription, sets deviceId from result, broadcasts, acks with result", async () => {
97
+ const deps = makeDeps({ addSubscription: mock(async () => ({ id: "sub-new" })) })
98
+ const cmd: ClientCommand = {
99
+ type: "push.subscribe",
100
+ subscription: { endpoint: "https://example.com/push", keys: { p256dh: "abc", auth: "def" } },
101
+ label: "Test device",
102
+ userAgent: "Bun/1.0",
103
+ }
104
+ const handled = await handlePushCommand(deps, cmd, "r3")
105
+ expect(handled).toBe(true)
106
+ expect(deps._deviceId).toBe("sub-new")
107
+ expect(deps.broadcasts).toHaveLength(1)
108
+ const ack = deps.sent[0] as { type: string; result: unknown }
109
+ expect(ack.type).toBe("ack")
110
+ expect(ack.result).toEqual({ id: "sub-new" })
111
+ })
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // push.unsubscribe
115
+ // ---------------------------------------------------------------------------
116
+
117
+ test("push.unsubscribe — calls removeSubscription, clears deviceId when it matches, broadcasts, acks", async () => {
118
+ const deps = makeDeps(undefined, "dev-1")
119
+ const cmd: ClientCommand = { type: "push.unsubscribe", pushDeviceId: "dev-1" }
120
+ const handled = await handlePushCommand(deps, cmd, "r4")
121
+ expect(handled).toBe(true)
122
+ expect(deps.pushManager.removeSubscription as ReturnType<typeof mock>).toHaveBeenCalledWith("dev-1", "user_revoked")
123
+ expect(deps._deviceId).toBeNull()
124
+ expect(deps.broadcasts).toHaveLength(1)
125
+ expect((deps.sent[0] as { type: string }).type).toBe("ack")
126
+ })
127
+
128
+ test("push.unsubscribe — does not clear deviceId when different device is unsubscribed", async () => {
129
+ const deps = makeDeps(undefined, "dev-1")
130
+ const cmd: ClientCommand = { type: "push.unsubscribe", pushDeviceId: "dev-2" }
131
+ await handlePushCommand(deps, cmd, "r5")
132
+ expect(deps._deviceId).toBe("dev-1")
133
+ })
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // push.test
137
+ // ---------------------------------------------------------------------------
138
+
139
+ test("push.test — calls sendTest with current deviceId and acks", async () => {
140
+ const deps = makeDeps(undefined, "dev-1")
141
+ const cmd: ClientCommand = { type: "push.test" }
142
+ const handled = await handlePushCommand(deps, cmd, "r6")
143
+ expect(handled).toBe(true)
144
+ expect(deps.pushManager.sendTest as ReturnType<typeof mock>).toHaveBeenCalledWith("dev-1")
145
+ expect((deps.sent[0] as { type: string }).type).toBe("ack")
146
+ })
147
+
148
+ test("push.test — skips sendTest when no deviceId, still acks", async () => {
149
+ const deps = makeDeps()
150
+ const cmd: ClientCommand = { type: "push.test" }
151
+ const handled = await handlePushCommand(deps, cmd, "r7")
152
+ expect(handled).toBe(true)
153
+ expect(deps.pushManager.sendTest as ReturnType<typeof mock>).not.toHaveBeenCalled()
154
+ expect((deps.sent[0] as { type: string }).type).toBe("ack")
155
+ })
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // push.setProjectMute
159
+ // ---------------------------------------------------------------------------
160
+
161
+ test("push.setProjectMute — calls setProjectMute, broadcasts, acks", async () => {
162
+ const deps = makeDeps()
163
+ const cmd: ClientCommand = { type: "push.setProjectMute", localPath: "/projects/foo", muted: true }
164
+ const handled = await handlePushCommand(deps, cmd, "r8")
165
+ expect(handled).toBe(true)
166
+ expect(deps.pushManager.setProjectMute as ReturnType<typeof mock>).toHaveBeenCalledWith("/projects/foo", true)
167
+ expect(deps.broadcasts).toHaveLength(1)
168
+ expect(deps.broadcasts[0]).toEqual({ includePushConfig: true })
169
+ expect((deps.sent[0] as { type: string }).type).toBe("ack")
170
+ })
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // push.setFocusedChat
174
+ // ---------------------------------------------------------------------------
175
+
176
+ test("push.setFocusedChat — calls setFocusedChat when deviceId is set, acks", async () => {
177
+ const deps = makeDeps(undefined, "dev-1")
178
+ const cmd: ClientCommand = { type: "push.setFocusedChat", chatId: "chat-42" }
179
+ const handled = await handlePushCommand(deps, cmd, "r9")
180
+ expect(handled).toBe(true)
181
+ expect(deps.pushManager.setFocusedChat as ReturnType<typeof mock>).toHaveBeenCalledWith("dev-1", "chat-42")
182
+ expect((deps.sent[0] as { type: string }).type).toBe("ack")
183
+ })
184
+
185
+ test("push.setFocusedChat — skips setFocusedChat when no deviceId, still acks", async () => {
186
+ const deps = makeDeps()
187
+ const cmd: ClientCommand = { type: "push.setFocusedChat", chatId: "chat-42" }
188
+ const handled = await handlePushCommand(deps, cmd, "r10")
189
+ expect(handled).toBe(true)
190
+ expect(deps.pushManager.setFocusedChat as ReturnType<typeof mock>).not.toHaveBeenCalled()
191
+ expect((deps.sent[0] as { type: string }).type).toBe("ack")
192
+ })
193
+ })
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ws-router-push.ts
3
+ *
4
+ * WS command handlers for push-notification lifecycle operations:
5
+ * push.identifyDevice, push.subscribe, push.unsubscribe,
6
+ * push.test, push.setProjectMute, push.setFocusedChat
7
+ *
8
+ * Extracted from ws-router.ts.
9
+ */
10
+ import { PROTOCOL_VERSION } from "../shared/types"
11
+ import type { PushSubscribeRequestPayload } from "../shared/types"
12
+ import type { ClientCommand, ServerEnvelope } from "../shared/protocol"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Dep interfaces (duck-typed; avoids circular imports with ws-router.ts)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** The subset of PushManager methods consumed by push WS commands. */
19
+ export interface PushManagerDep {
20
+ recordDeviceSeen(id: string): Promise<void>
21
+ addSubscription(args: {
22
+ subscription: PushSubscribeRequestPayload
23
+ label: string
24
+ userAgent: string
25
+ }): Promise<{ id: string }>
26
+ removeSubscription(id: string, reason: "user_revoked" | "expired" | "replaced"): Promise<void>
27
+ sendTest(id: string): Promise<void>
28
+ setProjectMute(localPath: string, muted: boolean): Promise<void>
29
+ setFocusedChat(deviceId: string, chatId: string | null): void
30
+ }
31
+
32
+ export interface PushCommandDeps {
33
+ /** Push manager for device/subscription operations. */
34
+ pushManager: PushManagerDep
35
+ /** Read the current connection's push device id. */
36
+ getPushDeviceId: () => string | null | undefined
37
+ /** Persist a new device id on the current connection (or null to clear). */
38
+ setPushDeviceId: (id: string | null) => void
39
+ /** Pre-bound to the current WebSocket; called to send an ack envelope. */
40
+ send: (envelope: ServerEnvelope) => void
41
+ /**
42
+ * Broadcast the updated push-config snapshot to all connected clients.
43
+ * Called after any operation that changes subscription or mute state.
44
+ */
45
+ broadcastPushConfig: () => Promise<void>
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Command dispatcher
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Handle one push WS command.
54
+ *
55
+ * Returns `true` when the command was handled (caller should `return`).
56
+ * Returns `false` when the command type is outside this module's scope.
57
+ */
58
+ export async function handlePushCommand(
59
+ deps: PushCommandDeps,
60
+ command: ClientCommand,
61
+ id: string,
62
+ ): Promise<boolean> {
63
+ const { pushManager, getPushDeviceId, setPushDeviceId, send, broadcastPushConfig } = deps
64
+
65
+ switch (command.type) {
66
+ case "push.identifyDevice": {
67
+ setPushDeviceId(command.pushDeviceId)
68
+ if (command.pushDeviceId) {
69
+ await pushManager.recordDeviceSeen(command.pushDeviceId)
70
+ await broadcastPushConfig()
71
+ }
72
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
73
+ return true
74
+ }
75
+ case "push.subscribe": {
76
+ const result = await pushManager.addSubscription({
77
+ subscription: command.subscription,
78
+ label: command.label,
79
+ userAgent: command.userAgent,
80
+ })
81
+ setPushDeviceId(result.id)
82
+ await broadcastPushConfig()
83
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
84
+ return true
85
+ }
86
+ case "push.unsubscribe": {
87
+ await pushManager.removeSubscription(command.pushDeviceId, "user_revoked")
88
+ if (getPushDeviceId() === command.pushDeviceId) {
89
+ setPushDeviceId(null)
90
+ }
91
+ await broadcastPushConfig()
92
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
93
+ return true
94
+ }
95
+ case "push.test": {
96
+ const deviceId = getPushDeviceId()
97
+ if (deviceId) {
98
+ await pushManager.sendTest(deviceId)
99
+ }
100
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
101
+ return true
102
+ }
103
+ case "push.setProjectMute": {
104
+ await pushManager.setProjectMute(command.localPath, command.muted)
105
+ await broadcastPushConfig()
106
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
107
+ return true
108
+ }
109
+ case "push.setFocusedChat": {
110
+ const deviceId = getPushDeviceId()
111
+ if (deviceId) {
112
+ pushManager.setFocusedChat(deviceId, command.chatId)
113
+ }
114
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
115
+ return true
116
+ }
117
+ default:
118
+ return false
119
+ }
120
+ }