kanna-code 0.26.8 → 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({