kanna-code 0.26.9 → 0.26.10

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.
@@ -91,7 +91,7 @@ interface ClaudeSessionState {
91
91
 
92
92
  interface AgentCoordinatorArgs {
93
93
  store: EventStore
94
- onStateChange: () => void
94
+ onStateChange: (chatId?: string) => void
95
95
  codexManager?: CodexAppServerManager
96
96
  generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
97
97
  startClaudeSession?: (args: {
@@ -629,7 +629,7 @@ async function startClaudeSession(args: {
629
629
 
630
630
  export class AgentCoordinator {
631
631
  private readonly store: EventStore
632
- private readonly onStateChange: () => void
632
+ private readonly onStateChange: (chatId?: string) => void
633
633
  private readonly codexManager: CodexAppServerManager
634
634
  private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
635
635
  private readonly startClaudeSessionFn: NonNullable<AgentCoordinatorArgs["startClaudeSession"]>
@@ -668,6 +668,10 @@ export class AgentCoordinator {
668
668
  return new Set(this.drainingStreams.keys())
669
669
  }
670
670
 
671
+ private emitStateChange(chatId?: string) {
672
+ this.onStateChange(chatId)
673
+ }
674
+
671
675
  getActiveTurnProfile(chatId: string): SendToStartingProfile | null {
672
676
  const active = this.activeTurns.get(chatId)
673
677
  if (!active?.clientTraceId || active.profilingStartedAt === undefined) {
@@ -685,7 +689,7 @@ export class AgentCoordinator {
685
689
  if (!draining) return
686
690
  draining.turn.close()
687
691
  this.drainingStreams.delete(chatId)
688
- this.onStateChange()
692
+ this.emitStateChange(chatId)
689
693
  }
690
694
 
691
695
  async closeChat(chatId: string) {
@@ -695,7 +699,7 @@ export class AgentCoordinator {
695
699
  claudeSession.session.close()
696
700
  this.claudeSessions.delete(chatId)
697
701
  }
698
- this.onStateChange()
702
+ this.emitStateChange(chatId)
699
703
  }
700
704
 
701
705
  private resolveProvider(command: Extract<ClientCommand, { type: "chat.send" }>, currentProvider: AgentProvider | null) {
@@ -813,7 +817,7 @@ export class AgentCoordinator {
813
817
  }
814
818
 
815
819
  active.status = "waiting_for_user"
816
- this.onStateChange()
820
+ this.emitStateChange(args.chatId)
817
821
 
818
822
  return await new Promise<unknown>((resolve) => {
819
823
  active.pendingTool = {
@@ -901,7 +905,7 @@ export class AgentCoordinator {
901
905
  chatId: args.chatId,
902
906
  status: active.status,
903
907
  })
904
- this.onStateChange()
908
+ this.emitStateChange(args.chatId)
905
909
  logSendToStartingProfile(args.profile, "start_turn.state_change_emitted", {
906
910
  chatId: args.chatId,
907
911
  status: active.status,
@@ -921,7 +925,7 @@ export class AgentCoordinator {
921
925
  }
922
926
  }
923
927
  await this.store.appendMessage(args.chatId, timestamped({ kind: "account_info", accountInfo }))
924
- this.onStateChange()
928
+ this.emitStateChange(args.chatId)
925
929
  })
926
930
  .catch(() => undefined)
927
931
  }
@@ -1055,7 +1059,7 @@ export class AgentCoordinator {
1055
1059
  if (event.type === "session_token" && event.sessionToken) {
1056
1060
  session.sessionToken = event.sessionToken
1057
1061
  await this.store.setSessionToken(session.chatId, event.sessionToken)
1058
- this.onStateChange()
1062
+ this.emitStateChange(session.chatId)
1059
1063
  continue
1060
1064
  }
1061
1065
 
@@ -1077,7 +1081,7 @@ export class AgentCoordinator {
1077
1081
  this.activeTurns.delete(session.chatId)
1078
1082
  }
1079
1083
 
1080
- this.onStateChange()
1084
+ this.emitStateChange(session.chatId)
1081
1085
  }
1082
1086
  } catch (error) {
1083
1087
  const active = this.activeTurns.get(session.chatId)
@@ -1105,7 +1109,7 @@ export class AgentCoordinator {
1105
1109
  this.activeTurns.delete(session.chatId)
1106
1110
  }
1107
1111
  session.session.close()
1108
- this.onStateChange()
1112
+ this.emitStateChange(session.chatId)
1109
1113
  }
1110
1114
  }
1111
1115
 
@@ -1123,7 +1127,7 @@ export class AgentCoordinator {
1123
1127
  if (chat.title !== expectedCurrentTitle) return
1124
1128
 
1125
1129
  await this.store.renameChat(chatId, result.title)
1126
- this.onStateChange()
1130
+ this.emitStateChange(chatId)
1127
1131
  } catch (error) {
1128
1132
  const message = error instanceof Error ? error.message : String(error)
1129
1133
  this.reportBackgroundError?.(
@@ -1141,7 +1145,7 @@ export class AgentCoordinator {
1141
1145
 
1142
1146
  if (event.type === "session_token" && event.sessionToken) {
1143
1147
  await this.store.setSessionToken(active.chatId, event.sessionToken)
1144
- this.onStateChange()
1148
+ this.emitStateChange(active.chatId)
1145
1149
  continue
1146
1150
  }
1147
1151
 
@@ -1169,7 +1173,7 @@ export class AgentCoordinator {
1169
1173
  this.drainingStreams.set(active.chatId, { turn: active.turn })
1170
1174
  }
1171
1175
 
1172
- this.onStateChange()
1176
+ this.emitStateChange(active.chatId)
1173
1177
  }
1174
1178
  } catch (error) {
1175
1179
  if (!active.cancelRequested) {
@@ -1199,7 +1203,7 @@ export class AgentCoordinator {
1199
1203
  }
1200
1204
  // Stream has fully ended — no longer draining.
1201
1205
  this.drainingStreams.delete(active.chatId)
1202
- this.onStateChange()
1206
+ this.emitStateChange(active.chatId)
1203
1207
 
1204
1208
  if (active.postToolFollowUp && !active.cancelRequested) {
1205
1209
  try {
@@ -1227,7 +1231,7 @@ export class AgentCoordinator {
1227
1231
  })
1228
1232
  )
1229
1233
  await this.store.recordTurnFailed(active.chatId, message)
1230
- this.onStateChange()
1234
+ this.emitStateChange(active.chatId)
1231
1235
  }
1232
1236
  }
1233
1237
  }
@@ -1274,7 +1278,7 @@ export class AgentCoordinator {
1274
1278
  // Remove from activeTurns immediately so the UI reflects the cancellation
1275
1279
  // right away, rather than waiting for interrupt() which may hang.
1276
1280
  this.activeTurns.delete(chatId)
1277
- this.onStateChange()
1281
+ this.emitStateChange(chatId)
1278
1282
 
1279
1283
  // Now attempt to interrupt/close the underlying stream in the background.
1280
1284
  // This is best-effort — the turn is already removed from active state above,
@@ -1343,6 +1347,6 @@ export class AgentCoordinator {
1343
1347
 
1344
1348
  pending.resolve(command.result)
1345
1349
 
1346
- this.onStateChange()
1350
+ this.emitStateChange(command.chatId)
1347
1351
  }
1348
1352
  }
@@ -305,7 +305,7 @@ export class EventStore {
305
305
  break
306
306
  }
307
307
  case "chat_created": {
308
- const chat = {
308
+ const chat = {
309
309
  id: event.chatId,
310
310
  projectId: event.projectId,
311
311
  title: event.title,
@@ -315,6 +315,7 @@ export class EventStore {
315
315
  provider: null,
316
316
  planMode: false,
317
317
  sessionToken: null,
318
+ hasMessages: false,
318
319
  lastTurnOutcome: null,
319
320
  }
320
321
  this.state.chatsById.set(chat.id, chat)
@@ -404,6 +405,7 @@ export class EventStore {
404
405
  private applyMessageMetadata(chatId: string, entry: TranscriptEntry) {
405
406
  const chat = this.state.chatsById.get(chatId)
406
407
  if (!chat) return
408
+ chat.hasMessages = true
407
409
  if (entry.kind === "user_prompt") {
408
410
  chat.lastMessageAt = entry.createdAt
409
411
  }
@@ -540,7 +542,11 @@ export class EventStore {
540
542
  for (const chat of this.state.chatsById.values()) {
541
543
  if (chat.deletedAt || protectedChatIds.has(chat.id)) continue
542
544
  if (now - chat.createdAt < maxAgeMs) continue
543
- if (this.getMessages(chat.id).length > 0) continue
545
+ if (chat.hasMessages) continue
546
+ if (this.getMessages(chat.id).length > 0) {
547
+ chat.hasMessages = true
548
+ continue
549
+ }
544
550
 
545
551
  const event: ChatEvent = {
546
552
  v: STORE_VERSION,
@@ -15,6 +15,7 @@ export interface ChatRecord {
15
15
  provider: AgentProvider | null
16
16
  planMode: boolean
17
17
  sessionToken: string | null
18
+ hasMessages?: boolean
18
19
  lastMessageAt?: number
19
20
  lastTurnOutcome: "success" | "failed" | "cancelled" | null
20
21
  }
@@ -99,8 +99,12 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
99
99
  : null
100
100
  const agent = new AgentCoordinator({
101
101
  store,
102
- onStateChange: () => {
103
- void router.broadcastSnapshots()
102
+ onStateChange: (chatId?: string) => {
103
+ if (chatId) {
104
+ router.scheduleChatStateBroadcast(chatId)
105
+ return
106
+ }
107
+ router.scheduleBroadcast()
104
108
  },
105
109
  })
106
110
  router = createWsRouter({
@@ -115,7 +119,8 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
115
119
  updateManager,
116
120
  })
117
121
  const staleEmptyChatPruneInterval = setInterval(() => {
118
- void router.broadcastSnapshots()
122
+ void router.pruneStaleEmptyChats()
123
+ .then(() => router.broadcastSnapshots())
119
124
  }, STALE_EMPTY_CHAT_PRUNE_INTERVAL_MS)
120
125
 
121
126
  const distDir = path.join(import.meta.dir, "..", "..", "dist", "client")
@@ -60,6 +60,7 @@ describe("ws-router", () => {
60
60
  updateManager: null,
61
61
  })
62
62
  const ws = new FakeWebSocket()
63
+ router.handleOpen(ws as never)
63
64
 
64
65
  ws.data.subscriptions.set("sub-1", { type: "sidebar" })
65
66
  await router.handleMessage(
@@ -143,6 +144,7 @@ describe("ws-router", () => {
143
144
  updateManager: null,
144
145
  })
145
146
  const ws = new FakeWebSocket()
147
+ router.handleOpen(ws as never)
146
148
 
147
149
  await router.handleMessage(
148
150
  ws as never,
@@ -230,6 +232,7 @@ describe("ws-router", () => {
230
232
  updateManager: null,
231
233
  })
232
234
  const ws = new FakeWebSocket()
235
+ router.handleOpen(ws as never)
233
236
 
234
237
  await router.handleMessage(
235
238
  ws as never,
@@ -302,6 +305,7 @@ describe("ws-router", () => {
302
305
  updateManager: null,
303
306
  })
304
307
  const ws = new FakeWebSocket()
308
+ router.handleOpen(ws as never)
305
309
 
306
310
  await router.handleMessage(
307
311
  ws as never,
@@ -383,6 +387,7 @@ describe("ws-router", () => {
383
387
  updateManager: null,
384
388
  })
385
389
  const ws = new FakeWebSocket()
390
+ router.handleOpen(ws as never)
386
391
 
387
392
  await router.handleMessage(
388
393
  ws as never,
@@ -666,7 +671,7 @@ describe("ws-router", () => {
666
671
  })
667
672
  })
668
673
 
669
- test("prunes stale empty chats before sending sidebar snapshots", async () => {
674
+ test("prunes stale empty chats during explicit maintenance runs", async () => {
670
675
  const state = createEmptyState()
671
676
  state.projectsById.set("project-1", {
672
677
  id: "project-1",
@@ -715,6 +720,7 @@ describe("ws-router", () => {
715
720
  })
716
721
  const ws = new FakeWebSocket()
717
722
 
723
+ await router.pruneStaleEmptyChats()
718
724
  await router.handleMessage(
719
725
  ws as never,
720
726
  JSON.stringify({
@@ -743,7 +749,7 @@ describe("ws-router", () => {
743
749
  })
744
750
  })
745
751
 
746
- test("protects draft-bearing chats from stale pruning before sidebar snapshots", async () => {
752
+ test("protects draft-bearing chats during explicit maintenance runs", async () => {
747
753
  const state = createEmptyState()
748
754
  state.projectsById.set("project-1", {
749
755
  id: "project-1",
@@ -790,6 +796,7 @@ describe("ws-router", () => {
790
796
  updateManager: null,
791
797
  })
792
798
  const ws = new FakeWebSocket()
799
+ router.handleOpen(ws as never)
793
800
 
794
801
  await router.handleMessage(
795
802
  ws as never,
@@ -804,6 +811,7 @@ describe("ws-router", () => {
804
811
  })
805
812
  )
806
813
 
814
+ await router.pruneStaleEmptyChats()
807
815
  await router.handleMessage(
808
816
  ws as never,
809
817
  JSON.stringify({
@@ -102,6 +102,16 @@ interface CreateWsRouterArgs {
102
102
  updateManager: UpdateManager | null
103
103
  }
104
104
 
105
+ interface SnapshotBroadcastFilter {
106
+ includeSidebar?: boolean
107
+ includeLocalProjects?: boolean
108
+ includeUpdate?: boolean
109
+ includeKeybindings?: boolean
110
+ chatIds?: Set<string>
111
+ projectIds?: Set<string>
112
+ terminalIds?: Set<string>
113
+ }
114
+
105
115
  function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
106
116
  const payload = JSON.stringify(message)
107
117
  ws.send(payload)
@@ -128,6 +138,9 @@ export function createWsRouter({
128
138
  updateManager,
129
139
  }: CreateWsRouterArgs) {
130
140
  const sockets = new Set<ServerWebSocket<ClientState>>()
141
+ let pendingBroadcastTimer: ReturnType<typeof setTimeout> | null = null
142
+ let pendingBroadcastAll = false
143
+ const pendingBroadcastChatIds = new Set<string>()
131
144
  const resolvedDiffStore = diffStore ?? {
132
145
  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 } }),
133
146
  refreshSnapshot: async () => false,
@@ -198,6 +211,36 @@ export function createWsRouter({
198
211
  }
199
212
  }
200
213
 
214
+ function shouldIncludeTopic(topic: SubscriptionTopic, filter?: SnapshotBroadcastFilter) {
215
+ if (!filter) {
216
+ return true
217
+ }
218
+
219
+ if (topic.type === "sidebar") {
220
+ return Boolean(filter.includeSidebar)
221
+ }
222
+ if (topic.type === "local-projects") {
223
+ return Boolean(filter.includeLocalProjects)
224
+ }
225
+ if (topic.type === "update") {
226
+ return Boolean(filter.includeUpdate)
227
+ }
228
+ if (topic.type === "keybindings") {
229
+ return Boolean(filter.includeKeybindings)
230
+ }
231
+ if (topic.type === "chat") {
232
+ return filter.chatIds?.has(topic.chatId) ?? false
233
+ }
234
+ if (topic.type === "project-git") {
235
+ return filter.projectIds?.has(topic.projectId) ?? false
236
+ }
237
+ if (topic.type === "terminal") {
238
+ return filter.terminalIds?.has(topic.terminalId) ?? false
239
+ }
240
+
241
+ return true
242
+ }
243
+
201
244
  function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
202
245
  if (topic.type === "sidebar") {
203
246
  const startedAt = performance.now()
@@ -314,7 +357,10 @@ export function createWsRouter({
314
357
  }
315
358
  }
316
359
 
317
- async function pushSnapshots(ws: ServerWebSocket<ClientState>, options?: { skipPrune?: boolean }) {
360
+ async function pushSnapshots(
361
+ ws: ServerWebSocket<ClientState>,
362
+ options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter }
363
+ ) {
318
364
  const pushStartedAt = performance.now()
319
365
  if (!options?.skipPrune) {
320
366
  await maybePruneStaleEmptyChats([ws])
@@ -323,6 +369,9 @@ export function createWsRouter({
323
369
  let sentCount = 0
324
370
  let skippedCount = 0
325
371
  for (const [id, topic] of ws.data.subscriptions.entries()) {
372
+ if (!shouldIncludeTopic(topic, options?.filter)) {
373
+ continue
374
+ }
326
375
  const envelopeStartedAt = performance.now()
327
376
  const envelope = createEnvelope(id, topic)
328
377
  const createdAt = performance.now()
@@ -369,8 +418,6 @@ export function createWsRouter({
369
418
 
370
419
  async function broadcastSnapshots() {
371
420
  const startedAt = performance.now()
372
- await maybePruneStaleEmptyChats()
373
- const afterPruneAt = performance.now()
374
421
  let socketCount = 0
375
422
  for (const ws of sockets) {
376
423
  socketCount += 1
@@ -380,7 +427,7 @@ export function createWsRouter({
380
427
  console.log("[kanna/send->starting][server]", JSON.stringify({
381
428
  stage: "ws.broadcast_snapshots_completed",
382
429
  elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
383
- pruneMs: Number((afterPruneAt - startedAt).toFixed(1)),
430
+ pruneMs: 0,
384
431
  socketCount,
385
432
  totalChatCount: store.state.chatsById.size,
386
433
  totalProjectCount: store.state.projectsById.size,
@@ -388,6 +435,83 @@ export function createWsRouter({
388
435
  }
389
436
  }
390
437
 
438
+ async function broadcastFilteredSnapshots(filter: SnapshotBroadcastFilter) {
439
+ const startedAt = performance.now()
440
+ let socketCount = 0
441
+ for (const ws of sockets) {
442
+ socketCount += 1
443
+ await pushSnapshots(ws, { skipPrune: true, filter })
444
+ }
445
+ if (isSendToStartingProfilingEnabled()) {
446
+ console.log("[kanna/send->starting][server]", JSON.stringify({
447
+ stage: "ws.broadcast_filtered_snapshots_completed",
448
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
449
+ socketCount,
450
+ includeSidebar: Boolean(filter.includeSidebar),
451
+ chatCount: filter.chatIds?.size ?? 0,
452
+ projectCount: filter.projectIds?.size ?? 0,
453
+ }))
454
+ }
455
+ }
456
+
457
+ function scheduleBroadcast() {
458
+ pendingBroadcastAll = true
459
+ pendingBroadcastChatIds.clear()
460
+ if (pendingBroadcastTimer) {
461
+ return
462
+ }
463
+ pendingBroadcastTimer = setTimeout(() => {
464
+ pendingBroadcastTimer = null
465
+ const shouldBroadcastAll = pendingBroadcastAll
466
+ const chatIds = new Set(pendingBroadcastChatIds)
467
+ pendingBroadcastAll = false
468
+ pendingBroadcastChatIds.clear()
469
+ if (shouldBroadcastAll) {
470
+ void broadcastSnapshots()
471
+ return
472
+ }
473
+ if (chatIds.size > 0) {
474
+ void broadcastFilteredSnapshots({
475
+ includeSidebar: true,
476
+ chatIds,
477
+ })
478
+ }
479
+ }, 16)
480
+ }
481
+
482
+ function scheduleChatStateBroadcast(chatId: string) {
483
+ if (!pendingBroadcastAll) {
484
+ pendingBroadcastChatIds.add(chatId)
485
+ }
486
+ if (pendingBroadcastTimer) {
487
+ return
488
+ }
489
+ pendingBroadcastTimer = setTimeout(() => {
490
+ pendingBroadcastTimer = null
491
+ const shouldBroadcastAll = pendingBroadcastAll
492
+ const chatIds = new Set(pendingBroadcastChatIds)
493
+ pendingBroadcastAll = false
494
+ pendingBroadcastChatIds.clear()
495
+ if (shouldBroadcastAll) {
496
+ void broadcastSnapshots()
497
+ return
498
+ }
499
+ if (chatIds.size > 0) {
500
+ void broadcastFilteredSnapshots({
501
+ includeSidebar: true,
502
+ chatIds,
503
+ })
504
+ }
505
+ }, 16)
506
+ }
507
+
508
+ async function broadcastChatAndSidebar(chatId: string) {
509
+ await broadcastFilteredSnapshots({
510
+ includeSidebar: true,
511
+ chatIds: new Set([chatId]),
512
+ })
513
+ }
514
+
391
515
  function broadcastError(message: string) {
392
516
  for (const ws of sockets) {
393
517
  send(ws, {
@@ -563,24 +687,28 @@ export function createWsRouter({
563
687
  case "chat.create": {
564
688
  const chat = await store.createChat(command.projectId)
565
689
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
566
- break
690
+ await broadcastChatAndSidebar(chat.id)
691
+ return
567
692
  }
568
693
  case "chat.rename": {
569
694
  await store.renameChat(command.chatId, command.title)
570
695
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
571
- break
696
+ await broadcastChatAndSidebar(command.chatId)
697
+ return
572
698
  }
573
699
  case "chat.delete": {
574
700
  await agent.cancel(command.chatId)
575
701
  await agent.closeChat(command.chatId)
576
702
  await store.deleteChat(command.chatId)
577
703
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
578
- break
704
+ await broadcastFilteredSnapshots({ includeSidebar: true })
705
+ return
579
706
  }
580
707
  case "chat.markRead": {
581
708
  await store.setChatReadState(command.chatId, false)
582
709
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
583
- break
710
+ await broadcastChatAndSidebar(command.chatId)
711
+ return
584
712
  }
585
713
  case "chat.setDraftProtection": {
586
714
  ws.data.protectedDraftChatIds = new Set(command.chatIds)
@@ -600,7 +728,7 @@ export function createWsRouter({
600
728
  chatId: result.chatId ?? null,
601
729
  payloadBytes,
602
730
  })
603
- break
731
+ return
604
732
  }
605
733
  case "chat.refreshDiffs": {
606
734
  const { project } = resolveChatProject(command.chatId)
@@ -780,12 +908,12 @@ export function createWsRouter({
780
908
  case "chat.cancel": {
781
909
  await agent.cancel(command.chatId)
782
910
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
783
- break
911
+ return
784
912
  }
785
913
  case "chat.stopDraining": {
786
914
  await agent.stopDraining(command.chatId)
787
915
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
788
- break
916
+ return
789
917
  }
790
918
  case "chat.loadHistory": {
791
919
  const chat = store.getChat(command.chatId)
@@ -797,7 +925,7 @@ export function createWsRouter({
797
925
  case "chat.respondTool": {
798
926
  await agent.respondTool(command)
799
927
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
800
- break
928
+ return
801
929
  }
802
930
  case "terminal.create": {
803
931
  const project = store.getProject(command.projectId)
@@ -852,6 +980,9 @@ export function createWsRouter({
852
980
  sockets.delete(ws)
853
981
  },
854
982
  broadcastSnapshots,
983
+ scheduleBroadcast,
984
+ scheduleChatStateBroadcast,
985
+ pruneStaleEmptyChats: () => maybePruneStaleEmptyChats(),
855
986
  async handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
856
987
  let parsed: unknown
857
988
  try {
@@ -873,12 +1004,12 @@ export function createWsRouter({
873
1004
  if (parsed.topic.type === "local-projects") {
874
1005
  void refreshDiscovery().then(() => {
875
1006
  if (ws.data.subscriptions.has(parsed.id)) {
876
- void pushSnapshots(ws)
1007
+ void pushSnapshots(ws, { skipPrune: true })
877
1008
  }
878
1009
  })
879
1010
  return
880
1011
  }
881
- await pushSnapshots(ws)
1012
+ await pushSnapshots(ws, { skipPrune: true })
882
1013
  return
883
1014
  }
884
1015
 
@@ -893,6 +1024,9 @@ export function createWsRouter({
893
1024
  await handleCommand(ws, parsed)
894
1025
  },
895
1026
  dispose() {
1027
+ if (pendingBroadcastTimer) {
1028
+ clearTimeout(pendingBroadcastTimer)
1029
+ }
896
1030
  agent.setBackgroundErrorReporter?.(null)
897
1031
  disposeTerminalEvents()
898
1032
  disposeKeybindingEvents()