kanna-code 0.25.1 → 0.26.1

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.
@@ -1,4 +1,4 @@
1
- import { appendFile, mkdir, rename, writeFile } from "node:fs/promises"
1
+ import { appendFile, mkdir, rename, rm, writeFile } from "node:fs/promises"
2
2
  import { existsSync, readFileSync as readFileSyncImmediate } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import path from "node:path"
@@ -19,6 +19,7 @@ import {
19
19
  import { resolveLocalPath } from "./paths"
20
20
 
21
21
  const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
22
+ const STALE_EMPTY_CHAT_MAX_AGE_MS = 5 * 60 * 1000
22
23
  interface LegacyTranscriptStats {
23
24
  hasLegacyData: boolean
24
25
  sources: Array<"snapshot" | "messages_log">
@@ -454,6 +455,41 @@ export class EventStore {
454
455
  await this.append(this.chatsLogPath, event)
455
456
  }
456
457
 
458
+ async pruneStaleEmptyChats(args?: {
459
+ now?: number
460
+ maxAgeMs?: number
461
+ activeChatIds?: Iterable<string>
462
+ }) {
463
+ const now = args?.now ?? Date.now()
464
+ const maxAgeMs = args?.maxAgeMs ?? STALE_EMPTY_CHAT_MAX_AGE_MS
465
+ const activeChatIds = new Set(args?.activeChatIds ?? [])
466
+ const prunedChatIds: string[] = []
467
+
468
+ for (const chat of this.state.chatsById.values()) {
469
+ if (chat.deletedAt || activeChatIds.has(chat.id)) continue
470
+ if (now - chat.createdAt < maxAgeMs) continue
471
+ if (this.getMessages(chat.id).length > 0) continue
472
+
473
+ const event: ChatEvent = {
474
+ v: STORE_VERSION,
475
+ type: "chat_deleted",
476
+ timestamp: now,
477
+ chatId: chat.id,
478
+ }
479
+ await this.append(this.chatsLogPath, event)
480
+
481
+ const transcriptPath = this.transcriptPath(chat.id)
482
+ await rm(transcriptPath, { force: true })
483
+ if (this.cachedTranscript?.chatId === chat.id) {
484
+ this.cachedTranscript = null
485
+ }
486
+
487
+ prunedChatIds.push(chat.id)
488
+ }
489
+
490
+ return prunedChatIds
491
+ }
492
+
457
493
  async setChatProvider(chatId: string, provider: AgentProvider) {
458
494
  const chat = this.requireChat(chatId)
459
495
  if (chat.provider === provider) return
@@ -17,6 +17,7 @@ import { getProjectUploadDir } from "./paths"
17
17
 
18
18
  const MAX_UPLOAD_FILES = 10
19
19
  const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024
20
+ const STALE_EMPTY_CHAT_PRUNE_INTERVAL_MS = 60 * 1000
20
21
 
21
22
  export async function persistUploadedFiles(args: {
22
23
  projectId: string
@@ -99,7 +100,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
99
100
  const agent = new AgentCoordinator({
100
101
  store,
101
102
  onStateChange: () => {
102
- router.broadcastSnapshots()
103
+ void router.broadcastSnapshots()
103
104
  },
104
105
  })
105
106
  router = createWsRouter({
@@ -113,6 +114,9 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
113
114
  machineDisplayName,
114
115
  updateManager,
115
116
  })
117
+ const staleEmptyChatPruneInterval = setInterval(() => {
118
+ void router.broadcastSnapshots()
119
+ }, STALE_EMPTY_CHAT_PRUNE_INTERVAL_MS)
116
120
 
117
121
  const distDir = path.join(import.meta.dir, "..", "..", "dist", "client")
118
122
 
@@ -188,6 +192,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
188
192
  }
189
193
 
190
194
  const shutdown = async () => {
195
+ clearInterval(staleEmptyChatPruneInterval)
191
196
  for (const chatId of [...agent.activeTurns.keys()]) {
192
197
  await agent.cancel(chatId)
193
198
  }
@@ -577,7 +577,7 @@ describe("ws-router", () => {
577
577
  router.handleOpen(wsA as never)
578
578
  router.handleOpen(wsB as never)
579
579
 
580
- router.handleMessage(
580
+ await router.handleMessage(
581
581
  wsA as never,
582
582
  JSON.stringify({
583
583
  v: 1,
@@ -586,7 +586,7 @@ describe("ws-router", () => {
586
586
  topic: { type: "sidebar" },
587
587
  })
588
588
  )
589
- router.handleMessage(
589
+ await router.handleMessage(
590
590
  wsB as never,
591
591
  JSON.stringify({
592
592
  v: 1,
@@ -596,7 +596,7 @@ describe("ws-router", () => {
596
596
  })
597
597
  )
598
598
 
599
- router.handleMessage(
599
+ await router.handleMessage(
600
600
  wsA as never,
601
601
  JSON.stringify({
602
602
  v: 1,
@@ -606,8 +606,6 @@ describe("ws-router", () => {
606
606
  })
607
607
  )
608
608
 
609
- await Promise.resolve()
610
-
611
609
  expect(wsA.sent.at(-2)).toEqual({
612
610
  v: PROTOCOL_VERSION,
613
611
  type: "ack",
@@ -667,6 +665,83 @@ describe("ws-router", () => {
667
665
  })
668
666
  })
669
667
 
668
+ test("prunes stale empty chats before sending sidebar snapshots", async () => {
669
+ const state = createEmptyState()
670
+ state.projectsById.set("project-1", {
671
+ id: "project-1",
672
+ localPath: "/tmp/project",
673
+ title: "Project",
674
+ createdAt: 1,
675
+ updatedAt: 1,
676
+ })
677
+ state.projectIdsByPath.set("/tmp/project", "project-1")
678
+ state.chatsById.set("chat-stale", {
679
+ id: "chat-stale",
680
+ projectId: "project-1",
681
+ title: "New Chat",
682
+ createdAt: 1,
683
+ updatedAt: 1,
684
+ unread: false,
685
+ provider: null,
686
+ planMode: false,
687
+ sessionToken: null,
688
+ lastTurnOutcome: null,
689
+ })
690
+
691
+ let pruneCalls = 0
692
+ const router = createWsRouter({
693
+ store: {
694
+ state,
695
+ async pruneStaleEmptyChats() {
696
+ pruneCalls += 1
697
+ state.chatsById.delete("chat-stale")
698
+ return ["chat-stale"]
699
+ },
700
+ } as never,
701
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
702
+ terminals: {
703
+ getSnapshot: () => null,
704
+ onEvent: () => () => {},
705
+ } as never,
706
+ keybindings: {
707
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
708
+ onChange: () => () => {},
709
+ } as never,
710
+ refreshDiscovery: async () => [],
711
+ getDiscoveredProjects: () => [],
712
+ machineDisplayName: "Local Machine",
713
+ updateManager: null,
714
+ })
715
+ const ws = new FakeWebSocket()
716
+
717
+ await router.handleMessage(
718
+ ws as never,
719
+ JSON.stringify({
720
+ v: 1,
721
+ type: "subscribe",
722
+ id: "sidebar-sub-1",
723
+ topic: { type: "sidebar" },
724
+ })
725
+ )
726
+
727
+ expect(pruneCalls).toBe(1)
728
+ expect(ws.sent[0]).toEqual({
729
+ v: PROTOCOL_VERSION,
730
+ type: "snapshot",
731
+ id: "sidebar-sub-1",
732
+ snapshot: {
733
+ type: "sidebar",
734
+ data: {
735
+ projectGroups: [{
736
+ groupKey: "project-1",
737
+ localPath: "/tmp/project",
738
+ chats: [],
739
+ }],
740
+ },
741
+ },
742
+ })
743
+ })
744
+
670
745
  test("broadcasts background title-generation errors to connected clients", () => {
671
746
  let reportBackgroundError: ((message: string) => void) | null | undefined
672
747
  const router = createWsRouter({
@@ -22,7 +22,7 @@ export interface ClientState {
22
22
 
23
23
  interface CreateWsRouterArgs {
24
24
  store: EventStore
25
- diffStore?: Pick<DiffStore, "getProjectSnapshot" | "refreshSnapshot" | "listBranches" | "previewMergeBranch" | "mergeBranch" | "syncBranch" | "checkoutBranch" | "createBranch" | "generateCommitMessage" | "commitFiles" | "discardFile" | "ignoreFile" | "readPatch">
25
+ diffStore?: Pick<DiffStore, "getProjectSnapshot" | "refreshSnapshot" | "initializeGit" | "getGitHubPublishInfo" | "checkGitHubRepoAvailability" | "publishToGitHub" | "listBranches" | "previewMergeBranch" | "mergeBranch" | "syncBranch" | "checkoutBranch" | "createBranch" | "generateCommitMessage" | "commitFiles" | "discardFile" | "ignoreFile" | "readPatch">
26
26
  agent: AgentCoordinator
27
27
  terminals: TerminalManager
28
28
  keybindings: KeybindingsManager
@@ -59,6 +59,10 @@ export function createWsRouter({
59
59
  const resolvedDiffStore = diffStore ?? {
60
60
  getProjectSnapshot: () => ({ status: "unknown", branchName: undefined, defaultBranchName: undefined, originRepoSlug: undefined, hasUpstream: undefined, aheadCount: undefined, behindCount: undefined, lastFetchedAt: undefined, files: [] as const, branchHistory: { entries: [] as const } }),
61
61
  refreshSnapshot: async () => false,
62
+ initializeGit: async () => ({ ok: true, branchName: undefined, snapshotChanged: false }),
63
+ getGitHubPublishInfo: async () => ({ ghInstalled: false, authenticated: false, activeAccountLogin: undefined, owners: [], suggestedRepoName: "my-repo" }),
64
+ checkGitHubRepoAvailability: async () => ({ available: false, message: "Unavailable" }),
65
+ publishToGitHub: async () => ({ ok: false, title: "Publish failed", message: "Unavailable", snapshotChanged: false }),
62
66
  listBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" as const }),
63
67
  previewMergeBranch: async () => ({ currentBranchName: undefined, targetBranchName: "", targetDisplayName: "", status: "error" as const, commitCount: 0, hasConflicts: false, message: "Merge preview unavailable." }),
64
68
  mergeBranch: async () => ({ ok: false as const, title: "Merge failed", message: "Merge unavailable.", snapshotChanged: false }),
@@ -72,6 +76,19 @@ export function createWsRouter({
72
76
  readPatch: async () => ({ patch: "" }),
73
77
  }
74
78
 
79
+ function getProtectedChatIds() {
80
+ return new Set([
81
+ ...agent.getActiveStatuses().keys(),
82
+ ...agent.getDrainingChatIds().values(),
83
+ ])
84
+ }
85
+
86
+ async function maybePruneStaleEmptyChats() {
87
+ await store.pruneStaleEmptyChats?.({
88
+ activeChatIds: getProtectedChatIds(),
89
+ })
90
+ }
91
+
75
92
  function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
76
93
  if (topic.type === "sidebar") {
77
94
  return {
@@ -175,7 +192,10 @@ export function createWsRouter({
175
192
  }
176
193
  }
177
194
 
178
- function pushSnapshots(ws: ServerWebSocket<ClientState>) {
195
+ async function pushSnapshots(ws: ServerWebSocket<ClientState>, options?: { skipPrune?: boolean }) {
196
+ if (!options?.skipPrune) {
197
+ await maybePruneStaleEmptyChats()
198
+ }
179
199
  const snapshotSignatures = ensureSnapshotSignatures(ws)
180
200
  for (const [id, topic] of ws.data.subscriptions.entries()) {
181
201
  const envelope = createEnvelope(id, topic)
@@ -189,9 +209,10 @@ export function createWsRouter({
189
209
  }
190
210
  }
191
211
 
192
- function broadcastSnapshots() {
212
+ async function broadcastSnapshots() {
213
+ await maybePruneStaleEmptyChats()
193
214
  for (const ws of sockets) {
194
- pushSnapshots(ws)
215
+ await pushSnapshots(ws, { skipPrune: true })
195
216
  }
196
217
  }
197
218
 
@@ -399,7 +420,51 @@ export function createWsRouter({
399
420
  const changed = await resolvedDiffStore.refreshSnapshot(project.id, project.localPath)
400
421
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
401
422
  if (changed) {
402
- broadcastSnapshots()
423
+ void broadcastSnapshots()
424
+ }
425
+ return
426
+ }
427
+ case "chat.initGit": {
428
+ const { project } = resolveChatProject(command.chatId)
429
+ const result = await resolvedDiffStore.initializeGit({
430
+ projectId: project.id,
431
+ projectPath: project.localPath,
432
+ })
433
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
434
+ if (result.snapshotChanged) {
435
+ void broadcastSnapshots()
436
+ }
437
+ return
438
+ }
439
+ case "chat.getGitHubPublishInfo": {
440
+ const { project } = resolveChatProject(command.chatId)
441
+ const result = await resolvedDiffStore.getGitHubPublishInfo({
442
+ projectPath: project.localPath,
443
+ })
444
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
445
+ return
446
+ }
447
+ case "chat.checkGitHubRepoAvailability": {
448
+ const result = await resolvedDiffStore.checkGitHubRepoAvailability({
449
+ owner: command.owner,
450
+ name: command.name,
451
+ })
452
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
453
+ return
454
+ }
455
+ case "chat.publishToGitHub": {
456
+ const { project } = resolveChatProject(command.chatId)
457
+ const result = await resolvedDiffStore.publishToGitHub({
458
+ projectId: project.id,
459
+ projectPath: project.localPath,
460
+ owner: command.owner,
461
+ name: command.name,
462
+ visibility: command.visibility,
463
+ description: command.description,
464
+ })
465
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
466
+ if (result.snapshotChanged) {
467
+ void broadcastSnapshots()
403
468
  }
404
469
  return
405
470
  }
@@ -429,7 +494,7 @@ export function createWsRouter({
429
494
  })
430
495
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
431
496
  if (result.snapshotChanged) {
432
- broadcastSnapshots()
497
+ void broadcastSnapshots()
433
498
  }
434
499
  return
435
500
  }
@@ -443,7 +508,7 @@ export function createWsRouter({
443
508
  })
444
509
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
445
510
  if (result.snapshotChanged) {
446
- broadcastSnapshots()
511
+ void broadcastSnapshots()
447
512
  }
448
513
  return
449
514
  }
@@ -456,7 +521,7 @@ export function createWsRouter({
456
521
  })
457
522
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
458
523
  if (result.snapshotChanged) {
459
- broadcastSnapshots()
524
+ void broadcastSnapshots()
460
525
  }
461
526
  return
462
527
  }
@@ -470,7 +535,7 @@ export function createWsRouter({
470
535
  })
471
536
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
472
537
  if (result.snapshotChanged) {
473
- broadcastSnapshots()
538
+ void broadcastSnapshots()
474
539
  }
475
540
  return
476
541
  }
@@ -495,7 +560,7 @@ export function createWsRouter({
495
560
  })
496
561
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
497
562
  if (result.snapshotChanged) {
498
- broadcastSnapshots()
563
+ void broadcastSnapshots()
499
564
  }
500
565
  return
501
566
  }
@@ -508,7 +573,7 @@ export function createWsRouter({
508
573
  })
509
574
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
510
575
  if (result.snapshotChanged) {
511
- broadcastSnapshots()
576
+ void broadcastSnapshots()
512
577
  }
513
578
  return
514
579
  }
@@ -521,7 +586,7 @@ export function createWsRouter({
521
586
  })
522
587
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
523
588
  if (result.snapshotChanged) {
524
- broadcastSnapshots()
589
+ void broadcastSnapshots()
525
590
  }
526
591
  return
527
592
  }
@@ -580,7 +645,7 @@ export function createWsRouter({
580
645
  }
581
646
  }
582
647
 
583
- broadcastSnapshots()
648
+ await broadcastSnapshots()
584
649
  } catch (error) {
585
650
  const messageText = error instanceof Error ? error.message : String(error)
586
651
  console.error("[ws-router] command failed", {
@@ -600,7 +665,7 @@ export function createWsRouter({
600
665
  sockets.delete(ws)
601
666
  },
602
667
  broadcastSnapshots,
603
- handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
668
+ async handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
604
669
  let parsed: unknown
605
670
  try {
606
671
  parsed = JSON.parse(String(raw))
@@ -621,12 +686,12 @@ export function createWsRouter({
621
686
  if (parsed.topic.type === "local-projects") {
622
687
  void refreshDiscovery().then(() => {
623
688
  if (ws.data.subscriptions.has(parsed.id)) {
624
- pushSnapshots(ws)
689
+ void pushSnapshots(ws)
625
690
  }
626
691
  })
627
692
  return
628
693
  }
629
- pushSnapshots(ws)
694
+ await pushSnapshots(ws)
630
695
  return
631
696
  }
632
697
 
@@ -638,7 +703,7 @@ export function createWsRouter({
638
703
  return
639
704
  }
640
705
 
641
- void handleCommand(ws, parsed)
706
+ await handleCommand(ws, parsed)
642
707
  },
643
708
  dispose() {
644
709
  agent.setBackgroundErrorReporter?.(null)
@@ -81,6 +81,17 @@ export type ClientCommand =
81
81
  planMode?: boolean
82
82
  }
83
83
  | { type: "chat.refreshDiffs"; chatId: string }
84
+ | { type: "chat.initGit"; chatId: string }
85
+ | { type: "chat.getGitHubPublishInfo"; chatId: string }
86
+ | { type: "chat.checkGitHubRepoAvailability"; chatId: string; owner: string; name: string }
87
+ | {
88
+ type: "chat.publishToGitHub"
89
+ chatId: string
90
+ owner: string
91
+ name: string
92
+ visibility: "public" | "private"
93
+ description?: string
94
+ }
84
95
  | { type: "chat.listBranches"; chatId: string }
85
96
  | {
86
97
  type: "chat.previewMergeBranch"
@@ -528,6 +528,19 @@ export interface ChatBranchListResult {
528
528
  pullRequestsError?: string
529
529
  }
530
530
 
531
+ export interface GitHubPublishInfo {
532
+ ghInstalled: boolean
533
+ authenticated: boolean
534
+ activeAccountLogin?: string
535
+ owners: string[]
536
+ suggestedRepoName: string
537
+ }
538
+
539
+ export interface GitHubRepoAvailabilityResult {
540
+ available: boolean
541
+ message: string
542
+ }
543
+
531
544
  export interface BranchMetadata {
532
545
  branchName?: string
533
546
  defaultBranchName?: string