@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
@@ -1,24 +1,15 @@
1
- import { randomUUID } from "node:crypto"
2
- import { readTextFileOrThrow, spawnCommandCapture } from "./ws-router-io.adapter"
3
1
  import type { AnyValue } from "../shared/errors"
4
- import { isRecord } from "../shared/errors"
5
2
  import { log } from "../shared/log"
6
- import os from "node:os"
7
- import path from "node:path"
8
3
  import type { ServerWebSocket } from "bun"
9
4
  import { PROTOCOL_VERSION } from "../shared/types"
10
- import type { ClientEnvelope, PtyInstancesEvent, ServerEnvelope, SubscriptionTopic } from "../shared/protocol"
11
- import type { PtyInstanceDelta } from "../shared/pty-instance"
12
- import type { PtyInstanceRegistry } from "./claude-pty/pty-instance-registry"
13
- import type { WorkflowRegistry } from "./workflow-registry"
14
- import type { SubagentTranscriptRegistry } from "./subagent-transcript-registry"
5
+ import type { ClientEnvelope } from "../shared/protocol"
15
6
  import { isClientEnvelope } from "../shared/protocol"
16
7
  import type { AgentCoordinator } from "./agent"
17
8
  import type { AnalyticsReporter } from "./analytics"
18
9
  import { NoopAnalyticsReporter } from "./analytics"
19
10
  import type { AppSettingsManager } from "./app-settings"
20
11
  import type { DiscoveredProject } from "./discovery.adapter"
21
- import { DiffStore, fetchGitHubReleases } from "./diff-store"
12
+ import { DiffStore } from "./diff-store"
22
13
  import { EventStore } from "./event-store"
23
14
  import { openExternal } from "./external-open"
24
15
  import { KeybindingsManager } from "./keybindings"
@@ -26,116 +17,58 @@ import { resolveLocalPath } from "./paths"
26
17
  import { ensureProjectDirectory } from "./project-directory.adapter"
27
18
  import { TerminalManager } from "./terminal-manager"
28
19
  import type { UpdateManager } from "./update-manager"
29
- import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
30
- import { toOrchRunSummary } from "./orchestration-input"
31
- import { AUTH_DEFAULTS, CLAUDE_AUTH_DEFAULTS, CLAUDE_DRIVER_DEFAULTS, CLAUDE_PTY_LIFECYCLE_DEFAULTS, CLOUDFLARE_TUNNEL_DEFAULTS, DEFAULT_OPENROUTER_SDK_MODEL, UPLOAD_DEFAULTS } from "../shared/types"
32
20
  import type {
33
- AppSettingsPatch,
34
- AppSettingsSnapshot,
35
- InstalledSkillsSnapshot,
36
- McpServerConfig,
37
21
  LlmProviderSnapshot,
38
22
  LlmProviderValidationResult,
39
23
  OpenRouterModel,
40
- SkillInstallResult,
41
- SkillSearchSnapshot,
42
- SkillUninstallResult,
43
- Subagent,
44
- SubagentValidationError,
45
24
  } from "../shared/types"
46
25
  import { importClaudeSessions } from "./claude-session-importer.adapter"
47
26
  import { listWorktrees } from "./worktree-store.adapter"
48
27
  import type { TunnelGateway } from "./cloudflare-tunnel/gateway"
49
28
  import type { PushManager } from "./push/push-manager"
50
- import { validateMcpServer } from "./mcp-validator"
51
- import { startMcpOAuth, completeMcpOAuth, ensureFreshMcpToken } from "./mcp-oauth.adapter"
52
29
  import type { SessionShareService } from "./session-share"
53
- import type { ShareCommandResult } from "../shared/session-share/protocol"
54
-
55
- const DEFAULT_CHAT_RECENT_LIMIT = 200
56
- const SKILL_AGENT_ALIASES = ["universal", "claude-code"] as const
57
-
58
- function isSendToStartingProfilingEnabled() {
59
- return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
60
- }
61
-
62
- function logSendToStartingProfile(
63
- traceId: string | null | undefined,
64
- startedAt: number | null | undefined,
65
- stage: string,
66
- details?: Record<string, unknown>
67
- ) {
68
- if (!traceId || startedAt === undefined || startedAt === null || !isSendToStartingProfilingEnabled()) {
69
- return
70
- }
71
-
72
- log.info("[kanna/send->starting][server]", JSON.stringify({
73
- traceId,
74
- stage,
75
- elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
76
- ...details,
77
- }))
78
- }
79
-
80
- function countSubscriptionsByTopic(ws: ServerWebSocket<ClientState>) {
81
- let sidebar = 0
82
- let chat = 0
83
- let projectGit = 0
84
- let localProjects = 0
85
- let update = 0
86
- let keybindings = 0
87
- let appSettings = 0
88
- let terminal = 0
89
-
90
- for (const topic of ws.data.subscriptions.values()) {
91
- switch (topic.type) {
92
- case "sidebar":
93
- sidebar += 1
94
- break
95
- case "chat":
96
- chat += 1
97
- break
98
- case "project-git":
99
- projectGit += 1
100
- break
101
- case "local-projects":
102
- localProjects += 1
103
- break
104
- case "update":
105
- update += 1
106
- break
107
- case "keybindings":
108
- keybindings += 1
109
- break
110
- case "app-settings":
111
- appSettings += 1
112
- break
113
- case "terminal":
114
- terminal += 1
115
- break
116
- }
117
- }
118
-
119
- return {
120
- total: ws.data.subscriptions.size,
121
- sidebar,
122
- chat,
123
- projectGit,
124
- localProjects,
125
- update,
126
- keybindings,
127
- appSettings,
128
- terminal,
129
- }
130
- }
131
-
132
- export interface ClientState {
133
- subscriptions: Map<string, SubscriptionTopic>
134
- snapshotSignatures: Map<string, string>
135
- protectedDraftChatIds?: Set<string>
136
- pushDeviceId?: string | null
137
- originHost?: string
138
- }
30
+ import type { PtyInstanceRegistry } from "./claude-pty/pty-instance-registry"
31
+ import type { WorkflowRegistry } from "./workflow-registry"
32
+ import type { SubagentTranscriptRegistry } from "./subagent-transcript-registry"
33
+ import { buildFallbackDiffStore, buildFallbackLlmProvider, buildResolvedAppSettings } from "./ws-router-defaults"
34
+ import { handleSettingsCommand } from "./ws-router-settings"
35
+ import { handleDiffCommand } from "./ws-router-diff"
36
+ import { handleOrchCommand } from "./ws-router-orch"
37
+ import { handleAgentCtrlCommand } from "./ws-router-agent-ctrl"
38
+ import { handlePushCommand } from "./ws-router-push"
39
+ import { handleMiscCommand } from "./ws-router-misc"
40
+ import { handleProjectCommand } from "./ws-router-project"
41
+ import { handleChatCommand } from "./ws-router-chat"
42
+ import {
43
+ ensureSnapshotSignatures,
44
+ isBenignStaleStateMessage,
45
+ logSendToStartingProfile,
46
+ send,
47
+ } from "./ws-router-utils"
48
+ import type { ClientState } from "./ws-router-utils"
49
+ import { createEnvelopeBuilder } from "./ws-router-envelope"
50
+ import { BroadcastManager } from "./ws-router-broadcast"
51
+
52
+ // Re-export skill utilities so existing callers (tests, server.ts, etc.) keep working.
53
+ export {
54
+ assertSafeSkillId,
55
+ assertSafeSkillSource,
56
+ buildInstallSkillCommand,
57
+ buildUninstallSkillCommand,
58
+ getGlobalSkillLockPath,
59
+ installSkill,
60
+ listInstalledSkills,
61
+ parseInstalledSkillsLock,
62
+ searchSkills,
63
+ uninstallSkill,
64
+ } from "./ws-router-skills"
65
+
66
+ // Re-export settings helpers that tests import from this module.
67
+ export { resolveMcpTestBearer } from "./ws-router-settings"
68
+
69
+ // Re-export for backwards compatibility — callers import these from ws-router.
70
+ export type { ClientState } from "./ws-router-utils"
71
+ export { isBenignStaleStateMessage } from "./ws-router-utils"
139
72
 
140
73
  interface CreateWsRouterArgs {
141
74
  store: EventStore
@@ -165,249 +98,6 @@ interface CreateWsRouterArgs {
165
98
  sessionShare?: SessionShareService
166
99
  }
167
100
 
168
- interface SnapshotBroadcastFilter {
169
- includeSidebar?: boolean
170
- includeLocalProjects?: boolean
171
- includeUpdate?: boolean
172
- includeKeybindings?: boolean
173
- includeAppSettings?: boolean
174
- includePushConfig?: boolean
175
- chatIds?: Set<string>
176
- projectIds?: Set<string>
177
- terminalIds?: Set<string>
178
- }
179
-
180
- interface SnapshotComputationCache {
181
- sidebar?: {
182
- data: ReturnType<typeof deriveSidebarData>
183
- signature: string
184
- }
185
- }
186
-
187
- function getSidebarProjectOrder(store: EventStore) {
188
- return typeof store.getSidebarProjectOrder === "function"
189
- ? store.getSidebarProjectOrder()
190
- : []
191
- }
192
-
193
- // Stale-state command failures happen during normal client/server races
194
- // (e.g. the user steers a queued message that drained between snapshots).
195
- // They flood pm2 logs at console.error level; downgrade to console.log.
196
- const BENIGN_STALE_STATE_MESSAGES = [
197
- /^Chat not found$/,
198
- /^Queued message not found$/,
199
- /^File is no longer changed: /,
200
- /^Project not found$/,
201
- ] as const
202
-
203
- export function isBenignStaleStateMessage(message: string): boolean {
204
- return BENIGN_STALE_STATE_MESSAGES.some((pattern) => pattern.test(message))
205
- }
206
-
207
- function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
208
- const payload = JSON.stringify(message)
209
- ws.send(payload)
210
- return payload.length
211
- }
212
-
213
- function isSubagentValidationError(value: Subagent | SubagentValidationError): value is SubagentValidationError {
214
- return "code" in value && "message" in value
215
- }
216
-
217
- export function assertSafeSkillSource(source: string) {
218
- const normalized = source.trim()
219
- if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)) {
220
- throw new Error("Skill source must be an owner/repo pair.")
221
- }
222
- return normalized
223
- }
224
-
225
- export function assertSafeSkillId(skillId: string) {
226
- const normalized = skillId.trim()
227
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(normalized)) {
228
- throw new Error("Skill id is invalid.")
229
- }
230
- return normalized
231
- }
232
-
233
- export function getGlobalSkillLockPath() {
234
- const xdgStateHome = process.env.XDG_STATE_HOME?.trim()
235
- if (xdgStateHome) {
236
- return path.join(xdgStateHome, "skills", ".skill-lock.json")
237
- }
238
- return path.join(os.homedir(), ".agents", ".skill-lock.json")
239
- }
240
-
241
- function asString(value: AnyValue) {
242
- return typeof value === "string" ? value : ""
243
- }
244
-
245
- export function parseInstalledSkillsLock(parsed: AnyValue, lockFilePath: string): InstalledSkillsSnapshot {
246
- const skillsRaw = isRecord(parsed) && isRecord(parsed.skills) && !Array.isArray(parsed.skills)
247
- ? parsed.skills
248
- : null
249
- const skillsRecord: Record<string, AnyValue> = skillsRaw ?? {}
250
-
251
- const skills = Object.entries(skillsRecord)
252
- .filter(([, entry]) => entry && typeof entry === "object" && !Array.isArray(entry))
253
- .map(([name, entry]) => {
254
- const record: Record<string, AnyValue> = isRecord(entry) ? entry : {}
255
- return {
256
- name,
257
- source: asString(record.source),
258
- sourceType: asString(record.sourceType),
259
- sourceUrl: asString(record.sourceUrl),
260
- skillPath: asString(record.skillPath) || undefined,
261
- installedAt: asString(record.installedAt),
262
- updatedAt: asString(record.updatedAt),
263
- pluginName: asString(record.pluginName) || undefined,
264
- }
265
- })
266
- .sort((a, b) => a.name.localeCompare(b.name))
267
-
268
- return {
269
- lockFilePath,
270
- skills,
271
- }
272
- }
273
-
274
- export async function listInstalledSkills(lockFilePath = getGlobalSkillLockPath()): Promise<InstalledSkillsSnapshot> {
275
- try {
276
- return parseInstalledSkillsLock(JSON.parse(await readTextFileOrThrow(lockFilePath)), lockFilePath)
277
- } catch {
278
- return {
279
- lockFilePath,
280
- skills: [],
281
- }
282
- }
283
- }
284
-
285
- export async function searchSkills(query: string, limit = 100): Promise<SkillSearchSnapshot> {
286
- const normalizedQuery = query.trim()
287
- if (normalizedQuery.length < 2) {
288
- return {
289
- query: normalizedQuery,
290
- searchType: "fuzzy",
291
- skills: [],
292
- count: 0,
293
- duration_ms: 0,
294
- }
295
- }
296
-
297
- const normalizedLimit = Math.max(1, Math.min(100, Math.trunc(limit)))
298
- const url = new URL("https://skills.sh/api/search")
299
- url.searchParams.set("q", normalizedQuery)
300
- url.searchParams.set("limit", String(normalizedLimit))
301
-
302
- const response = await fetch(url, {
303
- signal: AbortSignal.timeout(10_000),
304
- })
305
- if (!response.ok) {
306
- throw new Error(`Skills search failed with status ${response.status}.`)
307
- }
308
-
309
- const payload: Partial<SkillSearchSnapshot> = await response.json()
310
- return {
311
- query: typeof payload.query === "string" ? payload.query : normalizedQuery,
312
- searchType: typeof payload.searchType === "string" ? payload.searchType : "fuzzy",
313
- skills: Array.isArray(payload.skills)
314
- ? payload.skills
315
- .filter((skill) => (
316
- skill
317
- && typeof skill === "object"
318
- && typeof skill.id === "string"
319
- && typeof skill.skillId === "string"
320
- && typeof skill.name === "string"
321
- && typeof skill.source === "string"
322
- ))
323
- .map((skill) => ({
324
- id: skill.id,
325
- skillId: skill.skillId,
326
- name: skill.name,
327
- installs: typeof skill.installs === "number" ? skill.installs : 0,
328
- source: skill.source,
329
- }))
330
- : [],
331
- count: typeof payload.count === "number" ? payload.count : 0,
332
- duration_ms: typeof payload.duration_ms === "number" ? payload.duration_ms : 0,
333
- }
334
- }
335
-
336
- export function buildInstallSkillCommand(source: string, skillId: string) {
337
- return [
338
- process.platform === "win32" ? "npx.cmd" : "npx",
339
- "skills",
340
- "add",
341
- assertSafeSkillSource(source),
342
- "--skill",
343
- assertSafeSkillId(skillId),
344
- "--global",
345
- "--agent",
346
- ...SKILL_AGENT_ALIASES,
347
- "--yes",
348
- ]
349
- }
350
-
351
- export function buildUninstallSkillCommand(skillId: string) {
352
- return [
353
- process.platform === "win32" ? "npx.cmd" : "npx",
354
- "skills",
355
- "remove",
356
- assertSafeSkillId(skillId),
357
- "--global",
358
- "--agent",
359
- ...SKILL_AGENT_ALIASES,
360
- "--yes",
361
- ]
362
- }
363
-
364
- async function runSkillCommand(command: string[]) {
365
- const cwd = os.homedir()
366
- const { stdout, stderr, exitCode } = await spawnCommandCapture(command, cwd, {
367
- ...process.env,
368
- DISABLE_TELEMETRY: process.env.DISABLE_TELEMETRY ?? "1",
369
- })
370
-
371
- if (exitCode !== 0) {
372
- throw new Error(stderr.trim() || stdout.trim() || `skills CLI exited with code ${exitCode}.`)
373
- }
374
-
375
- return { cwd, stdout, stderr }
376
- }
377
-
378
- export async function installSkill(source: string, skillId: string): Promise<SkillInstallResult> {
379
- const command = buildInstallSkillCommand(source, skillId)
380
- const { cwd, stdout, stderr } = await runSkillCommand(command)
381
- return {
382
- source: command[3],
383
- skillId: command[5],
384
- command,
385
- cwd,
386
- stdout,
387
- stderr,
388
- }
389
- }
390
-
391
- export async function uninstallSkill(skillId: string): Promise<SkillUninstallResult> {
392
- const command = buildUninstallSkillCommand(skillId)
393
- const { cwd, stdout, stderr } = await runSkillCommand(command)
394
- return {
395
- skillId: command[3],
396
- command,
397
- cwd,
398
- stdout,
399
- stderr,
400
- }
401
- }
402
-
403
- function ensureSnapshotSignatures(ws: ServerWebSocket<ClientState>) {
404
- if (!ws.data.snapshotSignatures) {
405
- ws.data.snapshotSignatures = new Map()
406
- }
407
-
408
- return ws.data.snapshotSignatures
409
- }
410
-
411
101
  export function createWsRouter({
412
102
  store,
413
103
  diffStore,
@@ -430,908 +120,37 @@ export function createWsRouter({
430
120
  subagentTranscriptRegistry,
431
121
  sessionShare,
432
122
  }: CreateWsRouterArgs) {
433
- const sockets = new Set<ServerWebSocket<ClientState>>()
434
- let pendingBroadcastTimer: ReturnType<typeof setTimeout> | null = null
435
- let pendingBroadcastAll = false
436
- const pendingBroadcastChatIds = new Set<string>()
437
- const resolvedDiffStore = diffStore ?? {
438
- getProjectSnapshot: () => ({ status: "unknown", branchName: undefined, defaultBranchName: undefined, hasOriginRemote: undefined, originRepoSlug: undefined, hasUpstream: undefined, aheadCount: undefined, behindCount: undefined, lastFetchedAt: undefined, files: [] as const, branchHistory: { entries: [] as const } }),
439
- refreshSnapshot: async () => false,
440
- initializeGit: async () => ({ ok: true, branchName: undefined, snapshotChanged: false }),
441
- getGitHubPublishInfo: async () => ({ ghInstalled: false, authenticated: false, activeAccountLogin: undefined, owners: [], suggestedRepoName: "my-repo" }),
442
- checkGitHubRepoAvailability: async () => ({ available: false, message: "Unavailable" }),
443
- publishToGitHub: async () => ({ ok: false, title: "Publish failed", message: "Unavailable", snapshotChanged: false }),
444
- listBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" as const }),
445
- previewMergeBranch: async () => ({ currentBranchName: undefined, targetBranchName: "", targetDisplayName: "", status: "error" as const, commitCount: 0, hasConflicts: false, message: "Merge preview unavailable." }),
446
- mergeBranch: async () => ({ ok: false as const, title: "Merge failed", message: "Merge unavailable.", snapshotChanged: false }),
447
- syncBranch: async () => ({ ok: true, action: "fetch" as const, branchName: undefined, snapshotChanged: false }),
448
- checkoutBranch: async () => ({ ok: true, branchName: undefined, snapshotChanged: false }),
449
- createBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
450
- generateCommitMessage: async () => ({ subject: "Update selected files", body: "", usedFallback: true, failureMessage: null }),
451
- commitFiles: async () => ({ ok: true, mode: "commit_only" as const, branchName: undefined, pushed: false, snapshotChanged: false }),
452
- discardFile: async () => ({ snapshotChanged: false }),
453
- ignoreFile: async () => ({ snapshotChanged: false }),
454
- readPatch: async () => ({ patch: "" }),
455
- }
456
- const resolvedLlmProvider = llmProvider ?? {
457
- read: async () => ({
458
- provider: "openai" as const,
459
- apiKey: "",
460
- model: "gpt-5.4-mini",
461
- baseUrl: "",
462
- resolvedBaseUrl: "https://api.openai.com/v1",
463
- enabled: false,
464
- warning: null,
465
- filePathDisplay: "~/.kanna/llm-provider.json",
466
- }),
467
- write: async ({ provider, apiKey, model, baseUrl }: {
468
- provider: "openai" | "openrouter" | "custom"
469
- apiKey: string
470
- model: string
471
- baseUrl: string
472
- }) => {
473
- let resolvedBaseUrl: string
474
- if (provider === "openrouter") {
475
- resolvedBaseUrl = "https://openrouter.ai/api/v1"
476
- } else if (provider === "custom") {
477
- resolvedBaseUrl = baseUrl
478
- } else {
479
- resolvedBaseUrl = "https://api.openai.com/v1"
480
- }
481
- return {
482
- provider,
483
- apiKey,
484
- model,
485
- baseUrl,
486
- resolvedBaseUrl,
487
- enabled: false,
488
- warning: null,
489
- filePathDisplay: "~/.kanna/llm-provider.json",
490
- }
491
- },
492
- validate: async () => ({
493
- ok: false,
494
- error: {
495
- type: "config_error",
496
- message: "LLM provider validation unavailable.",
497
- },
498
- }),
499
- }
500
- let fallbackAppSettingsSnapshot: AppSettingsSnapshot = {
501
- analyticsEnabled: true,
502
- browserSettingsMigrated: false,
503
- theme: "system",
504
- chatSoundPreference: "always",
505
- chatSoundId: "funk",
506
- terminal: {
507
- scrollbackLines: 1_000,
508
- minColumnWidth: 450,
509
- },
510
- editor: {
511
- preset: "cursor",
512
- commandTemplate: "cursor {path}",
513
- },
514
- defaultProvider: "last_used",
515
- providerDefaults: {
516
- claude: {
517
- model: "claude-opus-4-7",
518
- modelOptions: {
519
- reasoningEffort: "high",
520
- contextWindow: "200k",
521
- },
522
- planMode: false,
523
- },
524
- codex: {
525
- model: "gpt-5.5",
526
- modelOptions: {
527
- reasoningEffort: "high",
528
- fastMode: false,
529
- },
530
- planMode: false,
531
- },
532
- openrouter: {
533
- model: DEFAULT_OPENROUTER_SDK_MODEL,
534
- modelOptions: {},
535
- planMode: false,
536
- },
537
- },
538
- warning: null,
539
- filePathDisplay: "~/.kanna/data/settings.json",
540
- cloudflareTunnel: CLOUDFLARE_TUNNEL_DEFAULTS,
541
- auth: AUTH_DEFAULTS,
542
- claudeAuth: CLAUDE_AUTH_DEFAULTS,
543
- uploads: UPLOAD_DEFAULTS,
544
- subagents: [],
545
- customMcpServers: [],
546
- customModels: [],
547
- textSnippets: [],
548
- claudeDriver: { ...CLAUDE_DRIVER_DEFAULTS, lifecycle: { ...CLAUDE_PTY_LIFECYCLE_DEFAULTS } },
549
- globalPromptAppend: "",
550
- shareDefaultTtlHours: 24,
551
- subagentRuntime: { runTimeoutMs: 600_000, defaultLoopSubagentId: null },
552
- }
553
- const mergeAppSettingsPatch = (snapshot: AppSettingsSnapshot, patch: AppSettingsPatch): AppSettingsSnapshot => {
554
- let subagents = snapshot.subagents
555
- if (patch.subagents?.create) {
556
- const now = Date.now()
557
- subagents = [...subagents, {
558
- id: randomUUID(),
559
- ...patch.subagents.create,
560
- name: patch.subagents.create.name.trim(),
561
- triggerMode: patch.subagents.create.triggerMode ?? "auto",
562
- createdAt: now,
563
- updatedAt: now,
564
- }]
565
- } else if (patch.subagents?.update) {
566
- subagents = subagents.map((subagent): Subagent => subagent.id === patch.subagents?.update?.id
567
- ? {
568
- ...subagent,
569
- ...patch.subagents.update.patch,
570
- name: patch.subagents.update.patch.name?.trim() ?? subagent.name,
571
- description: patch.subagents.update.patch.description === null
572
- ? undefined
573
- : patch.subagents.update.patch.description ?? subagent.description,
574
- modelOptions: <Subagent["modelOptions"]>{ ...subagent.modelOptions, ...(patch.subagents.update.patch.modelOptions ?? {}) },
575
- workingDir: patch.subagents.update.patch.workingDir === null
576
- ? undefined
577
- : patch.subagents.update.patch.workingDir ?? subagent.workingDir,
578
- allowedPaths: patch.subagents.update.patch.allowedPaths === null
579
- ? undefined
580
- : patch.subagents.update.patch.allowedPaths ?? subagent.allowedPaths,
581
- maxTurns: patch.subagents.update.patch.maxTurns === null
582
- ? undefined
583
- : patch.subagents.update.patch.maxTurns ?? subagent.maxTurns,
584
- updatedAt: Date.now(),
585
- }
586
- : subagent)
587
- } else if (patch.subagents?.delete) {
588
- subagents = subagents.filter((subagent) => subagent.id !== patch.subagents?.delete?.id)
589
- }
590
-
591
- return {
592
- ...snapshot,
593
- ...patch,
594
- terminal: {
595
- ...snapshot.terminal,
596
- ...patch.terminal,
597
- },
598
- editor: {
599
- ...snapshot.editor,
600
- ...patch.editor,
601
- },
602
- providerDefaults: {
603
- claude: {
604
- ...snapshot.providerDefaults.claude,
605
- ...patch.providerDefaults?.claude,
606
- modelOptions: {
607
- ...snapshot.providerDefaults.claude.modelOptions,
608
- ...patch.providerDefaults?.claude?.modelOptions,
609
- },
610
- },
611
- codex: {
612
- ...snapshot.providerDefaults.codex,
613
- ...patch.providerDefaults?.codex,
614
- modelOptions: {
615
- ...snapshot.providerDefaults.codex.modelOptions,
616
- ...patch.providerDefaults?.codex?.modelOptions,
617
- },
618
- },
619
- openrouter: {
620
- ...snapshot.providerDefaults.openrouter,
621
- ...patch.providerDefaults?.openrouter,
622
- modelOptions: {},
623
- },
624
- },
625
- cloudflareTunnel: {
626
- ...snapshot.cloudflareTunnel,
627
- ...patch.cloudflareTunnel,
628
- },
629
- auth: {
630
- ...snapshot.auth,
631
- ...patch.auth,
632
- },
633
- claudeAuth: {
634
- tokens: patch.claudeAuth?.tokens ?? snapshot.claudeAuth.tokens,
635
- concurrencyDefault: patch.claudeAuth?.concurrencyDefault ?? snapshot.claudeAuth.concurrencyDefault,
636
- },
637
- uploads: {
638
- ...snapshot.uploads,
639
- ...patch.uploads,
640
- },
641
- subagents,
642
- customMcpServers: snapshot.customMcpServers,
643
- customModels: snapshot.customModels,
644
- textSnippets: snapshot.textSnippets,
645
- claudeDriver: {
646
- preference: patch.claudeDriver?.preference ?? snapshot.claudeDriver.preference,
647
- lifecycle: {
648
- ...snapshot.claudeDriver.lifecycle,
649
- ...patch.claudeDriver?.lifecycle,
650
- },
651
- },
652
- subagentRuntime: {
653
- runTimeoutMs: patch.subagentRuntime?.runTimeoutMs ?? snapshot.subagentRuntime.runTimeoutMs,
654
- defaultLoopSubagentId: patch.subagentRuntime?.defaultLoopSubagentId !== undefined
655
- ? patch.subagentRuntime.defaultLoopSubagentId
656
- : snapshot.subagentRuntime.defaultLoopSubagentId,
657
- },
658
- }
659
- }
660
- const resolvedAppSettings = {
661
- getSnapshot: () => appSettings?.getSnapshot() ?? fallbackAppSettingsSnapshot,
662
- write: async (value: { analyticsEnabled: boolean }) => {
663
- if (appSettings) return await appSettings.write(value)
664
- fallbackAppSettingsSnapshot = { ...fallbackAppSettingsSnapshot, analyticsEnabled: value.analyticsEnabled }
665
- return fallbackAppSettingsSnapshot
666
- },
667
- writePatch: async (patch: AppSettingsPatch) => {
668
- if (appSettings?.writePatch) return await appSettings.writePatch(patch)
669
- if (appSettings && patch.analyticsEnabled !== undefined && Object.keys(patch).length === 1) {
670
- return await appSettings.write({ analyticsEnabled: patch.analyticsEnabled })
671
- }
672
- fallbackAppSettingsSnapshot = mergeAppSettingsPatch(appSettings?.getSnapshot() ?? fallbackAppSettingsSnapshot, patch)
673
- return fallbackAppSettingsSnapshot
674
- },
675
- setCloudflareTunnel: async (patch: Partial<AppSettingsSnapshot["cloudflareTunnel"]>) => {
676
- if (appSettings?.setCloudflareTunnel) return await appSettings.setCloudflareTunnel(patch)
677
- fallbackAppSettingsSnapshot = mergeAppSettingsPatch(appSettings?.getSnapshot() ?? fallbackAppSettingsSnapshot, { cloudflareTunnel: patch })
678
- return fallbackAppSettingsSnapshot
679
- },
680
- setClaudeAuth: async (patch: Partial<AppSettingsSnapshot["claudeAuth"]>) => {
681
- if (appSettings?.setClaudeAuth) return await appSettings.setClaudeAuth(patch)
682
- fallbackAppSettingsSnapshot = mergeAppSettingsPatch(
683
- appSettings?.getSnapshot() ?? fallbackAppSettingsSnapshot,
684
- { claudeAuth: patch },
685
- )
686
- return fallbackAppSettingsSnapshot
687
- },
688
- createSubagent: async (input: Parameters<AppSettingsManager["createSubagent"]>[0]) => {
689
- if (appSettings?.createSubagent) return await appSettings.createSubagent(input)
690
- const snapshot = await resolvedAppSettings.writePatch({ subagents: { create: input } })
691
- return snapshot.subagents[snapshot.subagents.length - 1] ?? { code: "NOT_FOUND" as const, message: "Created subagent not found" }
692
- },
693
- updateSubagent: async (id: string, patch: Parameters<AppSettingsManager["updateSubagent"]>[1]) => {
694
- if (appSettings?.updateSubagent) return await appSettings.updateSubagent(id, patch)
695
- const snapshot = await resolvedAppSettings.writePatch({ subagents: { update: { id, patch } } })
696
- return snapshot.subagents.find((subagent) => subagent.id === id) ?? { code: "NOT_FOUND" as const, message: `Subagent ${id} not found` }
697
- },
698
- deleteSubagent: async (id: string) => {
699
- if (appSettings?.deleteSubagent) return await appSettings.deleteSubagent(id)
700
- await resolvedAppSettings.writePatch({ subagents: { delete: { id } } })
701
- },
702
- onChange: (listener: (snapshot: AppSettingsSnapshot) => void) => appSettings?.onChange?.(listener) ?? (() => {}),
703
- }
123
+ const resolvedDiffStore = diffStore ?? buildFallbackDiffStore()
124
+ const resolvedLlmProvider = llmProvider ?? buildFallbackLlmProvider()
125
+ const resolvedAppSettings = buildResolvedAppSettings(appSettings)
704
126
  const resolvedAnalytics = analytics ?? NoopAnalyticsReporter
705
127
 
706
- function getProtectedChatIds() {
707
- const activeStatuses = agent.getActiveStatuses()
708
- const drainingChatIds = typeof agent.getDrainingChatIds === "function"
709
- ? agent.getDrainingChatIds()
710
- : new Set<string>()
711
- return new Set([
712
- ...activeStatuses.keys(),
713
- ...drainingChatIds.values(),
714
- ])
715
- }
716
-
717
- function getProtectedDraftChatIds(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
718
- const protectedChatIds = new Set<string>()
719
-
720
- for (const socket of sockets) {
721
- for (const chatId of socket.data.protectedDraftChatIds ?? []) {
722
- protectedChatIds.add(chatId)
723
- }
724
- }
725
-
726
- for (const socket of extraSockets ?? []) {
727
- for (const chatId of socket.data.protectedDraftChatIds ?? []) {
728
- protectedChatIds.add(chatId)
729
- }
730
- }
731
-
732
- return protectedChatIds
733
- }
734
-
735
- async function maybePruneStaleEmptyChats(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
736
- const startedAt = performance.now()
737
- const activeChatIds = getProtectedChatIds()
738
- const protectedDraftChatIds = getProtectedDraftChatIds(extraSockets)
739
- const prunedChatIds = await store.pruneStaleEmptyChats?.({
740
- activeChatIds,
741
- protectedChatIds: protectedDraftChatIds,
742
- })
743
- if (isSendToStartingProfilingEnabled()) {
744
- log.info("[kanna/send->starting][server]", JSON.stringify({
745
- stage: "ws.prune_stale_empty_chats",
746
- elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
747
- activeChatCount: activeChatIds.size,
748
- protectedDraftChatCount: protectedDraftChatIds.size,
749
- prunedCount: prunedChatIds?.length ?? 0,
750
- totalChatCount: store.state.chatsById.size,
751
- totalProjectCount: store.state.projectsById.size,
752
- }))
753
- }
754
- }
755
-
756
- function shouldIncludeTopic(topic: SubscriptionTopic, filter?: SnapshotBroadcastFilter) {
757
- if (!filter) {
758
- return true
759
- }
760
-
761
- if (topic.type === "sidebar") {
762
- return Boolean(filter.includeSidebar)
763
- }
764
- if (topic.type === "local-projects") {
765
- return Boolean(filter.includeLocalProjects)
766
- }
767
- if (topic.type === "update") {
768
- return Boolean(filter.includeUpdate)
769
- }
770
- if (topic.type === "keybindings") {
771
- return Boolean(filter.includeKeybindings)
772
- }
773
- if (topic.type === "app-settings") {
774
- return Boolean(filter.includeAppSettings)
775
- }
776
- if (topic.type === "push-config") {
777
- return Boolean(filter.includePushConfig)
778
- }
779
- if (topic.type === "chat") {
780
- return filter.chatIds?.has(topic.chatId) ?? false
781
- }
782
- if (topic.type === "project-git") {
783
- return filter.projectIds?.has(topic.projectId) ?? false
784
- }
785
- if (topic.type === "terminal") {
786
- return filter.terminalIds?.has(topic.terminalId) ?? false
787
- }
788
-
789
- return true
790
- }
791
-
792
- function getSidebarSnapshotCacheEntry(cache?: SnapshotComputationCache) {
793
- if (cache?.sidebar) {
794
- return cache.sidebar
795
- }
796
-
797
- const startedAt = performance.now()
798
- const data = deriveSidebarData(store.state, agent.getActiveStatuses(), {
799
- sidebarProjectOrder: getSidebarProjectOrder(store),
800
- drainingChatIds: agent.getDrainingChatIds(),
801
- claudeSessionStates: agent.getClaudeSessionStates?.(),
802
- })
803
- const observed = data.projectGroups.flatMap((group) =>
804
- group.chats.map((chat) => ({
805
- chatId: chat.chatId,
806
- projectLocalPath: group.localPath,
807
- projectTitle: group.localPath.split("/").filter(Boolean).pop() ?? group.localPath,
808
- chatTitle: chat.title,
809
- status: chat.status,
810
- }))
811
- )
812
- void pushManager.observeStatuses(observed).catch((error) => {
813
- log.warn("[kanna/push] observeStatuses failed", { error })
814
- })
815
- if (isSendToStartingProfilingEnabled()) {
816
- const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
817
- log.info("[kanna/send->starting][server]", JSON.stringify({
818
- stage: "ws.sidebar_snapshot_built",
819
- elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
820
- projectGroupCount: data.projectGroups.length,
821
- chatCount: totalChats,
822
- totalChatCount: store.state.chatsById.size,
823
- totalProjectCount: store.state.projectsById.size,
824
- }))
825
- }
826
-
827
- const sidebar = {
828
- data,
829
- signature: JSON.stringify({
830
- type: "sidebar" as const,
831
- data,
832
- }),
833
- }
834
-
835
- if (cache) {
836
- cache.sidebar = sidebar
837
- }
838
-
839
- return sidebar
840
- }
841
-
842
- function createEnvelope(
843
- id: string,
844
- topic: SubscriptionTopic,
845
- cache?: SnapshotComputationCache,
846
- connection?: ServerWebSocket<ClientState>,
847
- ): ServerEnvelope {
848
- if (topic.type === "sidebar") {
849
- const sidebar = getSidebarSnapshotCacheEntry(cache)
850
- return {
851
- v: PROTOCOL_VERSION,
852
- type: "snapshot",
853
- id,
854
- snapshot: {
855
- type: "sidebar",
856
- data: sidebar.data,
857
- },
858
- }
859
- }
860
-
861
- if (topic.type === "local-projects") {
862
- const discoveredProjects = getDiscoveredProjects()
863
- const data = deriveLocalProjectsSnapshot(store.state, discoveredProjects, machineDisplayName, os.homedir())
864
-
865
- return {
866
- v: PROTOCOL_VERSION,
867
- type: "snapshot",
868
- id,
869
- snapshot: {
870
- type: "local-projects",
871
- data,
872
- },
873
- }
874
- }
875
-
876
- if (topic.type === "keybindings") {
877
- return {
878
- v: PROTOCOL_VERSION,
879
- type: "snapshot",
880
- id,
881
- snapshot: {
882
- type: "keybindings",
883
- data: keybindings.getSnapshot(),
884
- },
885
- }
886
- }
887
-
888
- if (topic.type === "app-settings") {
889
- return {
890
- v: PROTOCOL_VERSION,
891
- type: "snapshot",
892
- id,
893
- snapshot: {
894
- type: "app-settings",
895
- data: resolvedAppSettings.getSnapshot(),
896
- },
897
- }
898
- }
899
-
900
- if (topic.type === "push-config") {
901
- return {
902
- v: PROTOCOL_VERSION,
903
- type: "snapshot",
904
- id,
905
- snapshot: {
906
- type: "push-config",
907
- data: pushManager.getConfigSnapshot(connection?.data.pushDeviceId ?? null),
908
- },
909
- }
910
- }
911
-
912
- if (topic.type === "update") {
913
- return {
914
- v: PROTOCOL_VERSION,
915
- type: "snapshot",
916
- id,
917
- snapshot: {
918
- type: "update",
919
- data: updateManager?.getSnapshot() ?? {
920
- currentVersion: "unknown",
921
- latestVersion: null,
922
- status: "idle",
923
- updateAvailable: false,
924
- lastCheckedAt: null,
925
- error: null,
926
- installAction: "restart",
927
- reloadRequestedAt: null,
928
- },
929
- },
930
- }
931
- }
932
-
933
- if (topic.type === "terminal") {
934
- return {
935
- v: PROTOCOL_VERSION,
936
- type: "snapshot",
937
- id,
938
- snapshot: {
939
- type: "terminal",
940
- data: terminals.getSnapshot(topic.terminalId),
941
- },
942
- }
943
- }
944
-
945
- if (topic.type === "project-git") {
946
- return {
947
- v: PROTOCOL_VERSION,
948
- type: "snapshot",
949
- id,
950
- snapshot: {
951
- type: "project-git",
952
- data: store.getProject(topic.projectId)
953
- ? resolvedDiffStore.getProjectSnapshot(topic.projectId)
954
- : null,
955
- },
956
- }
957
- }
958
-
959
- if (topic.type === "pty-instances") {
960
- return {
961
- v: PROTOCOL_VERSION,
962
- type: "snapshot",
963
- id,
964
- snapshot: {
965
- type: "pty-instances",
966
- data: { instances: ptyInstances?.snapshot() ?? [] },
967
- },
968
- }
969
- }
970
-
971
- if (topic.type === "workflows") {
972
- return {
973
- v: PROTOCOL_VERSION,
974
- type: "snapshot",
975
- id,
976
- snapshot: {
977
- type: "workflows",
978
- data: { chatId: topic.chatId, runs: workflowRegistry?.snapshot(topic.chatId) ?? [] },
979
- },
980
- }
981
- }
982
-
983
- if (topic.type === "orch-runs") {
984
- return {
985
- v: PROTOCOL_VERSION,
986
- type: "snapshot",
987
- id,
988
- snapshot: {
989
- type: "orch-runs",
990
- data: { runs: store.getOrchRuns().map(toOrchRunSummary) },
991
- },
992
- }
993
- }
994
-
995
- return {
996
- v: PROTOCOL_VERSION,
997
- type: "snapshot",
998
- id,
999
- snapshot: {
1000
- type: "chat",
1001
- data: deriveChatSnapshot(
1002
- store.state,
1003
- agent.getActiveStatuses(),
1004
- agent.getDrainingChatIds(),
1005
- agent.getSlashCommandsLoadingChatIds(),
1006
- topic.chatId,
1007
- (chatId) => store.getRecentChatHistory(chatId, topic.recentLimit ?? DEFAULT_CHAT_RECENT_LIMIT),
1008
- (chatId) => store.getTunnelEvents(chatId),
1009
- agent.getWaitStartedAtByChatId(),
1010
- Date.now(),
1011
- agent.getClaudeSessionStates?.() ?? new Map(),
1012
- appSettings?.getSnapshot().customModels ?? [],
1013
- ),
1014
- },
1015
- }
1016
- }
1017
-
1018
- // timings.derivedAtMs = Date.now() on every call, making every snapshot unique
1019
- // and defeating signature-based dedup. Strip timings from the signature so that
1020
- // idle/finished chats are only sent once instead of on every broadcastSnapshots call.
1021
- function getStableChatSnapshotSignature(snapshot: Extract<ServerEnvelope, { type: "snapshot" }>["snapshot"]): string {
1022
- if (snapshot.type === "chat" && snapshot.data?.runtime) {
1023
- const { timings: _t, ...stableRuntime } = snapshot.data.runtime
1024
- return JSON.stringify({ type: snapshot.type, data: { ...snapshot.data, runtime: stableRuntime } })
1025
- }
1026
- return JSON.stringify(snapshot)
1027
- }
1028
-
1029
- async function pushSnapshots(
1030
- ws: ServerWebSocket<ClientState>,
1031
- options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter; cache?: SnapshotComputationCache }
1032
- ) {
1033
- const pushStartedAt = performance.now()
1034
- if (!options?.skipPrune) {
1035
- await maybePruneStaleEmptyChats([ws])
1036
- }
1037
- const snapshotSignatures = ensureSnapshotSignatures(ws)
1038
- let sentCount = 0
1039
- let skippedCount = 0
1040
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1041
- if (!shouldIncludeTopic(topic, options?.filter)) {
1042
- continue
1043
- }
1044
- const envelopeStartedAt = performance.now()
1045
- const envelope = createEnvelope(id, topic, options?.cache, ws)
1046
- const createdAt = performance.now()
1047
- if (envelope.type !== "snapshot") continue
1048
- const signature = topic.type === "sidebar"
1049
- ? getSidebarSnapshotCacheEntry(options?.cache).signature
1050
- : getStableChatSnapshotSignature(envelope.snapshot)
1051
- const signatureReadyAt = topic.type === "sidebar" ? createdAt : performance.now()
1052
- if (snapshotSignatures.get(id) === signature) {
1053
- skippedCount += 1
1054
- continue
1055
- }
1056
- snapshotSignatures.set(id, signature)
1057
- if (topic.type === "chat" && envelope.snapshot.type === "chat" && envelope.snapshot.data?.runtime.status === "starting") {
1058
- const profile = agent.getActiveTurnProfile(topic.chatId)
1059
- logSendToStartingProfile(profile?.traceId, profile?.startedAt, "ws.snapshot_sent", {
1060
- chatId: topic.chatId,
1061
- status: envelope.snapshot.data.runtime.status,
1062
- messageCount: envelope.snapshot.data.messages.length,
1063
- buildMs: Number((createdAt - envelopeStartedAt).toFixed(1)),
1064
- signatureMs: Number((signatureReadyAt - createdAt).toFixed(1)),
1065
- signatureBytes: signature.length,
1066
- })
1067
- }
1068
- const payloadBytes = send(ws, envelope)
1069
- sentCount += 1
1070
- if (topic.type === "chat" && envelope.snapshot.type === "chat" && envelope.snapshot.data?.runtime.status === "starting") {
1071
- const profile = agent.getActiveTurnProfile(topic.chatId)
1072
- logSendToStartingProfile(profile?.traceId, profile?.startedAt, "ws.snapshot_send_completed", {
1073
- chatId: topic.chatId,
1074
- payloadBytes,
1075
- })
1076
- }
1077
- }
1078
- if (isSendToStartingProfilingEnabled()) {
1079
- log.info("[kanna/send->starting][server]", JSON.stringify({
1080
- stage: "ws.push_snapshots_completed",
1081
- elapsedMs: Number((performance.now() - pushStartedAt).toFixed(1)),
1082
- skipPrune: Boolean(options?.skipPrune),
1083
- sentCount,
1084
- skippedCount,
1085
- ...countSubscriptionsByTopic(ws),
1086
- }))
1087
- }
1088
- }
1089
-
1090
- async function broadcastSnapshots() {
1091
- const startedAt = performance.now()
1092
- let socketCount = 0
1093
- const cache: SnapshotComputationCache = {}
1094
- for (const ws of sockets) {
1095
- socketCount += 1
1096
- await pushSnapshots(ws, { skipPrune: true, cache })
1097
- }
1098
- if (isSendToStartingProfilingEnabled()) {
1099
- log.info("[kanna/send->starting][server]", JSON.stringify({
1100
- stage: "ws.broadcast_snapshots_completed",
1101
- elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
1102
- pruneMs: 0,
1103
- socketCount,
1104
- totalChatCount: store.state.chatsById.size,
1105
- totalProjectCount: store.state.projectsById.size,
1106
- }))
1107
- }
1108
- }
1109
-
1110
- async function broadcastFilteredSnapshots(filter: SnapshotBroadcastFilter) {
1111
- const startedAt = performance.now()
1112
- let socketCount = 0
1113
- const cache: SnapshotComputationCache = {}
1114
- for (const ws of sockets) {
1115
- socketCount += 1
1116
- await pushSnapshots(ws, { skipPrune: true, filter, cache })
1117
- }
1118
- if (isSendToStartingProfilingEnabled()) {
1119
- log.info("[kanna/send->starting][server]", JSON.stringify({
1120
- stage: "ws.broadcast_filtered_snapshots_completed",
1121
- elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
1122
- socketCount,
1123
- includeSidebar: Boolean(filter.includeSidebar),
1124
- chatCount: filter.chatIds?.size ?? 0,
1125
- projectCount: filter.projectIds?.size ?? 0,
1126
- }))
1127
- }
1128
- }
1129
-
1130
- function scheduleBroadcast() {
1131
- pendingBroadcastAll = true
1132
- pendingBroadcastChatIds.clear()
1133
- if (pendingBroadcastTimer) {
1134
- return
1135
- }
1136
- pendingBroadcastTimer = setTimeout(() => {
1137
- pendingBroadcastTimer = null
1138
- const shouldBroadcastAll = pendingBroadcastAll
1139
- const chatIds = new Set(pendingBroadcastChatIds)
1140
- pendingBroadcastAll = false
1141
- pendingBroadcastChatIds.clear()
1142
- if (shouldBroadcastAll) {
1143
- void broadcastSnapshots()
1144
- return
1145
- }
1146
- if (chatIds.size > 0) {
1147
- void broadcastFilteredSnapshots({
1148
- includeSidebar: true,
1149
- chatIds,
1150
- })
1151
- }
1152
- }, 16)
1153
- }
1154
-
1155
- function scheduleChatStateBroadcast(chatId: string) {
1156
- if (!pendingBroadcastAll) {
1157
- pendingBroadcastChatIds.add(chatId)
1158
- }
1159
- if (pendingBroadcastTimer) {
1160
- return
1161
- }
1162
- pendingBroadcastTimer = setTimeout(() => {
1163
- pendingBroadcastTimer = null
1164
- const shouldBroadcastAll = pendingBroadcastAll
1165
- const chatIds = new Set(pendingBroadcastChatIds)
1166
- pendingBroadcastAll = false
1167
- pendingBroadcastChatIds.clear()
1168
- if (shouldBroadcastAll) {
1169
- void broadcastSnapshots()
1170
- return
1171
- }
1172
- if (chatIds.size > 0) {
1173
- void broadcastFilteredSnapshots({
1174
- includeSidebar: true,
1175
- chatIds,
1176
- })
1177
- }
1178
- }, 16)
1179
- }
1180
-
1181
- async function broadcastChatAndSidebar(chatId: string) {
1182
- await broadcastFilteredSnapshots({
1183
- includeSidebar: true,
1184
- chatIds: new Set([chatId]),
1185
- })
1186
- }
1187
-
1188
- async function broadcastChatStateImmediately(chatId: string) {
1189
- await broadcastChatAndSidebar(chatId)
1190
- }
1191
-
1192
- function broadcastError(message: string) {
1193
- for (const ws of sockets) {
1194
- send(ws, {
1195
- v: PROTOCOL_VERSION,
1196
- type: "error",
1197
- message,
1198
- })
1199
- }
1200
- }
1201
-
1202
- function pushTerminalSnapshot(terminalId: string) {
1203
- for (const ws of sockets) {
1204
- const snapshotSignatures = ensureSnapshotSignatures(ws)
1205
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1206
- if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
1207
- const envelope = createEnvelope(id, topic, undefined, ws)
1208
- if (envelope.type !== "snapshot") continue
1209
- const signature = JSON.stringify(envelope.snapshot)
1210
- if (snapshotSignatures.get(id) === signature) continue
1211
- snapshotSignatures.set(id, signature)
1212
- send(ws, envelope)
1213
- }
1214
- }
1215
- }
1216
-
1217
- function pushTerminalEvent(terminalId: string, event: Extract<ServerEnvelope, { type: "event" }>["event"]) {
1218
- for (const ws of sockets) {
1219
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1220
- if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
1221
- send(ws, {
1222
- v: PROTOCOL_VERSION,
1223
- type: "event",
1224
- id,
1225
- event,
1226
- })
1227
- }
1228
- }
1229
- }
1230
-
1231
- const disposeTerminalEvents = terminals.onEvent((event) => {
1232
- pushTerminalEvent(event.terminalId, event)
1233
- })
1234
-
1235
- const disposeKeybindingEvents = keybindings.onChange(() => {
1236
- for (const ws of sockets) {
1237
- const snapshotSignatures = ensureSnapshotSignatures(ws)
1238
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1239
- if (topic.type !== "keybindings") continue
1240
- const envelope = createEnvelope(id, topic, undefined, ws)
1241
- if (envelope.type !== "snapshot") continue
1242
- const signature = JSON.stringify(envelope.snapshot)
1243
- if (snapshotSignatures.get(id) === signature) continue
1244
- snapshotSignatures.set(id, signature)
1245
- send(ws, envelope)
1246
- }
1247
- }
1248
- })
1249
-
1250
- const disposeAppSettingsEvents = resolvedAppSettings.onChange(() => {
1251
- for (const ws of sockets) {
1252
- const snapshotSignatures = ensureSnapshotSignatures(ws)
1253
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1254
- if (topic.type !== "app-settings") continue
1255
- const envelope = createEnvelope(id, topic, undefined, ws)
1256
- if (envelope.type !== "snapshot") continue
1257
- const signature = JSON.stringify(envelope.snapshot)
1258
- if (snapshotSignatures.get(id) === signature) continue
1259
- snapshotSignatures.set(id, signature)
1260
- send(ws, envelope)
1261
- }
1262
- }
128
+ const envelopeBuilder = createEnvelopeBuilder({
129
+ store,
130
+ agent,
131
+ resolvedAppSettings,
132
+ keybindings,
133
+ resolvedDiffStore,
134
+ ptyInstances,
135
+ workflowRegistry,
136
+ machineDisplayName,
137
+ updateManager,
138
+ getDiscoveredProjects,
139
+ terminals,
140
+ pushManager,
1263
141
  })
1264
142
 
1265
- const disposeUpdateEvents = updateManager?.onChange(() => {
1266
- for (const ws of sockets) {
1267
- const snapshotSignatures = ensureSnapshotSignatures(ws)
1268
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1269
- if (topic.type !== "update") continue
1270
- const envelope = createEnvelope(id, topic, undefined, ws)
1271
- if (envelope.type !== "snapshot") continue
1272
- const signature = JSON.stringify(envelope.snapshot)
1273
- if (snapshotSignatures.get(id) === signature) continue
1274
- snapshotSignatures.set(id, signature)
1275
- send(ws, envelope)
1276
- }
1277
- }
1278
- }) ?? (() => {})
1279
-
1280
- function pushPtyInstancesEvent(event: PtyInstancesEvent) {
1281
- for (const ws of sockets) {
1282
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1283
- if (topic.type !== "pty-instances") continue
1284
- send(ws, { v: PROTOCOL_VERSION, type: "event", id, event })
1285
- }
1286
- }
1287
- }
1288
-
1289
- const disposePtyInstances: () => void = ptyInstances?.subscribe((delta: PtyInstanceDelta) => {
1290
- if (delta.type === "added") {
1291
- pushPtyInstancesEvent({ type: "pty-instances.added", instance: delta.instance })
1292
- } else if (delta.type === "updated") {
1293
- pushPtyInstancesEvent({ type: "pty-instances.updated", instance: delta.instance })
1294
- } else {
1295
- pushPtyInstancesEvent({ type: "pty-instances.removed", chatId: delta.chatId })
1296
- }
1297
- }) ?? (() => {})
1298
-
1299
- const disposeWorkflows: () => void = workflowRegistry?.subscribe((chatId) => {
1300
- for (const ws of sockets) {
1301
- const snapshotSignatures = ensureSnapshotSignatures(ws)
1302
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1303
- if (topic.type !== "workflows" || topic.chatId !== chatId) continue
1304
- const envelope = createEnvelope(id, topic, undefined, ws)
1305
- if (envelope.type !== "snapshot") continue
1306
- const signature = JSON.stringify(envelope.snapshot)
1307
- if (snapshotSignatures.get(id) === signature) continue
1308
- snapshotSignatures.set(id, signature)
1309
- send(ws, envelope)
1310
- }
1311
- }
1312
- }) ?? (() => {})
1313
-
1314
- const pushOrchRuns = () => {
1315
- for (const ws of sockets) {
1316
- const snapshotSignatures = ensureSnapshotSignatures(ws)
1317
- for (const [id, topic] of ws.data.subscriptions.entries()) {
1318
- if (topic.type !== "orch-runs") continue
1319
- const envelope = createEnvelope(id, topic, undefined, ws)
1320
- if (envelope.type !== "snapshot") continue
1321
- const signature = JSON.stringify(envelope.snapshot)
1322
- if (snapshotSignatures.get(id) === signature) continue
1323
- snapshotSignatures.set(id, signature)
1324
- send(ws, envelope)
1325
- }
1326
- }
1327
- }
1328
- // Optional like the registry subscriptions above: partial store fakes in
1329
- // tests may not implement it. The real EventStore always does.
1330
- const disposeOrchRuns: () => void = typeof store.subscribeOrchRuns === "function"
1331
- ? store.subscribeOrchRuns(pushOrchRuns)
1332
- : () => {}
1333
-
1334
- agent.setBackgroundErrorReporter?.(broadcastError)
143
+ const broadcast = new BroadcastManager({
144
+ agent,
145
+ store,
146
+ terminals,
147
+ keybindings,
148
+ resolvedAppSettings,
149
+ updateManager,
150
+ ptyInstances,
151
+ workflowRegistry,
152
+ envelopeBuilder,
153
+ })
1335
154
 
1336
155
  function resolveChatProject(chatId: string) {
1337
156
  const chat = store.getChat(chatId)
@@ -1345,948 +164,298 @@ export function createWsRouter({
1345
164
  const { command, id } = message
1346
165
  try {
1347
166
  switch (command.type) {
1348
- case "system.ping": {
1349
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1350
- return
1351
- }
1352
- case "update.check": {
1353
- const snapshot = updateManager
1354
- ? await updateManager.checkForUpdates({ force: command.force })
1355
- : {
1356
- currentVersion: "unknown",
1357
- latestVersion: null,
1358
- status: "error",
1359
- updateAvailable: false,
1360
- lastCheckedAt: Date.now(),
1361
- error: "Update manager unavailable.",
1362
- installAction: "restart",
1363
- reloadRequestedAt: null,
1364
- }
1365
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1366
- return
1367
- }
1368
- case "update.install": {
1369
- if (!updateManager) {
1370
- throw new Error("Update manager unavailable.")
1371
- }
1372
- const result = await updateManager.installUpdate({ version: command.version })
1373
- send(ws, {
1374
- v: PROTOCOL_VERSION,
1375
- type: "ack",
1376
- id,
1377
- result,
1378
- })
1379
- return
1380
- }
1381
- case "update.reload": {
1382
- if (!updateManager) {
1383
- throw new Error("Update manager unavailable.")
1384
- }
1385
- const result = await updateManager.forceReload()
1386
- send(ws, {
1387
- v: PROTOCOL_VERSION,
1388
- type: "ack",
1389
- id,
1390
- result,
1391
- })
1392
- return
1393
- }
1394
- case "settings.readKeybindings": {
1395
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: keybindings.getSnapshot() })
1396
- return
1397
- }
1398
- case "settings.writeKeybindings": {
1399
- const snapshot = await keybindings.write(command.bindings)
1400
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1401
- return
1402
- }
1403
- case "settings.readAppSettings": {
1404
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: resolvedAppSettings.getSnapshot() })
1405
- return
1406
- }
1407
- case "settings.writeAppSettings": {
1408
- const previousAnalyticsEnabled = resolvedAppSettings.getSnapshot().analyticsEnabled
1409
- if (previousAnalyticsEnabled && !command.analyticsEnabled) {
1410
- resolvedAnalytics.track("analytics_disabled")
1411
- }
1412
- const snapshot = await resolvedAppSettings.write({ analyticsEnabled: command.analyticsEnabled })
1413
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1414
- if (!previousAnalyticsEnabled && command.analyticsEnabled) {
1415
- resolvedAnalytics.track("analytics_enabled")
1416
- }
1417
- return
1418
- }
1419
- case "appSettings.setCloudflareTunnel": {
1420
- await resolvedAppSettings.setCloudflareTunnel(command.patch)
1421
- const snapshot = resolvedAppSettings.getSnapshot()
1422
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1423
- return
1424
- }
1425
- case "appSettings.setClaudeAuth": {
1426
- await resolvedAppSettings.setClaudeAuth(command.patch)
1427
- const snapshot = resolvedAppSettings.getSnapshot()
1428
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1429
- return
1430
- }
1431
- case "appSettings.testOAuthToken": {
1432
- const result = await testOAuthToken(command.token)
1433
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1434
- return
1435
- }
1436
- case "settings.writeAppSettingsPatch": {
1437
- const previousAnalyticsEnabled = resolvedAppSettings.getSnapshot().analyticsEnabled
1438
- const snapshot = await resolvedAppSettings.writePatch(command.patch)
1439
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1440
-
1441
- // Fire-and-forget auto-test for newly created or updated MCP server.
1442
- const targetId = (() => {
1443
- const ops = command.patch.customMcpServers
1444
- if (!ops) return null
1445
- if (ops.update) return ops.update.id
1446
- if (ops.create) {
1447
- // The created entry is the one with no prior match by name —
1448
- // simplest: pick the entry with the latest createdAt.
1449
- const list = snapshot.customMcpServers
1450
- if (list.length === 0) return null
1451
- return list.reduce((latest, e) => (e.createdAt > latest.createdAt ? e : latest), list[0]!).id
1452
- }
1453
- return null
1454
- })()
1455
- if (targetId) {
1456
- void runMcpAutoTest(targetId, resolvedAppSettings)
1457
- }
1458
-
1459
- if (command.patch.analyticsEnabled !== undefined && previousAnalyticsEnabled && !snapshot.analyticsEnabled) {
1460
- resolvedAnalytics.track("analytics_disabled")
1461
- }
1462
- if (command.patch.analyticsEnabled !== undefined && !previousAnalyticsEnabled && snapshot.analyticsEnabled) {
1463
- resolvedAnalytics.track("analytics_enabled")
1464
- }
1465
- return
1466
- }
1467
- case "subagent.create": {
1468
- const result = await resolvedAppSettings.createSubagent(command.input)
1469
- send(ws, {
1470
- v: PROTOCOL_VERSION,
1471
- type: "ack",
1472
- id,
1473
- result: isSubagentValidationError(result)
1474
- ? { ok: false, error: result }
1475
- : { ok: true, subagent: result },
1476
- })
1477
- return
1478
- }
1479
- case "subagent.update": {
1480
- const result = await resolvedAppSettings.updateSubagent(command.id, command.patch)
1481
- send(ws, {
1482
- v: PROTOCOL_VERSION,
1483
- type: "ack",
1484
- id,
1485
- result: isSubagentValidationError(result)
1486
- ? { ok: false, error: result }
1487
- : { ok: true, subagent: result },
1488
- })
1489
- return
1490
- }
1491
- case "subagent.delete": {
1492
- await resolvedAppSettings.deleteSubagent(command.id)
1493
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: true } })
1494
- return
1495
- }
1496
- case "settings.testMcpServer": {
1497
- const snapshot = resolvedAppSettings.getSnapshot()
1498
- const entry = snapshot.customMcpServers.find((s) => s.id === command.id)
1499
- if (!entry) {
1500
- send(ws, {
1501
- v: PROTOCOL_VERSION,
1502
- type: "ack",
1503
- id,
1504
- result: {
1505
- ok: false,
1506
- message: "MCP server not found",
1507
- lastTest: { status: "error", testedAt: new Date().toISOString(), message: "not found" } as const,
1508
- },
1509
- })
1510
- return
1511
- }
1512
- // Mark pending so the UI sees a spinner while we connect.
1513
- await resolvedAppSettings.writePatch({
1514
- customMcpServers: {
1515
- setTestResult: { id: entry.id, result: { status: "pending", startedAt: new Date().toISOString() } },
167
+ case "settings.readKeybindings":
168
+ case "settings.writeKeybindings":
169
+ case "settings.readAppSettings":
170
+ case "settings.writeAppSettings":
171
+ case "appSettings.setCloudflareTunnel":
172
+ case "appSettings.setClaudeAuth":
173
+ case "appSettings.testOAuthToken":
174
+ case "settings.writeAppSettingsPatch":
175
+ case "subagent.create":
176
+ case "subagent.update":
177
+ case "subagent.delete":
178
+ case "settings.testMcpServer":
179
+ case "settings.startMcpOAuth":
180
+ case "settings.completeMcpOAuth":
181
+ case "settings.readLlmProvider":
182
+ case "settings.listOpenRouterModels":
183
+ case "settings.getChangelog":
184
+ case "settings.writeLlmProvider":
185
+ case "settings.validateLlmProvider":
186
+ case "skills.search":
187
+ case "skills.install":
188
+ case "skills.uninstall":
189
+ case "skills.listInstalled": {
190
+ await handleSettingsCommand(
191
+ {
192
+ keybindings,
193
+ resolvedAppSettings,
194
+ resolvedAnalytics,
195
+ resolvedLlmProvider,
196
+ listOpenRouterModels,
197
+ send: (envelope) => send(ws, envelope),
1516
198
  },
1517
- })
1518
- const testBearer = await resolveMcpTestBearer(entry, resolvedAppSettings)
1519
- const lastTest = await validateMcpServer(entry, testBearer ? { bearer: testBearer } : {})
1520
- await resolvedAppSettings.writePatch({
1521
- customMcpServers: { setTestResult: { id: entry.id, result: lastTest } },
1522
- })
1523
- send(ws, {
1524
- v: PROTOCOL_VERSION,
1525
- type: "ack",
199
+ command,
1526
200
  id,
1527
- result: {
1528
- ok: lastTest.status === "ok",
1529
- message: lastTest.status === "error" ? lastTest.message : undefined,
1530
- lastTest,
1531
- },
1532
- })
1533
- return
1534
- }
1535
- case "settings.startMcpOAuth": {
1536
- const snapshot = resolvedAppSettings.getSnapshot()
1537
- const entry = snapshot.customMcpServers.find((s) => s.id === command.id)
1538
- if (!entry || entry.transport === "stdio") {
1539
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: "not found or unsupported transport" } })
1540
- return
1541
- }
1542
- try {
1543
- const result = await startMcpOAuth(entry, {
1544
- persist: (oauth) => void resolvedAppSettings.writePatch({ customMcpServers: { setOAuthState: { id: entry.id, oauth } } }),
1545
- })
1546
- send(ws, {
1547
- v: PROTOCOL_VERSION, type: "ack", id,
1548
- result: result.kind === "authorizationUrl"
1549
- ? { ok: true, authorizationUrl: result.authorizationUrl }
1550
- : { ok: true, alreadyAuthenticated: true },
1551
- })
1552
- } catch (err) {
1553
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: err instanceof Error ? err.message : "oauth start failed" } })
1554
- }
1555
- return
1556
- }
1557
- case "settings.completeMcpOAuth": {
1558
- const snapshot = resolvedAppSettings.getSnapshot()
1559
- const entry = snapshot.customMcpServers.find((s) => s.id === command.id)
1560
- if (!entry || entry.transport === "stdio") {
1561
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: "not found" } })
1562
- return
1563
- }
1564
- try {
1565
- const result = await completeMcpOAuth(entry, command.callbackUrl, {
1566
- persist: (oauth) => void resolvedAppSettings.writePatch({ customMcpServers: { setOAuthState: { id: entry.id, oauth } } }),
1567
- listTools: async (_serverUrl, accessToken) => {
1568
- const r = await validateMcpServer(entry, { bearer: accessToken })
1569
- return r.status === "ok" ? r.toolCount : 0
1570
- },
1571
- })
1572
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: true, testResult: result } })
1573
- } catch (err) {
1574
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: err instanceof Error ? err.message : "oauth complete failed" } })
1575
- }
1576
- return
1577
- }
1578
- case "settings.readLlmProvider": {
1579
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: await resolvedLlmProvider.read() })
1580
- return
1581
- }
1582
- case "settings.listOpenRouterModels": {
1583
- const models = listOpenRouterModels ? await listOpenRouterModels() : []
1584
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: models })
1585
- return
1586
- }
1587
- case "settings.getChangelog": {
1588
- const releases = await fetchGitHubReleases("cuongtranba/kanna")
1589
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: releases })
1590
- return
1591
- }
1592
- case "settings.writeLlmProvider": {
1593
- const snapshot = await resolvedLlmProvider.write({
1594
- provider: command.provider,
1595
- apiKey: command.apiKey,
1596
- model: command.model,
1597
- baseUrl: command.baseUrl,
1598
- })
1599
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1600
- return
1601
- }
1602
- case "settings.validateLlmProvider": {
1603
- const result = await resolvedLlmProvider.validate({
1604
- provider: command.provider,
1605
- apiKey: command.apiKey,
1606
- model: command.model,
1607
- baseUrl: command.baseUrl,
1608
- })
1609
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1610
- return
1611
- }
1612
- case "skills.search": {
1613
- const snapshot = await searchSkills(command.query, command.limit)
1614
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
1615
- return
1616
- }
1617
- case "skills.install": {
1618
- const result = await installSkill(command.source, command.skillId)
1619
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1620
- return
1621
- }
1622
- case "skills.uninstall": {
1623
- const result = await uninstallSkill(command.skillId)
1624
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1625
- return
1626
- }
1627
- case "skills.listInstalled": {
1628
- const result = await listInstalledSkills()
1629
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1630
- return
1631
- }
1632
- case "project.open": {
1633
- await ensureProjectDirectory(command.localPath)
1634
- const normalizedPath = resolveLocalPath(command.localPath)
1635
- const existingProjectId = store.state.projectIdsByPath.get(normalizedPath)
1636
- const project = await store.openProject(command.localPath)
1637
- await refreshDiscovery()
1638
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id } })
1639
- if (!existingProjectId) {
1640
- resolvedAnalytics.track("project_opened")
1641
- }
1642
- break
1643
- }
1644
- case "project.create": {
1645
- await ensureProjectDirectory(command.localPath)
1646
- const normalizedPath = resolveLocalPath(command.localPath)
1647
- const existingProjectId = store.state.projectIdsByPath.get(normalizedPath)
1648
- const project = await store.openProject(command.localPath, command.title)
1649
- await refreshDiscovery()
1650
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id } })
1651
- if (!existingProjectId) {
1652
- resolvedAnalytics.track("project_opened")
1653
- resolvedAnalytics.track("project_created")
1654
- }
1655
- break
1656
- }
1657
- case "sessions.importClaude": {
1658
- const result = await importClaudeSessions({ store })
1659
- if (result.newProjects > 0) {
1660
- await refreshDiscovery()
1661
- }
1662
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1663
- await broadcastFilteredSnapshots({ includeSidebar: true })
1664
- break
1665
- }
1666
- case "project.remove": {
1667
- const project = store.getProject(command.projectId)
1668
- await store.removeProject(command.projectId)
1669
- if (project) {
1670
- terminals.closeByCwd(project.localPath)
1671
- }
1672
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1673
- resolvedAnalytics.track("project_removed")
1674
- break
1675
- }
1676
- case "project.setStar": {
1677
- await store.setProjectStar(command.projectId, command.starred)
1678
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1679
- await broadcastFilteredSnapshots({ includeSidebar: true })
1680
- return
1681
- }
1682
- case "sidebar.reorderProjectGroups": {
1683
- await store.setSidebarProjectOrder(command.projectIds)
1684
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1685
- await broadcastFilteredSnapshots({ includeSidebar: true })
1686
- return
1687
- }
1688
- case "project.readDiffPatch": {
1689
- const project = store.getProject(command.projectId)
1690
- if (!project) {
1691
- throw new Error("Project not found")
1692
- }
1693
- const result = await resolvedDiffStore.readPatch({
1694
- projectPath: project.localPath,
1695
- path: command.path,
1696
- })
1697
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1698
- return
1699
- }
1700
- case "system.openExternal": {
1701
- await openExternal(command)
1702
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1703
- break
1704
- }
1705
- case "chat.create": {
1706
- const chat = await store.createChat(command.projectId, {
1707
- stackId: command.stackId,
1708
- stackBindings: command.stackBindings,
1709
- })
1710
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
1711
- resolvedAnalytics.track("chat_created")
1712
- await broadcastChatAndSidebar(chat.id)
1713
- return
1714
- }
1715
- case "chat.fork": {
1716
- const result = await agent.forkChat(command.chatId)
1717
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1718
- await broadcastFilteredSnapshots({ includeSidebar: true })
1719
- return
1720
- }
1721
- case "chat.rename": {
1722
- await store.renameChat(command.chatId, command.title)
1723
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1724
- await broadcastChatAndSidebar(command.chatId)
1725
- return
1726
- }
1727
- case "chat.archive": {
1728
- await store.archiveChat(command.chatId)
1729
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1730
- await broadcastFilteredSnapshots({ includeSidebar: true })
1731
- return
1732
- }
1733
- case "chat.unarchive": {
1734
- await store.unarchiveChat(command.chatId)
1735
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1736
- await broadcastChatAndSidebar(command.chatId)
201
+ )
1737
202
  return
1738
203
  }
204
+ case "chat.create":
205
+ case "chat.fork":
206
+ case "chat.rename":
207
+ case "chat.archive":
208
+ case "chat.unarchive":
1739
209
  case "chat.delete": {
1740
- await agent.cancel(command.chatId)
1741
- for (const scheduleId of agent.listLiveSchedules(command.chatId)) {
1742
- await agent.cancelAutoContinue(command.chatId, scheduleId, "chat_deleted")
1743
- }
1744
- await agent.closeChat(command.chatId)
1745
- if (agent.toolCallbackService) {
1746
- await agent.toolCallbackService.cancelAllForChat(command.chatId, "chat_deleted")
1747
- }
1748
- await store.deleteChat(command.chatId)
1749
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1750
- resolvedAnalytics.track("chat_deleted")
1751
- await broadcastFilteredSnapshots({ includeSidebar: true })
1752
- return
1753
- }
1754
- case "autoContinue.accept": {
1755
- await agent.acceptAutoContinue(command.chatId, command.scheduleId, command.scheduledAt)
1756
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1757
- await broadcastChatAndSidebar(command.chatId)
1758
- return
1759
- }
1760
- case "autoContinue.reschedule": {
1761
- await agent.rescheduleAutoContinue(command.chatId, command.scheduleId, command.scheduledAt)
1762
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1763
- await broadcastChatAndSidebar(command.chatId)
1764
- return
1765
- }
1766
- case "autoContinue.cancel": {
1767
- await agent.cancelAutoContinue(command.chatId, command.scheduleId, "user")
1768
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1769
- await broadcastChatAndSidebar(command.chatId)
1770
- return
1771
- }
1772
- case "tunnel.accept": {
1773
- if (tunnelGateway) {
1774
- await tunnelGateway.accept(command.chatId, command.tunnelId)
1775
- }
1776
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1777
- await broadcastChatAndSidebar(command.chatId)
1778
- return
1779
- }
1780
- case "tunnel.stop": {
1781
- if (tunnelGateway) {
1782
- await tunnelGateway.stop(command.chatId, command.tunnelId)
1783
- }
1784
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1785
- await broadcastChatAndSidebar(command.chatId)
210
+ await handleChatCommand(
211
+ {
212
+ store,
213
+ agent,
214
+ analytics: resolvedAnalytics,
215
+ setDraftProtection: (chatIds) => { ws.data.protectedDraftChatIds = new Set(chatIds) },
216
+ logSendProfilingFn: logSendToStartingProfile,
217
+ send: (envelope) => send(ws, envelope),
218
+ broadcastChatAndSidebar: (chatId) => broadcast.broadcastChatAndSidebar(chatId),
219
+ broadcastSidebar: () => broadcast.broadcastFilteredSnapshots({ includeSidebar: true }),
220
+ broadcastAll: () => broadcast.broadcastSnapshots(),
221
+ },
222
+ command,
223
+ id,
224
+ )
1786
225
  return
1787
226
  }
227
+ case "autoContinue.accept":
228
+ case "autoContinue.reschedule":
229
+ case "autoContinue.cancel":
230
+ case "tunnel.accept":
231
+ case "tunnel.stop":
1788
232
  case "tunnel.retry": {
1789
- if (tunnelGateway) {
1790
- await tunnelGateway.retry(command.chatId, command.tunnelId)
1791
- }
1792
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1793
- await broadcastChatAndSidebar(command.chatId)
1794
- return
1795
- }
1796
- case "chat.markRead": {
1797
- await store.setChatReadState(command.chatId, false)
1798
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1799
- await broadcastChatAndSidebar(command.chatId)
1800
- return
1801
- }
1802
- case "chat.setPolicyOverride": {
1803
- await store.setChatPolicyOverride(command.chatId, command.policyOverride ?? null)
1804
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1805
- await broadcastChatAndSidebar(command.chatId)
233
+ await handleAgentCtrlCommand(
234
+ {
235
+ agent,
236
+ tunnelGateway,
237
+ killPtyInstance,
238
+ send: (envelope) => send(ws, envelope),
239
+ broadcastChatAndSidebar: (chatId) => broadcast.broadcastChatAndSidebar(chatId),
240
+ },
241
+ command,
242
+ id,
243
+ )
1806
244
  return
1807
245
  }
1808
- case "chat.setDraftProtection": {
1809
- ws.data.protectedDraftChatIds = new Set(command.chatIds)
1810
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1811
- break
1812
- }
246
+ case "chat.markRead":
247
+ case "chat.setPolicyOverride":
248
+ case "chat.setDraftProtection":
1813
249
  case "chat.send": {
1814
- const result = await agent.send(command)
1815
- const profile = command.clientTraceId && result.chatId
1816
- ? agent.getActiveTurnProfile(result.chatId)
1817
- : null
1818
- logSendToStartingProfile(profile?.traceId ?? command.clientTraceId, profile?.startedAt, "ws.chat_send_ack", {
1819
- chatId: result.chatId ?? null,
1820
- })
1821
- const payloadBytes = send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1822
- logSendToStartingProfile(profile?.traceId ?? command.clientTraceId, profile?.startedAt, "ws.chat_send_ack_completed", {
1823
- chatId: result.chatId ?? null,
1824
- payloadBytes,
1825
- })
1826
- return
1827
- }
1828
- case "chat.refreshDiffs": {
1829
- const { project } = resolveChatProject(command.chatId)
1830
- const changed = await resolvedDiffStore.refreshSnapshot(project.id, project.localPath)
1831
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1832
- if (changed) {
1833
- void broadcastSnapshots()
1834
- }
1835
- return
1836
- }
1837
- case "chat.initGit": {
1838
- const { project } = resolveChatProject(command.chatId)
1839
- const result = await resolvedDiffStore.initializeGit({
1840
- projectId: project.id,
1841
- projectPath: project.localPath,
1842
- })
1843
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1844
- if (result.snapshotChanged) {
1845
- void broadcastSnapshots()
1846
- }
1847
- return
1848
- }
1849
- case "chat.getGitHubPublishInfo": {
1850
- const { project } = resolveChatProject(command.chatId)
1851
- const result = await resolvedDiffStore.getGitHubPublishInfo({
1852
- projectPath: project.localPath,
1853
- })
1854
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1855
- return
1856
- }
1857
- case "chat.checkGitHubRepoAvailability": {
1858
- const result = await resolvedDiffStore.checkGitHubRepoAvailability({
1859
- owner: command.owner,
1860
- name: command.name,
1861
- })
1862
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1863
- return
1864
- }
1865
- case "chat.publishToGitHub": {
1866
- const { project } = resolveChatProject(command.chatId)
1867
- const result = await resolvedDiffStore.publishToGitHub({
1868
- projectId: project.id,
1869
- projectPath: project.localPath,
1870
- owner: command.owner,
1871
- name: command.name,
1872
- visibility: command.visibility,
1873
- description: command.description,
1874
- })
1875
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1876
- if (result.snapshotChanged) {
1877
- void broadcastSnapshots()
1878
- }
1879
- return
1880
- }
1881
- case "chat.listBranches": {
1882
- const { project } = resolveChatProject(command.chatId)
1883
- const result = await resolvedDiffStore.listBranches({
1884
- projectPath: project.localPath,
1885
- })
1886
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1887
- return
1888
- }
1889
- case "chat.previewMergeBranch": {
1890
- const { project } = resolveChatProject(command.chatId)
1891
- const result = await resolvedDiffStore.previewMergeBranch({
1892
- projectPath: project.localPath,
1893
- branch: command.branch,
1894
- })
1895
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1896
- return
1897
- }
1898
- case "chat.mergeBranch": {
1899
- const { project } = resolveChatProject(command.chatId)
1900
- const result = await resolvedDiffStore.mergeBranch({
1901
- projectId: project.id,
1902
- projectPath: project.localPath,
1903
- branch: command.branch,
1904
- })
1905
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1906
- if (result.snapshotChanged) {
1907
- void broadcastSnapshots()
1908
- }
1909
- return
1910
- }
1911
- case "chat.checkoutBranch": {
1912
- const { project } = resolveChatProject(command.chatId)
1913
- const result = await resolvedDiffStore.checkoutBranch({
1914
- projectId: project.id,
1915
- projectPath: project.localPath,
1916
- branch: command.branch,
1917
- bringChanges: command.bringChanges,
1918
- })
1919
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1920
- if (result.snapshotChanged) {
1921
- void broadcastSnapshots()
1922
- }
1923
- return
1924
- }
1925
- case "chat.syncBranch": {
1926
- const { project } = resolveChatProject(command.chatId)
1927
- const result = await resolvedDiffStore.syncBranch({
1928
- projectId: project.id,
1929
- projectPath: project.localPath,
1930
- action: command.action,
1931
- })
1932
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1933
- if (result.snapshotChanged) {
1934
- void broadcastSnapshots()
1935
- }
1936
- return
1937
- }
1938
- case "chat.createBranch": {
1939
- const { project } = resolveChatProject(command.chatId)
1940
- const result = await resolvedDiffStore.createBranch({
1941
- projectId: project.id,
1942
- projectPath: project.localPath,
1943
- name: command.name,
1944
- baseBranchName: command.baseBranchName,
1945
- })
1946
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1947
- if (result.snapshotChanged) {
1948
- void broadcastSnapshots()
1949
- }
1950
- return
1951
- }
1952
- case "chat.generateCommitMessage": {
1953
- const { project } = resolveChatProject(command.chatId)
1954
- const result = await resolvedDiffStore.generateCommitMessage({
1955
- projectPath: project.localPath,
1956
- paths: command.paths,
1957
- })
1958
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1959
- return
1960
- }
1961
- case "chat.commitDiffs": {
1962
- const { project } = resolveChatProject(command.chatId)
1963
- const result = await resolvedDiffStore.commitFiles({
1964
- projectId: project.id,
1965
- projectPath: project.localPath,
1966
- paths: command.paths,
1967
- summary: command.summary,
1968
- description: command.description,
1969
- mode: command.mode,
1970
- })
1971
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1972
- if (result.snapshotChanged) {
1973
- void broadcastSnapshots()
1974
- }
1975
- return
1976
- }
1977
- case "chat.discardDiffFile": {
1978
- const { project } = resolveChatProject(command.chatId)
1979
- const result = await resolvedDiffStore.discardFile({
1980
- projectId: project.id,
1981
- projectPath: project.localPath,
1982
- path: command.path,
1983
- })
1984
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1985
- if (result.snapshotChanged) {
1986
- void broadcastSnapshots()
1987
- }
1988
- return
1989
- }
250
+ await handleChatCommand(
251
+ {
252
+ store,
253
+ agent,
254
+ analytics: resolvedAnalytics,
255
+ setDraftProtection: (chatIds) => { ws.data.protectedDraftChatIds = new Set(chatIds) },
256
+ logSendProfilingFn: logSendToStartingProfile,
257
+ send: (envelope) => send(ws, envelope),
258
+ broadcastChatAndSidebar: (chatId) => broadcast.broadcastChatAndSidebar(chatId),
259
+ broadcastSidebar: () => broadcast.broadcastFilteredSnapshots({ includeSidebar: true }),
260
+ broadcastAll: () => broadcast.broadcastSnapshots(),
261
+ },
262
+ command,
263
+ id,
264
+ )
265
+ return
266
+ }
267
+ case "chat.refreshDiffs":
268
+ case "chat.initGit":
269
+ case "chat.getGitHubPublishInfo":
270
+ case "chat.checkGitHubRepoAvailability":
271
+ case "chat.publishToGitHub":
272
+ case "chat.listBranches":
273
+ case "chat.previewMergeBranch":
274
+ case "chat.mergeBranch":
275
+ case "chat.checkoutBranch":
276
+ case "chat.syncBranch":
277
+ case "chat.createBranch":
278
+ case "chat.generateCommitMessage":
279
+ case "chat.commitDiffs":
280
+ case "chat.discardDiffFile":
1990
281
  case "chat.ignoreDiffFile": {
1991
- const { project } = resolveChatProject(command.chatId)
1992
- const result = await resolvedDiffStore.ignoreFile({
1993
- projectId: project.id,
1994
- projectPath: project.localPath,
1995
- path: command.path,
1996
- })
1997
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1998
- if (result.snapshotChanged) {
1999
- void broadcastSnapshots()
2000
- }
2001
- return
2002
- }
2003
- case "chat.cancel": {
2004
- await agent.cancel(command.chatId)
2005
- // Resolve any open ask-style tool-callback prompts for this chat
2006
- // so the model's tool_use does not hang on a stranded pending. The
2007
- // session-close path no longer fires this cascade because it also
2008
- // ran on transparent respawns (rotation / idle sweep) — see
2009
- // makeClaudeSessionHandle.close() in agent.ts.
2010
- if (agent.toolCallbackService) {
2011
- await agent.toolCallbackService.cancelAllForChat(command.chatId, "chat_cancelled")
2012
- }
2013
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2014
- return
2015
- }
2016
- case "chat.stopDraining": {
2017
- await agent.stopDraining(command.chatId)
2018
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2019
- return
2020
- }
2021
- case "chat.loadHistory": {
2022
- const chat = store.getChat(command.chatId)
2023
- if (!chat) throw new Error("Chat not found")
2024
- const page = store.getMessagesPageBefore(command.chatId, command.beforeCursor, command.limit)
2025
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: page })
2026
- return
2027
- }
2028
- case "chat.respondTool": {
2029
- await agent.respondTool(command)
2030
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2031
- return
2032
- }
2033
- case "chat.toolRequestAnswer": {
2034
- const toolCallbackSvc = agent.toolCallbackService
2035
- if (!toolCallbackSvc) throw new Error("tool callback service unavailable")
2036
- const validKinds = new Set(["allow", "deny", "answer"])
2037
- if (!isRecord(command.decision) || !validKinds.has(typeof command.decision.kind === "string" ? command.decision.kind : "")) {
2038
- throw new Error("Invalid tool request decision kind")
2039
- }
2040
- const existing = store.getToolRequest(command.toolRequestId)
2041
- if (!existing || existing.chatId !== command.chatId) {
2042
- throw new Error("Tool request does not belong to this chat")
2043
- }
2044
- await toolCallbackSvc.answer(command.toolRequestId, command.decision)
2045
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2046
- await broadcastChatAndSidebar(command.chatId)
2047
- return
2048
- }
2049
- case "chat.respondSubagentTool": {
2050
- await agent.respondSubagentTool(command)
2051
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
282
+ await handleDiffCommand(
283
+ {
284
+ resolvedDiffStore,
285
+ resolveChatProject,
286
+ send: (envelope) => send(ws, envelope),
287
+ broadcastSnapshots: () => broadcast.broadcastSnapshots(),
288
+ },
289
+ command,
290
+ id,
291
+ )
2052
292
  return
2053
293
  }
294
+ case "chat.cancel":
295
+ case "chat.stopDraining":
296
+ case "chat.loadHistory":
297
+ case "chat.respondTool":
298
+ case "chat.toolRequestAnswer":
299
+ case "chat.respondSubagentTool":
2054
300
  case "chat.cancelSubagentRun": {
2055
- await agent.cancelSubagentRun(command)
2056
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2057
- return
2058
- }
2059
- case "message.enqueue": {
2060
- const result = await agent.enqueue(command)
2061
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
2062
- await broadcastChatAndSidebar(command.chatId)
2063
- return
2064
- }
2065
- case "message.steer": {
2066
- await agent.steer(command)
2067
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2068
- await broadcastChatAndSidebar(command.chatId)
2069
- return
2070
- }
2071
- case "message.dequeue": {
2072
- await agent.dequeue(command)
2073
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2074
- await broadcastChatAndSidebar(command.chatId)
2075
- return
2076
- }
2077
- case "terminal.create": {
2078
- const project = store.getProject(command.projectId)
2079
- if (!project) {
2080
- throw new Error("Project not found")
2081
- }
2082
- const snapshot = terminals.createTerminal({
2083
- projectPath: project.localPath,
2084
- terminalId: command.terminalId,
2085
- cols: command.cols,
2086
- rows: command.rows,
2087
- scrollback: command.scrollback,
2088
- })
2089
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
2090
- return
2091
- }
2092
- case "terminal.input": {
2093
- terminals.write(command.terminalId, command.data)
2094
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2095
- return
2096
- }
2097
- case "terminal.resize": {
2098
- terminals.resize(command.terminalId, command.cols, command.rows)
2099
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
301
+ await handleChatCommand(
302
+ {
303
+ store,
304
+ agent,
305
+ analytics: resolvedAnalytics,
306
+ setDraftProtection: (chatIds) => { ws.data.protectedDraftChatIds = new Set(chatIds) },
307
+ logSendProfilingFn: logSendToStartingProfile,
308
+ send: (envelope) => send(ws, envelope),
309
+ broadcastChatAndSidebar: (chatId) => broadcast.broadcastChatAndSidebar(chatId),
310
+ broadcastSidebar: () => broadcast.broadcastFilteredSnapshots({ includeSidebar: true }),
311
+ broadcastAll: () => broadcast.broadcastSnapshots(),
312
+ },
313
+ command,
314
+ id,
315
+ )
2100
316
  return
2101
317
  }
318
+ case "message.enqueue":
319
+ case "message.steer":
320
+ case "message.dequeue":
321
+ case "terminal.create":
322
+ case "terminal.input":
323
+ case "terminal.resize":
2102
324
  case "terminal.close": {
2103
- terminals.close(command.terminalId)
2104
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2105
- pushTerminalSnapshot(command.terminalId)
2106
- return
2107
- }
2108
- case "push.identifyDevice": {
2109
- ws.data.pushDeviceId = command.pushDeviceId
2110
- if (command.pushDeviceId) {
2111
- await pushManager.recordDeviceSeen(command.pushDeviceId)
2112
- await broadcastFilteredSnapshots({ includePushConfig: true })
2113
- }
2114
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2115
- return
2116
- }
2117
- case "push.subscribe": {
2118
- const result = await pushManager.addSubscription({
2119
- subscription: command.subscription,
2120
- label: command.label,
2121
- userAgent: command.userAgent,
2122
- })
2123
- ws.data.pushDeviceId = result.id
2124
- await broadcastFilteredSnapshots({ includePushConfig: true })
2125
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
2126
- return
2127
- }
2128
- case "push.unsubscribe": {
2129
- await pushManager.removeSubscription(command.pushDeviceId, "user_revoked")
2130
- if (ws.data.pushDeviceId === command.pushDeviceId) {
2131
- ws.data.pushDeviceId = null
2132
- }
2133
- await broadcastFilteredSnapshots({ includePushConfig: true })
2134
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2135
- return
2136
- }
2137
- case "push.test": {
2138
- if (ws.data.pushDeviceId) {
2139
- await pushManager.sendTest(ws.data.pushDeviceId)
2140
- }
2141
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2142
- return
2143
- }
2144
- case "push.setProjectMute": {
2145
- await pushManager.setProjectMute(command.localPath, command.muted)
2146
- await broadcastFilteredSnapshots({ includePushConfig: true })
2147
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
325
+ await handleMiscCommand(
326
+ {
327
+ store,
328
+ terminals,
329
+ agent,
330
+ sessionShare,
331
+ analytics: resolvedAnalytics,
332
+ listWorktrees,
333
+ getOriginHost: () => ws.data.originHost ?? "",
334
+ send: (envelope) => send(ws, envelope),
335
+ broadcastSidebar: () => broadcast.broadcastFilteredSnapshots({ includeSidebar: true }),
336
+ broadcastChatAndSidebar: (chatId) => broadcast.broadcastChatAndSidebar(chatId),
337
+ pushTerminalSnapshot: (terminalId) => broadcast.pushTerminalSnapshot(terminalId),
338
+ },
339
+ command,
340
+ id,
341
+ )
2148
342
  return
2149
343
  }
344
+ case "push.identifyDevice":
345
+ case "push.subscribe":
346
+ case "push.unsubscribe":
347
+ case "push.test":
348
+ case "push.setProjectMute":
2150
349
  case "push.setFocusedChat": {
2151
- if (ws.data.pushDeviceId) {
2152
- pushManager.setFocusedChat(ws.data.pushDeviceId, command.chatId)
2153
- }
2154
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2155
- return
2156
- }
2157
- case "pty.cancel": {
2158
- try {
2159
- await agent.cancel(command.chatId)
2160
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: true } })
2161
- } catch (err) {
2162
- send(ws, {
2163
- v: PROTOCOL_VERSION,
2164
- type: "ack",
2165
- id,
2166
- result: { ok: false, error: err instanceof Error ? err.message : String(err) },
2167
- })
2168
- }
350
+ await handlePushCommand(
351
+ {
352
+ pushManager,
353
+ getPushDeviceId: () => ws.data.pushDeviceId,
354
+ setPushDeviceId: (did) => { ws.data.pushDeviceId = did },
355
+ send: (envelope) => send(ws, envelope),
356
+ broadcastPushConfig: () => broadcast.broadcastFilteredSnapshots({ includePushConfig: true }),
357
+ },
358
+ command,
359
+ id,
360
+ )
2169
361
  return
2170
362
  }
363
+ case "pty.cancel":
2171
364
  case "pty.kill": {
2172
- if (!killPtyInstance) {
2173
- send(ws, {
2174
- v: PROTOCOL_VERSION,
2175
- type: "ack",
2176
- id,
2177
- result: { ok: false, error: "pty kill not available" },
2178
- })
2179
- return
2180
- }
2181
- const result = await killPtyInstance(command.chatId)
2182
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
2183
- return
2184
- }
2185
- case "stack.create": {
2186
- const stack = await store.createStack(command.title, command.projectIds)
2187
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { stackId: stack.id } })
2188
- resolvedAnalytics.track("stack_created")
2189
- await broadcastFilteredSnapshots({ includeSidebar: true })
2190
- return
2191
- }
2192
- case "stack.rename": {
2193
- await store.renameStack(command.stackId, command.title)
2194
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2195
- await broadcastFilteredSnapshots({ includeSidebar: true })
2196
- return
2197
- }
2198
- case "stack.remove": {
2199
- await store.removeStack(command.stackId)
2200
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2201
- await broadcastFilteredSnapshots({ includeSidebar: true })
2202
- return
2203
- }
2204
- case "stack.addProject": {
2205
- await store.addProjectToStack(command.stackId, command.projectId)
2206
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2207
- await broadcastFilteredSnapshots({ includeSidebar: true })
2208
- return
2209
- }
2210
- case "stack.removeProject": {
2211
- await store.removeProjectFromStack(command.stackId, command.projectId)
2212
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
2213
- await broadcastFilteredSnapshots({ includeSidebar: true })
2214
- return
2215
- }
2216
- case "stack.listWorktrees": {
2217
- const project = store.getProject(command.projectId)
2218
- if (!project) {
2219
- throw new Error("Project not found")
2220
- }
2221
- const worktrees = await listWorktrees(project.localPath)
2222
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { worktrees } })
2223
- return
2224
- }
2225
- case "share.mint": {
2226
- if (!sessionShare) {
2227
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: { kind: "snapshot_write_failed", message: "session-share service unavailable" } } })
2228
- return
2229
- }
2230
- const r = await sessionShare.mintToken(command.payload, ws.data.originHost ?? "")
2231
- const result: ShareCommandResult = r.ok
2232
- ? { ok: true, kind: "mint", data: r.data }
2233
- : { ok: false, error: r.error }
2234
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
2235
- return
2236
- }
2237
- case "share.revoke": {
2238
- if (!sessionShare) {
2239
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: false, error: { kind: "not_found" } } })
2240
- return
2241
- }
2242
- const r = await sessionShare.revokeToken(command.payload)
2243
- const result: ShareCommandResult = r.ok
2244
- ? { ok: true, kind: "revoke", data: r.data }
2245
- : { ok: false, error: r.error }
2246
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
365
+ await handleAgentCtrlCommand(
366
+ {
367
+ agent,
368
+ tunnelGateway,
369
+ killPtyInstance,
370
+ send: (envelope) => send(ws, envelope),
371
+ broadcastChatAndSidebar: (chatId) => broadcast.broadcastChatAndSidebar(chatId),
372
+ },
373
+ command,
374
+ id,
375
+ )
2247
376
  return
2248
377
  }
378
+ case "stack.create":
379
+ case "stack.rename":
380
+ case "stack.remove":
381
+ case "stack.addProject":
382
+ case "stack.removeProject":
383
+ case "stack.listWorktrees":
384
+ case "share.mint":
385
+ case "share.revoke":
2249
386
  case "share.list": {
2250
- if (!sessionShare) {
2251
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: true, kind: "list", data: { shares: [] } } })
2252
- return
2253
- }
2254
- const shares = sessionShare.listSharesForChat(command.payload.chatId, ws.data.originHost ?? "")
2255
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: true, kind: "list", data: { shares } } })
2256
- return
2257
- }
2258
- case "workflows.getRun": {
2259
- const run = workflowRegistry?.getRun(command.chatId, command.runId) ?? null
2260
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: run })
2261
- return
2262
- }
2263
- case "workflows.getAgentTranscript": {
2264
- const entries = workflowRegistry?.getAgentTranscript(command.chatId, command.runId, command.agentId) ?? []
2265
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: entries })
2266
- return
2267
- }
2268
- case "subagents.getRun": {
2269
- const entries = subagentTranscriptRegistry?.getAgentTranscript(command.chatId, command.agentId) ?? []
2270
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: entries })
2271
- return
2272
- }
2273
- case "orch.run": {
2274
- const result = await agent.runOrchestration(command.chatId, command.input)
2275
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
2276
- return
2277
- }
2278
- case "orch.cancelRun": {
2279
- await agent.cancelOrchRun(command.runId)
2280
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { ok: true } })
387
+ await handleMiscCommand(
388
+ {
389
+ store,
390
+ terminals,
391
+ agent,
392
+ sessionShare,
393
+ analytics: resolvedAnalytics,
394
+ listWorktrees,
395
+ getOriginHost: () => ws.data.originHost ?? "",
396
+ send: (envelope) => send(ws, envelope),
397
+ broadcastSidebar: () => broadcast.broadcastFilteredSnapshots({ includeSidebar: true }),
398
+ broadcastChatAndSidebar: (chatId) => broadcast.broadcastChatAndSidebar(chatId),
399
+ pushTerminalSnapshot: (terminalId) => broadcast.pushTerminalSnapshot(terminalId),
400
+ },
401
+ command,
402
+ id,
403
+ )
2281
404
  return
2282
405
  }
406
+ case "workflows.getRun":
407
+ case "workflows.getAgentTranscript":
408
+ case "subagents.getRun":
409
+ case "orch.run":
410
+ case "orch.cancelRun":
2283
411
  case "orch.getRun": {
2284
- send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: agent.getOrchRunDetail(command.runId) })
412
+ await handleOrchCommand(
413
+ {
414
+ agent,
415
+ workflowRegistry,
416
+ subagentTranscriptRegistry,
417
+ send: (envelope) => send(ws, envelope),
418
+ },
419
+ command,
420
+ id,
421
+ )
422
+ return
423
+ }
424
+ case "system.ping":
425
+ case "system.openExternal":
426
+ case "update.check":
427
+ case "update.install":
428
+ case "update.reload":
429
+ case "project.open":
430
+ case "project.create":
431
+ case "project.remove":
432
+ case "project.setStar":
433
+ case "project.readDiffPatch":
434
+ case "sessions.importClaude":
435
+ case "sidebar.reorderProjectGroups": {
436
+ await handleProjectCommand(
437
+ {
438
+ store,
439
+ updateManager,
440
+ diffStore: resolvedDiffStore,
441
+ analytics: resolvedAnalytics,
442
+ refreshDiscovery,
443
+ ensureProjectDirectory,
444
+ resolveLocalPath,
445
+ importClaudeSessionsFn: () => importClaudeSessions({ store }),
446
+ openExternalFn: openExternal,
447
+ terminals,
448
+ send: (envelope) => send(ws, envelope),
449
+ broadcastSidebar: () => broadcast.broadcastFilteredSnapshots({ includeSidebar: true }),
450
+ },
451
+ command,
452
+ id,
453
+ )
2285
454
  return
2286
455
  }
2287
456
  }
2288
457
 
2289
- await broadcastSnapshots()
458
+ await broadcast.broadcastSnapshots()
2290
459
  } catch (error) {
2291
460
  const messageText = error instanceof Error ? error.message : String(error)
2292
461
  const benign = isBenignStaleStateMessage(messageText)
@@ -2302,19 +471,19 @@ export function createWsRouter({
2302
471
 
2303
472
  return {
2304
473
  handleOpen(ws: ServerWebSocket<ClientState>) {
2305
- sockets.add(ws)
474
+ broadcast.addSocket(ws)
2306
475
  },
2307
476
  handleClose(ws: ServerWebSocket<ClientState>) {
2308
477
  if (ws.data.pushDeviceId) {
2309
478
  pushManager.clearFocus(ws.data.pushDeviceId)
2310
479
  }
2311
- sockets.delete(ws)
480
+ broadcast.removeSocket(ws)
2312
481
  },
2313
- broadcastSnapshots,
2314
- broadcastChatStateImmediately,
2315
- scheduleBroadcast,
2316
- scheduleChatStateBroadcast,
2317
- pruneStaleEmptyChats: () => maybePruneStaleEmptyChats(),
482
+ broadcastSnapshots: () => broadcast.broadcastSnapshots(),
483
+ broadcastChatStateImmediately: (chatId: string) => broadcast.broadcastChatStateImmediately(chatId),
484
+ scheduleBroadcast: () => broadcast.scheduleBroadcast(),
485
+ scheduleChatStateBroadcast: (chatId: string) => broadcast.scheduleChatStateBroadcast(chatId),
486
+ pruneStaleEmptyChats: () => broadcast.maybePruneStaleEmptyChats(),
2318
487
  async handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
2319
488
  let parsed: AnyValue
2320
489
  try {
@@ -2339,12 +508,12 @@ export function createWsRouter({
2339
508
  if (parsed.topic.type === "local-projects") {
2340
509
  void refreshDiscovery().then(() => {
2341
510
  if (ws.data.subscriptions.has(parsed.id)) {
2342
- void pushSnapshots(ws, { skipPrune: true })
511
+ void broadcast.pushSnapshots(ws, { skipPrune: true })
2343
512
  }
2344
513
  })
2345
514
  return
2346
515
  }
2347
- await pushSnapshots(ws, { skipPrune: true })
516
+ await broadcast.pushSnapshots(ws, { skipPrune: true })
2348
517
  return
2349
518
  }
2350
519
 
@@ -2352,6 +521,7 @@ export function createWsRouter({
2352
521
  const snapshotSignatures = ensureSnapshotSignatures(ws)
2353
522
  ws.data.subscriptions.delete(parsed.id)
2354
523
  snapshotSignatures.delete(parsed.id)
524
+ ws.data.chatOpSeqBySubId?.delete(parsed.id)
2355
525
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id: parsed.id })
2356
526
  return
2357
527
  }
@@ -2359,91 +529,7 @@ export function createWsRouter({
2359
529
  await handleCommand(ws, parsed)
2360
530
  },
2361
531
  dispose() {
2362
- if (pendingBroadcastTimer) {
2363
- clearTimeout(pendingBroadcastTimer)
2364
- }
2365
- agent.setBackgroundErrorReporter?.(null)
2366
- disposeTerminalEvents()
2367
- disposeKeybindingEvents()
2368
- disposeAppSettingsEvents()
2369
- disposeUpdateEvents()
2370
- disposePtyInstances()
2371
- disposeWorkflows()
2372
- disposeOrchRuns()
532
+ broadcast.dispose()
2373
533
  },
2374
534
  }
2375
535
  }
2376
-
2377
- async function testOAuthToken(token: string): Promise<{ ok: boolean; error: string | null }> {
2378
- const trimmed = typeof token === "string" ? token.trim() : ""
2379
- if (!trimmed) return { ok: false, error: "Token is empty" }
2380
- try {
2381
- const res = await fetch("https://api.anthropic.com/v1/messages", {
2382
- method: "POST",
2383
- headers: {
2384
- "anthropic-version": "2023-06-01",
2385
- "content-type": "application/json",
2386
- "authorization": `Bearer ${trimmed}`,
2387
- },
2388
- body: JSON.stringify({
2389
- model: "claude-haiku-4-5-20251001",
2390
- max_tokens: 1,
2391
- messages: [{ role: "user", content: "ok" }],
2392
- }),
2393
- signal: AbortSignal.timeout(10_000),
2394
- })
2395
- if (res.status === 401 || res.status === 403) return { ok: false, error: "Unauthorized" }
2396
- if (res.status === 429) return { ok: true, error: "Token valid but currently rate-limited" }
2397
- if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
2398
- return { ok: true, error: null }
2399
- } catch (err) {
2400
- if (err instanceof Error && err.name === "TimeoutError") {
2401
- return { ok: false, error: "Request timed out after 10s" }
2402
- }
2403
- return { ok: false, error: err instanceof Error ? err.message : String(err) }
2404
- }
2405
- }
2406
-
2407
- /**
2408
- * For an OAuth-authenticated network server, resolve a fresh access token to
2409
- * inject as a Bearer when probing it — the manual "Test" / auto-test path is
2410
- * otherwise tokenless and a healthy OAuth server 401s. Returns undefined for
2411
- * stdio, static-header, or not-yet-authenticated servers (the probe runs with
2412
- * stored headers only). A refresh failure also yields undefined, so the probe
2413
- * surfaces the unauthorized error that correctly signals re-auth is needed.
2414
- */
2415
- export async function resolveMcpTestBearer(
2416
- entry: McpServerConfig,
2417
- appSettings: { writePatch(p: AppSettingsPatch): Promise<unknown> },
2418
- ): Promise<string | undefined> {
2419
- if (entry.transport === "stdio" || entry.oauth?.status !== "authenticated") return undefined
2420
- try {
2421
- return await ensureFreshMcpToken(entry, {
2422
- persist: (oauth) =>
2423
- void appSettings.writePatch({ customMcpServers: { setOAuthState: { id: entry.id, oauth } } }),
2424
- })
2425
- } catch {
2426
- return undefined
2427
- }
2428
- }
2429
-
2430
- async function runMcpAutoTest(
2431
- id: string,
2432
- appSettings: { getSnapshot(): AppSettingsSnapshot; writePatch(p: AppSettingsPatch): Promise<unknown> },
2433
- ): Promise<void> {
2434
- try {
2435
- const entry = appSettings.getSnapshot().customMcpServers.find((s) => s.id === id)
2436
- if (!entry) return
2437
- await appSettings.writePatch({
2438
- customMcpServers: {
2439
- setTestResult: { id, result: { status: "pending", startedAt: new Date().toISOString() } },
2440
- },
2441
- })
2442
- const bearer = await resolveMcpTestBearer(entry, appSettings)
2443
- const result = await validateMcpServer(entry, bearer ? { bearer } : {})
2444
- await appSettings.writePatch({ customMcpServers: { setTestResult: { id, result } } })
2445
- } catch (err) {
2446
- // Auto-test must never throw; log + swallow.
2447
- log.warn("[kanna/ws-router] runMcpAutoTest failed", String(err))
2448
- }
2449
- }