@cuongtran001/kanna 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-BHj370W7.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
  5. package/dist/client/assets/channel-BP9IMEvd.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
  27. package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
  28. package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
  29. package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
  34. package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.test.ts +22 -6
  55. package/src/server/agent.ts +35 -4987
  56. package/src/server/chat-op-log.test.ts +63 -0
  57. package/src/server/chat-op-log.ts +71 -0
  58. package/src/server/chat-ops-diff.test.ts +120 -0
  59. package/src/server/chat-ops-diff.ts +97 -0
  60. package/src/server/chat-ops-parity.test.ts +140 -0
  61. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  62. package/src/server/claude-autocontinue-commands.ts +256 -0
  63. package/src/server/claude-cancel-handler.test.ts +472 -0
  64. package/src/server/claude-cancel-handler.ts +241 -0
  65. package/src/server/claude-chat-management.test.ts +395 -0
  66. package/src/server/claude-chat-management.ts +251 -0
  67. package/src/server/claude-harness-stream.test.ts +273 -0
  68. package/src/server/claude-harness-stream.ts +158 -0
  69. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  70. package/src/server/claude-loop-orch-commands.ts +466 -0
  71. package/src/server/claude-message-normalizer.test.ts +213 -0
  72. package/src/server/claude-message-normalizer.ts +436 -0
  73. package/src/server/claude-prompt-helpers.test.ts +248 -0
  74. package/src/server/claude-prompt-helpers.ts +139 -0
  75. package/src/server/claude-sdk-queue.test.ts +169 -0
  76. package/src/server/claude-sdk-queue.ts +82 -0
  77. package/src/server/claude-send-command.test.ts +414 -0
  78. package/src/server/claude-send-command.ts +420 -0
  79. package/src/server/claude-session-config-helpers.test.ts +279 -0
  80. package/src/server/claude-session-config-helpers.ts +151 -0
  81. package/src/server/claude-session-config.test.ts +250 -0
  82. package/src/server/claude-session-config.ts +159 -0
  83. package/src/server/claude-session-error-handler.test.ts +498 -0
  84. package/src/server/claude-session-error-handler.ts +361 -0
  85. package/src/server/claude-session-lifecycle.test.ts +669 -0
  86. package/src/server/claude-session-lifecycle.ts +283 -0
  87. package/src/server/claude-session-rebuild.test.ts +224 -0
  88. package/src/server/claude-session-rebuild.ts +101 -0
  89. package/src/server/claude-session-runner.test.ts +523 -0
  90. package/src/server/claude-session-runner.ts +521 -0
  91. package/src/server/claude-session-spawner.test.ts +401 -0
  92. package/src/server/claude-session-spawner.ts +401 -0
  93. package/src/server/claude-session-start.test.ts +205 -0
  94. package/src/server/claude-session-start.ts +277 -0
  95. package/src/server/claude-session-state-queries.test.ts +344 -0
  96. package/src/server/claude-session-state-queries.ts +160 -0
  97. package/src/server/claude-session-state.ts +104 -0
  98. package/src/server/claude-slash-commands.test.ts +277 -0
  99. package/src/server/claude-slash-commands.ts +234 -0
  100. package/src/server/claude-spawn-helpers.test.ts +221 -0
  101. package/src/server/claude-spawn-helpers.ts +179 -0
  102. package/src/server/claude-steer-log.test.ts +243 -0
  103. package/src/server/claude-steer-log.ts +58 -0
  104. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  105. package/src/server/claude-subagent-tool-response.ts +165 -0
  106. package/src/server/claude-subagent-wiring.test.ts +263 -0
  107. package/src/server/claude-subagent-wiring.ts +324 -0
  108. package/src/server/claude-tool-respond.test.ts +321 -0
  109. package/src/server/claude-tool-respond.ts +142 -0
  110. package/src/server/claude-turn-runner.test.ts +310 -0
  111. package/src/server/claude-turn-runner.ts +228 -0
  112. package/src/server/claude-turn-starter.test.ts +266 -0
  113. package/src/server/claude-turn-starter.ts +566 -0
  114. package/src/server/claude-usage-math.test.ts +169 -0
  115. package/src/server/claude-usage-math.ts +132 -0
  116. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  117. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  118. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  119. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  120. package/src/server/diff-store-errors.ts +85 -0
  121. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  122. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  123. package/src/server/diff-store-io.adapter.ts +27 -0
  124. package/src/server/diff-store-parse.test.ts +212 -0
  125. package/src/server/diff-store-parse.ts +122 -0
  126. package/src/server/diff-store-state.ts +82 -0
  127. package/src/server/diff-store.ts +76 -1854
  128. package/src/server/event-store-apply.test.ts +280 -0
  129. package/src/server/event-store-apply.ts +132 -0
  130. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  131. package/src/server/event-store-chat-lifecycle.ts +441 -0
  132. package/src/server/event-store-entity-write.ts +356 -0
  133. package/src/server/event-store-helpers.test.ts +230 -0
  134. package/src/server/event-store-helpers.ts +235 -0
  135. package/src/server/event-store-init.ts +276 -0
  136. package/src/server/event-store-messages.adapter.test.ts +532 -0
  137. package/src/server/event-store-messages.adapter.ts +455 -0
  138. package/src/server/event-store-orch-subscription.ts +124 -0
  139. package/src/server/event-store-orch.test.ts +462 -0
  140. package/src/server/event-store-orch.ts +312 -0
  141. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  142. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  143. package/src/server/event-store-snapshot.test.ts +597 -0
  144. package/src/server/event-store-snapshot.ts +658 -0
  145. package/src/server/event-store-subagent.test.ts +408 -0
  146. package/src/server/event-store-subagent.ts +198 -0
  147. package/src/server/event-store-tool-requests.test.ts +210 -0
  148. package/src/server/event-store-tool-requests.ts +131 -0
  149. package/src/server/event-store-transcript-write.adapter.ts +291 -0
  150. package/src/server/event-store-write-ops.test.ts +554 -0
  151. package/src/server/event-store-write-ops.ts +529 -0
  152. package/src/server/event-store.test.ts +75 -0
  153. package/src/server/event-store.ts +257 -2285
  154. package/src/server/events.ts +1 -1
  155. package/src/server/harness-types.ts +15 -1
  156. package/src/server/oauth-errors.ts +19 -0
  157. package/src/server/storage/backend.ts +7 -0
  158. package/src/server/storage/fs-storage.adapter.ts +21 -1
  159. package/src/server/storage/in-memory-storage.ts +12 -0
  160. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  161. package/src/server/ws-router-agent-ctrl.ts +137 -0
  162. package/src/server/ws-router-broadcast.ts +560 -0
  163. package/src/server/ws-router-chat.test.ts +461 -0
  164. package/src/server/ws-router-chat.ts +279 -0
  165. package/src/server/ws-router-defaults.test.ts +283 -0
  166. package/src/server/ws-router-defaults.ts +434 -0
  167. package/src/server/ws-router-diff.test.ts +167 -0
  168. package/src/server/ws-router-diff.ts +250 -0
  169. package/src/server/ws-router-envelope.ts +360 -0
  170. package/src/server/ws-router-misc.test.ts +422 -0
  171. package/src/server/ws-router-misc.ts +265 -0
  172. package/src/server/ws-router-orch.test.ts +190 -0
  173. package/src/server/ws-router-orch.ts +93 -0
  174. package/src/server/ws-router-project.test.ts +403 -0
  175. package/src/server/ws-router-project.ts +256 -0
  176. package/src/server/ws-router-push.test.ts +193 -0
  177. package/src/server/ws-router-push.ts +120 -0
  178. package/src/server/ws-router-settings.test.ts +173 -0
  179. package/src/server/ws-router-settings.ts +416 -0
  180. package/src/server/ws-router-skills.test.ts +194 -0
  181. package/src/server/ws-router-skills.ts +204 -0
  182. package/src/server/ws-router-utils.ts +238 -0
  183. package/src/server/ws-router.test.ts +90 -0
  184. package/src/server/ws-router.ts +340 -2254
  185. package/src/shared/app-settings-types.ts +365 -0
  186. package/src/shared/chat-ops.test.ts +138 -0
  187. package/src/shared/chat-ops.ts +71 -0
  188. package/src/shared/core-types.ts +27 -0
  189. package/src/shared/git-diff-types.ts +165 -0
  190. package/src/shared/mcp-types.ts +105 -0
  191. package/src/shared/protocol.ts +3 -2
  192. package/src/shared/provider-model-types.ts +437 -0
  193. package/src/shared/subagent-types.ts +198 -0
  194. package/src/shared/tool-call-types.ts +314 -0
  195. package/src/shared/transcript-types.ts +270 -0
  196. package/src/shared/types.ts +31 -1711
  197. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  198. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  199. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  200. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,560 @@
1
+ /**
2
+ * ws-router-broadcast.ts
3
+ *
4
+ * BroadcastManager — owns the connected-socket set, pending-broadcast state,
5
+ * and all snapshot-push / broadcast methods. Wires event subscriptions
6
+ * (terminals, keybindings, appSettings, update, ptyInstances, workflows, orch)
7
+ * and exposes a `dispose()` to tear them down.
8
+ *
9
+ * Extracted from ws-router.ts to reduce its size.
10
+ */
11
+ import { log } from "../shared/log"
12
+ import { PROTOCOL_VERSION } from "../shared/types"
13
+ import type { ServerWebSocket } from "bun"
14
+ import type { PtyInstanceDelta } from "../shared/pty-instance"
15
+ import type { PtyInstanceRegistry } from "./claude-pty/pty-instance-registry"
16
+ import type { WorkflowRegistry } from "./workflow-registry"
17
+ import type { EventStore } from "./event-store"
18
+ import type { AgentCoordinator } from "./agent"
19
+ import type { TerminalManager } from "./terminal-manager"
20
+ import type { KeybindingsManager } from "./keybindings"
21
+ import type { UpdateManager } from "./update-manager"
22
+ import type { ServerEnvelope } from "../shared/protocol"
23
+ import type { ResolvedAppSettings } from "./ws-router-defaults"
24
+ import type { EnvelopeBuilder } from "./ws-router-envelope"
25
+ import {
26
+ countSubscriptionsByTopic,
27
+ ensureChatOpSeqMap,
28
+ ensureSnapshotSignatures,
29
+ getStableChatSnapshotSignature,
30
+ isSendToStartingProfilingEnabled,
31
+ logSendToStartingProfile,
32
+ send,
33
+ shouldIncludeTopic,
34
+ } from "./ws-router-utils"
35
+ import { diffChatMeta } from "./chat-ops-diff"
36
+ import type { ChatMetaSignatures } from "./chat-ops-diff"
37
+ import type { ClientState, SnapshotBroadcastFilter, SnapshotComputationCache } from "./ws-router-utils"
38
+
39
+ // ── Deps ──────────────────────────────────────────────────────────────────────
40
+
41
+ export interface BroadcastManagerDeps {
42
+ agent: AgentCoordinator
43
+ store: EventStore
44
+ terminals: TerminalManager
45
+ keybindings: KeybindingsManager
46
+ resolvedAppSettings: ResolvedAppSettings
47
+ updateManager: UpdateManager | null
48
+ ptyInstances?: PtyInstanceRegistry
49
+ workflowRegistry?: WorkflowRegistry
50
+ envelopeBuilder: EnvelopeBuilder
51
+ }
52
+
53
+ // ── BroadcastManager ─────────────────────────────────────────────────────────
54
+
55
+ export class BroadcastManager {
56
+ private readonly sockets = new Set<ServerWebSocket<ClientState>>()
57
+ /** Last-recorded meta signatures per chat for the chat.ops diff. */
58
+ private readonly metaSigsByChatId = new Map<string, ChatMetaSignatures>()
59
+ private pendingBroadcastTimer: ReturnType<typeof setTimeout> | null = null
60
+ private pendingBroadcastAll = false
61
+ private readonly pendingBroadcastChatIds = new Set<string>()
62
+
63
+ private readonly disposeTerminalEvents: () => void
64
+ private readonly disposeKeybindingEvents: () => void
65
+ private readonly disposeAppSettingsEvents: () => void
66
+ private readonly disposeUpdateEvents: () => void
67
+ private readonly disposePtyInstances: () => void
68
+ private readonly disposeWorkflows: () => void
69
+ private readonly disposeOrchRuns: () => void
70
+
71
+ constructor(private readonly deps: BroadcastManagerDeps) {
72
+ const {
73
+ agent,
74
+ terminals,
75
+ keybindings,
76
+ resolvedAppSettings,
77
+ updateManager,
78
+ ptyInstances,
79
+ workflowRegistry,
80
+ store,
81
+ } = deps
82
+
83
+ // Wire background error reporter
84
+ agent.setBackgroundErrorReporter?.(this.broadcastError.bind(this))
85
+
86
+ // Terminal events
87
+ this.disposeTerminalEvents = terminals.onEvent((event) => {
88
+ this.pushTerminalEvent(event.terminalId, event)
89
+ })
90
+
91
+ // Keybinding snapshot push on change
92
+ this.disposeKeybindingEvents = keybindings.onChange(() => {
93
+ for (const ws of this.sockets) {
94
+ const snapshotSignatures = ensureSnapshotSignatures(ws)
95
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
96
+ if (topic.type !== "keybindings") continue
97
+ const envelope = deps.envelopeBuilder.createEnvelope(id, topic, undefined, ws)
98
+ if (envelope.type !== "snapshot") continue
99
+ const signature = JSON.stringify(envelope.snapshot)
100
+ if (snapshotSignatures.get(id) === signature) continue
101
+ snapshotSignatures.set(id, signature)
102
+ send(ws, envelope)
103
+ }
104
+ }
105
+ })
106
+
107
+ // App-settings snapshot push on change
108
+ this.disposeAppSettingsEvents = resolvedAppSettings.onChange(() => {
109
+ for (const ws of this.sockets) {
110
+ const snapshotSignatures = ensureSnapshotSignatures(ws)
111
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
112
+ if (topic.type !== "app-settings") continue
113
+ const envelope = deps.envelopeBuilder.createEnvelope(id, topic, undefined, ws)
114
+ if (envelope.type !== "snapshot") continue
115
+ const signature = JSON.stringify(envelope.snapshot)
116
+ if (snapshotSignatures.get(id) === signature) continue
117
+ snapshotSignatures.set(id, signature)
118
+ send(ws, envelope)
119
+ }
120
+ }
121
+ })
122
+
123
+ // Update snapshot push on change
124
+ this.disposeUpdateEvents = updateManager?.onChange(() => {
125
+ for (const ws of this.sockets) {
126
+ const snapshotSignatures = ensureSnapshotSignatures(ws)
127
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
128
+ if (topic.type !== "update") continue
129
+ const envelope = deps.envelopeBuilder.createEnvelope(id, topic, undefined, ws)
130
+ if (envelope.type !== "snapshot") continue
131
+ const signature = JSON.stringify(envelope.snapshot)
132
+ if (snapshotSignatures.get(id) === signature) continue
133
+ snapshotSignatures.set(id, signature)
134
+ send(ws, envelope)
135
+ }
136
+ }
137
+ }) ?? (() => {})
138
+
139
+ // PTY instance events
140
+ this.disposePtyInstances = ptyInstances?.subscribe((delta: PtyInstanceDelta) => {
141
+ if (delta.type === "added") {
142
+ this.pushPtyInstancesEvent({ type: "pty-instances.added", instance: delta.instance })
143
+ } else if (delta.type === "updated") {
144
+ this.pushPtyInstancesEvent({ type: "pty-instances.updated", instance: delta.instance })
145
+ } else {
146
+ this.pushPtyInstancesEvent({ type: "pty-instances.removed", chatId: delta.chatId })
147
+ }
148
+ }) ?? (() => {})
149
+
150
+ // Workflow snapshot push on change
151
+ this.disposeWorkflows = workflowRegistry?.subscribe((chatId) => {
152
+ for (const ws of this.sockets) {
153
+ const snapshotSignatures = ensureSnapshotSignatures(ws)
154
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
155
+ if (topic.type !== "workflows" || topic.chatId !== chatId) continue
156
+ const envelope = deps.envelopeBuilder.createEnvelope(id, topic, undefined, ws)
157
+ if (envelope.type !== "snapshot") continue
158
+ const signature = JSON.stringify(envelope.snapshot)
159
+ if (snapshotSignatures.get(id) === signature) continue
160
+ snapshotSignatures.set(id, signature)
161
+ send(ws, envelope)
162
+ }
163
+ }
164
+ }) ?? (() => {})
165
+
166
+ // Orchestration run pushes
167
+ // Optional like the registry subscriptions above: partial store fakes in
168
+ // tests may not implement it. The real EventStore always does.
169
+ this.disposeOrchRuns = typeof store.subscribeOrchRuns === "function"
170
+ ? store.subscribeOrchRuns(() => this.pushOrchRuns())
171
+ : () => {}
172
+ }
173
+
174
+ // ── Socket lifecycle ────────────────────────────────────────────────────────
175
+
176
+ addSocket(ws: ServerWebSocket<ClientState>): void {
177
+ this.sockets.add(ws)
178
+ }
179
+
180
+ removeSocket(ws: ServerWebSocket<ClientState>): void {
181
+ this.sockets.delete(ws)
182
+ }
183
+
184
+ // ── Protected-chat helpers ──────────────────────────────────────────────────
185
+
186
+ getProtectedChatIds(): Set<string> {
187
+ const { agent } = this.deps
188
+ const activeStatuses = agent.getActiveStatuses()
189
+ const drainingChatIds = typeof agent.getDrainingChatIds === "function"
190
+ ? agent.getDrainingChatIds()
191
+ : new Set<string>()
192
+ return new Set([
193
+ ...activeStatuses.keys(),
194
+ ...drainingChatIds.values(),
195
+ ])
196
+ }
197
+
198
+ getProtectedDraftChatIds(extraSockets?: Iterable<ServerWebSocket<ClientState>>): Set<string> {
199
+ const protectedChatIds = new Set<string>()
200
+
201
+ for (const socket of this.sockets) {
202
+ for (const chatId of socket.data.protectedDraftChatIds ?? []) {
203
+ protectedChatIds.add(chatId)
204
+ }
205
+ }
206
+
207
+ for (const socket of extraSockets ?? []) {
208
+ for (const chatId of socket.data.protectedDraftChatIds ?? []) {
209
+ protectedChatIds.add(chatId)
210
+ }
211
+ }
212
+
213
+ return protectedChatIds
214
+ }
215
+
216
+ async maybePruneStaleEmptyChats(extraSockets?: Iterable<ServerWebSocket<ClientState>>): Promise<void> {
217
+ const { store } = this.deps
218
+ const startedAt = performance.now()
219
+ const activeChatIds = this.getProtectedChatIds()
220
+ const protectedDraftChatIds = this.getProtectedDraftChatIds(extraSockets)
221
+ const prunedChatIds = await store.pruneStaleEmptyChats?.({
222
+ activeChatIds,
223
+ protectedChatIds: protectedDraftChatIds,
224
+ })
225
+ if (isSendToStartingProfilingEnabled()) {
226
+ log.info("[kanna/send->starting][server]", JSON.stringify({
227
+ stage: "ws.prune_stale_empty_chats",
228
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
229
+ activeChatCount: activeChatIds.size,
230
+ protectedDraftChatCount: protectedDraftChatIds.size,
231
+ prunedCount: prunedChatIds?.length ?? 0,
232
+ totalChatCount: store.state.chatsById.size,
233
+ totalProjectCount: store.state.projectsById.size,
234
+ }))
235
+ }
236
+ }
237
+
238
+ // ── Chat ops (delta) path ───────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Records runtime/section/pending delta ops for a chat into the op-log
242
+ * (once per broadcast pass — deduped via the shared computation cache).
243
+ * Ordering authority is the op-log itself: subscribers only ever read
244
+ * batches out of `chatOps.since`.
245
+ */
246
+ private recordMetaOps(chatId: string, cache?: SnapshotComputationCache): void {
247
+ const { store, envelopeBuilder } = this.deps
248
+ if (typeof store.chatOps?.record !== "function") return
249
+ if (typeof envelopeBuilder.deriveChatMeta !== "function") return
250
+ if (cache) {
251
+ cache.chatMetaOpsRecordedChatIds ??= new Set()
252
+ if (cache.chatMetaOpsRecordedChatIds.has(chatId)) return
253
+ cache.chatMetaOpsRecordedChatIds.add(chatId)
254
+ }
255
+ const meta = envelopeBuilder.deriveChatMeta(chatId)
256
+ if (!meta) return
257
+ const { ops, next } = diffChatMeta(this.metaSigsByChatId.get(chatId), meta)
258
+ this.metaSigsByChatId.set(chatId, next)
259
+ for (const op of ops) {
260
+ store.chatOps.record(chatId, op)
261
+ }
262
+ }
263
+
264
+ // ── Snapshot push ───────────────────────────────────────────────────────────
265
+
266
+ async pushSnapshots(
267
+ ws: ServerWebSocket<ClientState>,
268
+ options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter; cache?: SnapshotComputationCache }
269
+ ): Promise<void> {
270
+ const { agent } = this.deps
271
+ const pushStartedAt = performance.now()
272
+ if (!options?.skipPrune) {
273
+ await this.maybePruneStaleEmptyChats([ws])
274
+ }
275
+ const snapshotSignatures = ensureSnapshotSignatures(ws)
276
+ let sentCount = 0
277
+ let skippedCount = 0
278
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
279
+ if (!shouldIncludeTopic(topic, options?.filter)) {
280
+ continue
281
+ }
282
+ if (topic.type === "chat" && typeof this.deps.store.chatOps?.since === "function") {
283
+ const seqMap = ensureChatOpSeqMap(ws)
284
+ const tracked = seqMap.get(id)
285
+ if (tracked !== undefined) {
286
+ this.recordMetaOps(topic.chatId, options?.cache)
287
+ const batch = this.deps.store.chatOps.since(topic.chatId, tracked)
288
+ if (batch !== null) {
289
+ if (batch.ops.length === 0) {
290
+ skippedCount += 1
291
+ continue
292
+ }
293
+ seqMap.set(id, batch.toSeq)
294
+ send(ws, {
295
+ v: PROTOCOL_VERSION,
296
+ type: "event",
297
+ id,
298
+ event: {
299
+ type: "chat.ops",
300
+ chatId: topic.chatId,
301
+ fromSeq: batch.fromSeq,
302
+ toSeq: batch.toSeq,
303
+ ops: batch.ops,
304
+ },
305
+ })
306
+ sentCount += 1
307
+ continue
308
+ }
309
+ // Ring gap — drop tracking AND the snapshot signature so the
310
+ // fallback full snapshot below always sends and re-arms the
311
+ // delta path (a signature-dedup skip here would strand the
312
+ // subscriber outside both paths).
313
+ seqMap.delete(id)
314
+ snapshotSignatures.delete(id)
315
+ }
316
+ }
317
+ const envelopeStartedAt = performance.now()
318
+ const envelope = this.deps.envelopeBuilder.createEnvelope(id, topic, options?.cache, ws)
319
+ const createdAt = performance.now()
320
+ if (envelope.type !== "snapshot") continue
321
+ const signature = topic.type === "sidebar"
322
+ ? this.deps.envelopeBuilder.getSidebarSnapshotCacheEntry(options?.cache).signature
323
+ : getStableChatSnapshotSignature(envelope.snapshot)
324
+ const signatureReadyAt = topic.type === "sidebar" ? createdAt : performance.now()
325
+ if (snapshotSignatures.get(id) === signature) {
326
+ skippedCount += 1
327
+ continue
328
+ }
329
+ snapshotSignatures.set(id, signature)
330
+ if (topic.type === "chat" && envelope.snapshot.type === "chat" && envelope.snapshot.data?.runtime.status === "starting") {
331
+ const profile = agent.getActiveTurnProfile(topic.chatId)
332
+ logSendToStartingProfile(profile?.traceId, profile?.startedAt, "ws.snapshot_sent", {
333
+ chatId: topic.chatId,
334
+ status: envelope.snapshot.data.runtime.status,
335
+ messageCount: envelope.snapshot.data.messages.length,
336
+ buildMs: Number((createdAt - envelopeStartedAt).toFixed(1)),
337
+ signatureMs: Number((signatureReadyAt - createdAt).toFixed(1)),
338
+ signatureBytes: signature.length,
339
+ })
340
+ }
341
+ const payloadBytes = send(ws, envelope)
342
+ sentCount += 1
343
+ if (topic.type === "chat" && envelope.snapshot.type === "chat") {
344
+ const dataSeq = envelope.snapshot.data?.seq
345
+ if (dataSeq !== undefined) {
346
+ ensureChatOpSeqMap(ws).set(id, dataSeq)
347
+ }
348
+ }
349
+ if (topic.type === "chat" && envelope.snapshot.type === "chat" && envelope.snapshot.data?.runtime.status === "starting") {
350
+ const profile = agent.getActiveTurnProfile(topic.chatId)
351
+ logSendToStartingProfile(profile?.traceId, profile?.startedAt, "ws.snapshot_send_completed", {
352
+ chatId: topic.chatId,
353
+ payloadBytes,
354
+ })
355
+ }
356
+ }
357
+ if (isSendToStartingProfilingEnabled()) {
358
+ log.info("[kanna/send->starting][server]", JSON.stringify({
359
+ stage: "ws.push_snapshots_completed",
360
+ elapsedMs: Number((performance.now() - pushStartedAt).toFixed(1)),
361
+ skipPrune: Boolean(options?.skipPrune),
362
+ sentCount,
363
+ skippedCount,
364
+ ...countSubscriptionsByTopic(ws),
365
+ }))
366
+ }
367
+ }
368
+
369
+ // ── Broadcast ───────────────────────────────────────────────────────────────
370
+
371
+ async broadcastSnapshots(): Promise<void> {
372
+ const { store } = this.deps
373
+ const startedAt = performance.now()
374
+ let socketCount = 0
375
+ const cache: SnapshotComputationCache = {}
376
+ for (const ws of this.sockets) {
377
+ socketCount += 1
378
+ await this.pushSnapshots(ws, { skipPrune: true, cache })
379
+ }
380
+ if (isSendToStartingProfilingEnabled()) {
381
+ log.info("[kanna/send->starting][server]", JSON.stringify({
382
+ stage: "ws.broadcast_snapshots_completed",
383
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
384
+ pruneMs: 0,
385
+ socketCount,
386
+ totalChatCount: store.state.chatsById.size,
387
+ totalProjectCount: store.state.projectsById.size,
388
+ }))
389
+ }
390
+ }
391
+
392
+ async broadcastFilteredSnapshots(filter: SnapshotBroadcastFilter): Promise<void> {
393
+ const startedAt = performance.now()
394
+ let socketCount = 0
395
+ const cache: SnapshotComputationCache = {}
396
+ for (const ws of this.sockets) {
397
+ socketCount += 1
398
+ await this.pushSnapshots(ws, { skipPrune: true, filter, cache })
399
+ }
400
+ if (isSendToStartingProfilingEnabled()) {
401
+ log.info("[kanna/send->starting][server]", JSON.stringify({
402
+ stage: "ws.broadcast_filtered_snapshots_completed",
403
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
404
+ socketCount,
405
+ includeSidebar: Boolean(filter.includeSidebar),
406
+ chatCount: filter.chatIds?.size ?? 0,
407
+ projectCount: filter.projectIds?.size ?? 0,
408
+ }))
409
+ }
410
+ }
411
+
412
+ scheduleBroadcast(): void {
413
+ this.pendingBroadcastAll = true
414
+ this.pendingBroadcastChatIds.clear()
415
+ if (this.pendingBroadcastTimer) {
416
+ return
417
+ }
418
+ this.pendingBroadcastTimer = setTimeout(() => {
419
+ this.pendingBroadcastTimer = null
420
+ const shouldBroadcastAll = this.pendingBroadcastAll
421
+ const chatIds = new Set(this.pendingBroadcastChatIds)
422
+ this.pendingBroadcastAll = false
423
+ this.pendingBroadcastChatIds.clear()
424
+ if (shouldBroadcastAll) {
425
+ void this.broadcastSnapshots()
426
+ return
427
+ }
428
+ if (chatIds.size > 0) {
429
+ void this.broadcastFilteredSnapshots({
430
+ includeSidebar: true,
431
+ chatIds,
432
+ })
433
+ }
434
+ }, 16)
435
+ }
436
+
437
+ scheduleChatStateBroadcast(chatId: string): void {
438
+ if (!this.pendingBroadcastAll) {
439
+ this.pendingBroadcastChatIds.add(chatId)
440
+ }
441
+ if (this.pendingBroadcastTimer) {
442
+ return
443
+ }
444
+ this.pendingBroadcastTimer = setTimeout(() => {
445
+ this.pendingBroadcastTimer = null
446
+ const shouldBroadcastAll = this.pendingBroadcastAll
447
+ const chatIds = new Set(this.pendingBroadcastChatIds)
448
+ this.pendingBroadcastAll = false
449
+ this.pendingBroadcastChatIds.clear()
450
+ if (shouldBroadcastAll) {
451
+ void this.broadcastSnapshots()
452
+ return
453
+ }
454
+ if (chatIds.size > 0) {
455
+ void this.broadcastFilteredSnapshots({
456
+ includeSidebar: true,
457
+ chatIds,
458
+ })
459
+ }
460
+ }, 16)
461
+ }
462
+
463
+ async broadcastChatAndSidebar(chatId: string): Promise<void> {
464
+ await this.broadcastFilteredSnapshots({
465
+ includeSidebar: true,
466
+ chatIds: new Set([chatId]),
467
+ })
468
+ }
469
+
470
+ async broadcastChatStateImmediately(chatId: string): Promise<void> {
471
+ await this.broadcastChatAndSidebar(chatId)
472
+ }
473
+
474
+ broadcastError(message: string): void {
475
+ for (const ws of this.sockets) {
476
+ send(ws, {
477
+ v: PROTOCOL_VERSION,
478
+ type: "error",
479
+ message,
480
+ })
481
+ }
482
+ }
483
+
484
+ // ── Per-topic targeted pushes ───────────────────────────────────────────────
485
+
486
+ pushTerminalSnapshot(terminalId: string): void {
487
+ for (const ws of this.sockets) {
488
+ const snapshotSignatures = ensureSnapshotSignatures(ws)
489
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
490
+ if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
491
+ const envelope = this.deps.envelopeBuilder.createEnvelope(id, topic, undefined, ws)
492
+ if (envelope.type !== "snapshot") continue
493
+ const signature = JSON.stringify(envelope.snapshot)
494
+ if (snapshotSignatures.get(id) === signature) continue
495
+ snapshotSignatures.set(id, signature)
496
+ send(ws, envelope)
497
+ }
498
+ }
499
+ }
500
+
501
+ pushTerminalEvent(
502
+ terminalId: string,
503
+ event: Extract<ServerEnvelope, { type: "event" }>["event"]
504
+ ): void {
505
+ for (const ws of this.sockets) {
506
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
507
+ if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
508
+ send(ws, {
509
+ v: PROTOCOL_VERSION,
510
+ type: "event",
511
+ id,
512
+ event,
513
+ })
514
+ }
515
+ }
516
+ }
517
+
518
+ private pushPtyInstancesEvent(
519
+ event: Extract<ServerEnvelope, { type: "event" }>["event"]
520
+ ): void {
521
+ for (const ws of this.sockets) {
522
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
523
+ if (topic.type !== "pty-instances") continue
524
+ send(ws, { v: PROTOCOL_VERSION, type: "event", id, event })
525
+ }
526
+ }
527
+ }
528
+
529
+ private pushOrchRuns(): void {
530
+ for (const ws of this.sockets) {
531
+ const snapshotSignatures = ensureSnapshotSignatures(ws)
532
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
533
+ if (topic.type !== "orch-runs") continue
534
+ const envelope = this.deps.envelopeBuilder.createEnvelope(id, topic, undefined, ws)
535
+ if (envelope.type !== "snapshot") continue
536
+ const signature = JSON.stringify(envelope.snapshot)
537
+ if (snapshotSignatures.get(id) === signature) continue
538
+ snapshotSignatures.set(id, signature)
539
+ send(ws, envelope)
540
+ }
541
+ }
542
+ }
543
+
544
+ // ── Cleanup ─────────────────────────────────────────────────────────────────
545
+
546
+ dispose(): void {
547
+ if (this.pendingBroadcastTimer) {
548
+ clearTimeout(this.pendingBroadcastTimer)
549
+ this.pendingBroadcastTimer = null
550
+ }
551
+ this.deps.agent.setBackgroundErrorReporter?.(null)
552
+ this.disposeTerminalEvents()
553
+ this.disposeKeybindingEvents()
554
+ this.disposeAppSettingsEvents()
555
+ this.disposeUpdateEvents()
556
+ this.disposePtyInstances()
557
+ this.disposeWorkflows()
558
+ this.disposeOrchRuns()
559
+ }
560
+ }