@cuongtran001/kanna 0.43.2 → 0.45.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 (40) hide show
  1. package/dist/client/assets/index-DLVKyl74.js +1 -0
  2. package/dist/client/assets/index-DVqFbcyL.css +32 -0
  3. package/dist/client/assets/index-DrdIVK7H.js +2655 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/export-viewer/assets/{index-D5AqC1n_.js → index-DE2jvYMB.js} +95 -95
  6. package/dist/export-viewer/assets/index-V85HqTWK.css +1 -0
  7. package/dist/export-viewer/index.html +2 -2
  8. package/package.json +4 -3
  9. package/src/server/agent.test.ts +643 -0
  10. package/src/server/agent.ts +143 -16
  11. package/src/server/app-settings.test.ts +54 -1
  12. package/src/server/app-settings.ts +49 -0
  13. package/src/server/auth.test.ts +23 -1
  14. package/src/server/background-tasks.test.ts +293 -0
  15. package/src/server/background-tasks.ts +219 -0
  16. package/src/server/cli-runtime.test.ts +15 -1
  17. package/src/server/cloudflare-tunnel/e2e.test.ts +4 -1
  18. package/src/server/codex-app-server.test.ts +69 -0
  19. package/src/server/codex-app-server.ts +13 -1
  20. package/src/server/diff-store.ts +2 -2
  21. package/src/server/kanna-mcp.test.ts +90 -0
  22. package/src/server/kanna-mcp.ts +116 -0
  23. package/src/server/orphan-persistence.test.ts +148 -0
  24. package/src/server/orphan-persistence.ts +147 -0
  25. package/src/server/server.ts +32 -7
  26. package/src/server/terminal-manager.test.ts +100 -0
  27. package/src/server/terminal-manager.ts +16 -2
  28. package/src/server/test-helpers/worktree-repo.ts +37 -0
  29. package/src/server/uploads.test.ts +36 -0
  30. package/src/server/worktree-store.test.ts +209 -0
  31. package/src/server/worktree-store.ts +120 -0
  32. package/src/server/ws-router.test.ts +360 -2
  33. package/src/server/ws-router.ts +86 -2
  34. package/src/shared/protocol.ts +18 -1
  35. package/src/shared/tools.test.ts +65 -0
  36. package/src/shared/tools.ts +56 -0
  37. package/src/shared/types.ts +75 -0
  38. package/dist/client/assets/index-CC4TiTzA.css +0 -32
  39. package/dist/client/assets/index-UQWb6QtR.js +0 -2620
  40. package/dist/export-viewer/assets/index-BWQYh3zz.css +0 -1
@@ -1,4 +1,6 @@
1
1
  import { query, type CanUseTool, type PermissionResult, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
2
+ import { createKannaMcpServer } from "./kanna-mcp"
3
+ import { KANNA_MCP_SERVER_NAME } from "../shared/tools"
2
4
  import { homedir } from "node:os"
3
5
  import type {
4
6
  AgentProvider,
@@ -34,6 +36,8 @@ import { ClaudeLimitDetector, CodexLimitDetector, type LimitDetection, type Limi
34
36
  import type { ScheduleManager } from "./auto-continue/schedule-manager"
35
37
  import { deriveChatSchedules } from "./auto-continue/read-model"
36
38
  import type { TunnelGateway } from "./cloudflare-tunnel/gateway"
39
+ import type { BackgroundTaskRegistry } from "./background-tasks"
40
+ import type { TerminalManager } from "./terminal-manager"
37
41
 
38
42
  const CLAUDE_TOOLSET = [
39
43
  "Skill",
@@ -111,9 +115,11 @@ interface AgentCoordinatorArgs {
111
115
  onStateChange: (chatId?: string, options?: { immediate?: boolean }) => void
112
116
  analytics?: AnalyticsReporter
113
117
  codexManager?: CodexAppServerManager
118
+ terminalManager?: TerminalManager
114
119
  generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
115
120
  tunnelGateway?: TunnelGateway
116
121
  startClaudeSession?: (args: {
122
+ projectId: string
117
123
  localPath: string
118
124
  model: string
119
125
  effort?: string
@@ -127,6 +133,7 @@ interface AgentCoordinatorArgs {
127
133
  scheduleManager?: ScheduleManager
128
134
  getAutoResumePreference?: () => boolean
129
135
  throwOnClaudeSessionStart?: boolean
136
+ backgroundTasks?: BackgroundTaskRegistry
130
137
  }
131
138
 
132
139
  interface SendToStartingProfile {
@@ -583,6 +590,7 @@ class AsyncMessageQueue<T> implements AsyncIterable<T> {
583
590
  }
584
591
 
585
592
  async function startClaudeSession(args: {
593
+ projectId: string
586
594
  localPath: string
587
595
  model: string
588
596
  effort?: string
@@ -659,6 +667,12 @@ async function startClaudeSession(args: {
659
667
  permissionMode: args.planMode ? "plan" : "acceptEdits",
660
668
  canUseTool,
661
669
  tools: [...CLAUDE_TOOLSET],
670
+ mcpServers: {
671
+ [KANNA_MCP_SERVER_NAME]: createKannaMcpServer({
672
+ projectId: args.projectId,
673
+ localPath: args.localPath,
674
+ }),
675
+ },
662
676
  systemPrompt: {
663
677
  type: "preset",
664
678
  preset: "claude_code",
@@ -715,11 +729,50 @@ async function startClaudeSession(args: {
715
729
  }
716
730
  }
717
731
 
732
+ function parseBackgroundPid(output: string): number | null {
733
+ const match = output.match(/\bpid[\s:=]+(\d+)\b/i)
734
+ return match ? Number(match[1]) : null
735
+ }
736
+
737
+ function parseBackgroundShellId(output: string): string | null {
738
+ const match = output.match(/shell[_\s-]?id[:\s]+([\w-]+)/i)
739
+ return match ? match[1] : null
740
+ }
741
+
742
+ /**
743
+ * Extracts the canonical background task ID from a tool_result content value.
744
+ *
745
+ * The SDK may surface `backgroundTaskId` either:
746
+ * - as a top-level field on a BashOutput object (the direct-object form), or
747
+ * - as a field on one of the items in a content-block array.
748
+ *
749
+ * Returns the first non-empty string found, or null if absent.
750
+ */
751
+ function extractBackgroundTaskId(content: unknown): string | null {
752
+ if (content === null || typeof content !== "object") return null
753
+ if (Array.isArray(content)) {
754
+ for (const item of content) {
755
+ if (typeof item === "object" && item !== null && "backgroundTaskId" in item) {
756
+ const id = (item as { backgroundTaskId?: unknown }).backgroundTaskId
757
+ if (typeof id === "string" && id.length > 0) return id
758
+ }
759
+ }
760
+ return null
761
+ }
762
+ // Direct BashOutput-like object
763
+ if ("backgroundTaskId" in content) {
764
+ const id = (content as { backgroundTaskId?: unknown }).backgroundTaskId
765
+ if (typeof id === "string" && id.length > 0) return id
766
+ }
767
+ return null
768
+ }
769
+
718
770
  export class AgentCoordinator {
719
771
  private readonly store: EventStore
720
772
  private readonly onStateChange: (chatId?: string, options?: { immediate?: boolean }) => void
721
773
  private readonly analytics: AnalyticsReporter
722
774
  private readonly codexManager: CodexAppServerManager
775
+ private readonly terminalManager: TerminalManager | null
723
776
  private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
724
777
  private readonly startClaudeSessionFn: NonNullable<AgentCoordinatorArgs["startClaudeSession"]>
725
778
  private reportBackgroundError: ((message: string) => void) | null = null
@@ -734,13 +787,17 @@ export class AgentCoordinator {
734
787
  private readonly throwOnClaudeSessionStart: boolean
735
788
  private readonly autoResumeByChat = new Map<string, boolean>()
736
789
  private readonly tunnelGateway: TunnelGateway | null
737
- private readonly pendingBashCalls = new Map<string, { command: string; chatId: string }>()
790
+ private readonly backgroundTasks: BackgroundTaskRegistry | null
791
+ private readonly pendingBashCalls = new Map<string, { command: string; chatId: string; isBg: boolean }>()
738
792
 
739
793
  constructor(args: AgentCoordinatorArgs) {
740
794
  this.store = args.store
741
795
  this.onStateChange = args.onStateChange
742
796
  this.analytics = args.analytics ?? NoopAnalyticsReporter
743
- this.codexManager = args.codexManager ?? new CodexAppServerManager()
797
+ this.codexManager = args.codexManager ?? new CodexAppServerManager({
798
+ backgroundTasks: args.backgroundTasks,
799
+ })
800
+ this.terminalManager = args.terminalManager ?? null
744
801
  this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
745
802
  this.startClaudeSessionFn = args.startClaudeSession ?? startClaudeSession
746
803
  this.claudeLimitDetector = args.claudeLimitDetector ?? new ClaudeLimitDetector()
@@ -749,6 +806,18 @@ export class AgentCoordinator {
749
806
  this.getAutoResumePreference = args.getAutoResumePreference ?? (() => false)
750
807
  this.throwOnClaudeSessionStart = args.throwOnClaudeSessionStart ?? false
751
808
  this.tunnelGateway = args.tunnelGateway ?? null
809
+ this.backgroundTasks = args.backgroundTasks ?? null
810
+ this.backgroundTasks?.setStrategies({
811
+ closeStream: async (task) => {
812
+ await this.stopDraining(task.chatId)
813
+ },
814
+ killPty: async (task) => {
815
+ this.terminalManager?.close(task.ptyId)
816
+ },
817
+ shutdownCodex: async (task) => {
818
+ this.codexManager.stopSession(task.chatId)
819
+ },
820
+ })
752
821
  }
753
822
 
754
823
  setBackgroundErrorReporter(report: ((message: string) => void) | null) {
@@ -790,11 +859,10 @@ export class AgentCoordinator {
790
859
  }
791
860
 
792
861
  private trackBashToolEntry(chatId: string, entry: TranscriptEntry): void {
793
- if (!this.tunnelGateway) return
794
-
795
862
  if (entry.kind === "tool_call" && entry.tool.toolKind === "bash") {
796
863
  const command = entry.tool.input.command ?? ""
797
- this.pendingBashCalls.set(entry.tool.toolId, { command, chatId })
864
+ const isBg = entry.tool.input.runInBackground === true
865
+ this.pendingBashCalls.set(entry.tool.toolId, { command, chatId, isBg })
798
866
  return
799
867
  }
800
868
 
@@ -803,12 +871,35 @@ export class AgentCoordinator {
803
871
  if (!pending) return
804
872
  this.pendingBashCalls.delete(entry.toolId)
805
873
  const stdout = stringifyToolResultContent(entry.content)
806
- void this.tunnelGateway.handleBashResult({
807
- command: pending.command,
808
- stdout,
809
- chatId: pending.chatId,
810
- sourcePid: null,
811
- })
874
+
875
+ if (this.tunnelGateway) {
876
+ void this.tunnelGateway.handleBashResult({
877
+ command: pending.command,
878
+ stdout,
879
+ chatId: pending.chatId,
880
+ sourcePid: null,
881
+ })
882
+ }
883
+
884
+ if (pending.isBg && this.backgroundTasks) {
885
+ const registryId = `bash:${entry.toolId}`
886
+ const shellId =
887
+ extractBackgroundTaskId(entry.content) ??
888
+ parseBackgroundShellId(stdout) ??
889
+ entry.toolId
890
+ const pid = parseBackgroundPid(stdout)
891
+ this.backgroundTasks.register({
892
+ kind: "bash_shell",
893
+ id: registryId,
894
+ chatId: pending.chatId,
895
+ command: pending.command,
896
+ shellId,
897
+ pid,
898
+ startedAt: Date.now(),
899
+ lastOutput: stdout.slice(-1024),
900
+ status: "running",
901
+ })
902
+ }
812
903
  }
813
904
  }
814
905
 
@@ -824,11 +915,16 @@ export class AgentCoordinator {
824
915
  }
825
916
  }
826
917
 
918
+ private clearDrainingStream(chatId: string): void {
919
+ this.drainingStreams.delete(chatId)
920
+ this.backgroundTasks?.unregister(`drain:${chatId}`)
921
+ }
922
+
827
923
  async stopDraining(chatId: string) {
828
924
  const draining = this.drainingStreams.get(chatId)
829
925
  if (!draining) return
830
926
  draining.turn.close()
831
- this.drainingStreams.delete(chatId)
927
+ this.clearDrainingStream(chatId)
832
928
  this.emitStateChange(chatId)
833
929
  }
834
930
 
@@ -853,6 +949,7 @@ export class AgentCoordinator {
853
949
  const defaultModel = normalizeServerModel("claude")
854
950
  const defaultOptions = normalizeClaudeModelOptions(defaultModel)
855
951
  const ephemeral = await this.startClaudeSessionFn({
952
+ projectId: project.id,
856
953
  localPath: project.localPath,
857
954
  model: resolveClaudeApiModelId(defaultModel, defaultOptions.contextWindow),
858
955
  effort: defaultOptions.reasoningEffort,
@@ -984,7 +1081,7 @@ export class AgentCoordinator {
984
1081
  const draining = this.drainingStreams.get(args.chatId)
985
1082
  if (draining) {
986
1083
  draining.turn.close()
987
- this.drainingStreams.delete(args.chatId)
1084
+ this.clearDrainingStream(args.chatId)
988
1085
  }
989
1086
 
990
1087
  const chat = this.store.requireChat(args.chatId)
@@ -1070,6 +1167,7 @@ export class AgentCoordinator {
1070
1167
  })
1071
1168
  turn = await this.startClaudeTurn({
1072
1169
  chatId: args.chatId,
1170
+ projectId: project.id,
1073
1171
  localPath: project.localPath,
1074
1172
  model: args.model,
1075
1173
  effort: args.effort,
@@ -1198,6 +1296,7 @@ export class AgentCoordinator {
1198
1296
 
1199
1297
  private async startClaudeTurn(args: {
1200
1298
  chatId: string
1299
+ projectId: string
1201
1300
  localPath: string
1202
1301
  model: string
1203
1302
  effort?: string
@@ -1215,6 +1314,7 @@ export class AgentCoordinator {
1215
1314
  }
1216
1315
 
1217
1316
  const started = await this.startClaudeSessionFn({
1317
+ projectId: args.projectId,
1218
1318
  localPath: args.localPath,
1219
1319
  model: args.model,
1220
1320
  effort: args.effort,
@@ -1503,7 +1603,13 @@ export class AgentCoordinator {
1503
1603
  }
1504
1604
  }
1505
1605
  } finally {
1506
- this.claudeSessions.delete(session.chatId)
1606
+ // Only clear the chat's session slot if it still points at us. A cancel
1607
+ // followed by an immediate steer can install a fresh session under the
1608
+ // same chatId before this finally runs; deleting unconditionally would
1609
+ // wipe the new session.
1610
+ if (this.claudeSessions.get(session.chatId) === session) {
1611
+ this.claudeSessions.delete(session.chatId)
1612
+ }
1507
1613
  const active = this.activeTurns.get(session.chatId)
1508
1614
  if (active?.provider === "claude") {
1509
1615
  if (active.cancelRequested && !active.cancelRecorded) {
@@ -1582,6 +1688,13 @@ export class AgentCoordinator {
1582
1688
  // Track the still-open stream so the UI can show a draining
1583
1689
  // indicator and the user can stop background tasks.
1584
1690
  this.drainingStreams.set(active.chatId, { turn: active.turn })
1691
+ this.backgroundTasks?.register({
1692
+ kind: "draining_stream",
1693
+ id: `drain:${active.chatId}`,
1694
+ chatId: active.chatId,
1695
+ startedAt: Date.now(),
1696
+ lastOutput: "",
1697
+ })
1585
1698
  }
1586
1699
 
1587
1700
  this.emitStateChange(active.chatId)
@@ -1618,7 +1731,7 @@ export class AgentCoordinator {
1618
1731
  this.activeTurns.delete(active.chatId)
1619
1732
  }
1620
1733
  // Stream has fully ended — no longer draining.
1621
- this.drainingStreams.delete(active.chatId)
1734
+ this.clearDrainingStream(active.chatId)
1622
1735
  this.emitStateChange(active.chatId)
1623
1736
 
1624
1737
  if (active.postToolFollowUp && !active.cancelRequested) {
@@ -1824,7 +1937,7 @@ export class AgentCoordinator {
1824
1937
  const draining = this.drainingStreams.get(chatId)
1825
1938
  if (draining) {
1826
1939
  draining.turn.close()
1827
- this.drainingStreams.delete(chatId)
1940
+ this.clearDrainingStream(chatId)
1828
1941
  }
1829
1942
 
1830
1943
  const active = this.activeTurns.get(chatId)
@@ -1866,6 +1979,20 @@ export class AgentCoordinator {
1866
1979
  // Remove from activeTurns immediately so the UI reflects the cancellation
1867
1980
  // right away, rather than waiting for interrupt() which may hang.
1868
1981
  this.activeTurns.delete(chatId)
1982
+
1983
+ // Drain the cancelled prompt's seq from the Claude session's pending
1984
+ // queue. The SDK does not always echo a `result.subtype=cancelled` for
1985
+ // an interrupted prompt — when the stream just ends, the seq would
1986
+ // otherwise linger and cause a FIFO mismatch when the next turn's
1987
+ // result arrives, leaving the chat stuck in "running".
1988
+ if (active.provider === "claude" && active.claudePromptSeq != null) {
1989
+ const session = this.claudeSessions.get(chatId)
1990
+ if (session) {
1991
+ const idx = session.pendingPromptSeqs.indexOf(active.claudePromptSeq)
1992
+ if (idx >= 0) session.pendingPromptSeqs.splice(idx, 1)
1993
+ }
1994
+ }
1995
+
1869
1996
  this.emitStateChange(chatId)
1870
1997
  logClaudeSteer("cancel_active_turn_deleted", {
1871
1998
  chatId,
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
2
2
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3
3
  import { tmpdir } from "node:os"
4
4
  import path from "node:path"
5
- import { AUTH_DEFAULTS, CLOUDFLARE_TUNNEL_DEFAULTS } from "../shared/types"
5
+ import { AUTH_DEFAULTS, CLOUDFLARE_TUNNEL_DEFAULTS, UPLOAD_DEFAULTS } from "../shared/types"
6
6
  import { AppSettingsManager, readAppSettingsSnapshot } from "./app-settings"
7
7
  import type { AppSettingsSnapshot } from "../shared/types"
8
8
 
@@ -63,6 +63,7 @@ function expectedSettingsSnapshot(filePath: string, overrides: Partial<AppSettin
63
63
  filePathDisplay: filePath,
64
64
  cloudflareTunnel: CLOUDFLARE_TUNNEL_DEFAULTS,
65
65
  auth: AUTH_DEFAULTS,
66
+ uploads: UPLOAD_DEFAULTS,
66
67
  ...overrides,
67
68
  }
68
69
  }
@@ -232,3 +233,55 @@ describe("cloudflareTunnel normalization", () => {
232
233
  })
233
234
  })
234
235
  })
236
+
237
+ describe("uploads normalization", () => {
238
+ test("returns defaults when uploads block missing", async () => {
239
+ const filePath = await writeSettingsFile({ analyticsEnabled: true })
240
+ const snapshot = await readAppSettingsSnapshot(filePath)
241
+ expect(snapshot.uploads).toEqual({ maxFileSizeMb: 100 })
242
+ })
243
+
244
+ test("preserves valid maxFileSizeMb", async () => {
245
+ const filePath = await writeSettingsFile({ uploads: { maxFileSizeMb: 250 } })
246
+ const snapshot = await readAppSettingsSnapshot(filePath)
247
+ expect(snapshot.uploads.maxFileSizeMb).toBe(250)
248
+ })
249
+
250
+ test("clamps out-of-range values and emits warning", async () => {
251
+ const filePath = await writeSettingsFile({ uploads: { maxFileSizeMb: 99999 } })
252
+ const snapshot = await readAppSettingsSnapshot(filePath)
253
+ expect(snapshot.uploads.maxFileSizeMb).toBe(2048)
254
+ expect(snapshot.warning).toContain("uploads.maxFileSizeMb")
255
+ })
256
+
257
+ test("rejects non-number maxFileSizeMb and falls back to default", async () => {
258
+ const filePath = await writeSettingsFile({ uploads: { maxFileSizeMb: "big" } })
259
+ const snapshot = await readAppSettingsSnapshot(filePath)
260
+ expect(snapshot.uploads.maxFileSizeMb).toBe(100)
261
+ expect(snapshot.warning).toContain("uploads.maxFileSizeMb must be a number")
262
+ })
263
+
264
+ test("setUploads persists patch and round-trips through readAppSettingsSnapshot", async () => {
265
+ const filePath = await writeSettingsFile({ analyticsEnabled: true })
266
+ const manager = new AppSettingsManager(filePath)
267
+ await manager.initialize()
268
+ await manager.setUploads({ maxFileSizeMb: 500 })
269
+ const reloaded = await readAppSettingsSnapshot(filePath)
270
+ expect(reloaded.uploads.maxFileSizeMb).toBe(500)
271
+ manager.dispose()
272
+ })
273
+
274
+ test("setUploads throws on invalid value", async () => {
275
+ const filePath = await createTempFilePath()
276
+ const manager = new AppSettingsManager(filePath)
277
+ await manager.initialize()
278
+ let lowError: unknown
279
+ try { await manager.setUploads({ maxFileSizeMb: 0 }) } catch (error) { lowError = error }
280
+ expect((lowError as Error)?.message).toMatch(/between/)
281
+ let highError: unknown
282
+ try { await manager.setUploads({ maxFileSizeMb: 99999 }) } catch (error) { highError = error }
283
+ expect((highError as Error)?.message).toMatch(/between/)
284
+ manager.dispose()
285
+ })
286
+ })
287
+
@@ -17,6 +17,9 @@ import {
17
17
  normalizeClaudeModelId,
18
18
  normalizeCodexModelId,
19
19
  supportsClaudeMaxReasoningEffort,
20
+ UPLOAD_DEFAULTS,
21
+ UPLOAD_MAX_FILE_SIZE_MB_MAX,
22
+ UPLOAD_MAX_FILE_SIZE_MB_MIN,
20
23
  type AppSettingsPatch,
21
24
  type AppSettingsSnapshot,
22
25
  type AppThemePreference,
@@ -30,6 +33,7 @@ import {
30
33
  type DefaultProviderPreference,
31
34
  type EditorPreset,
32
35
  type ProviderPreference,
36
+ type UploadSettings,
33
37
  } from "../shared/types"
34
38
 
35
39
  interface AppSettingsFile {
@@ -54,6 +58,7 @@ interface AppSettingsFile {
54
58
  }
55
59
  cloudflareTunnel?: unknown
56
60
  auth?: unknown
61
+ uploads?: unknown
57
62
  }
58
63
 
59
64
  interface AppSettingsState extends AppSettingsSnapshot {
@@ -278,6 +283,30 @@ function normalizeAuthSettings(value: unknown, warnings: string[]): AuthSettings
278
283
  return { sessionMaxAgeDays }
279
284
  }
280
285
 
286
+ function normalizeUploadSettings(value: unknown, warnings: string[]): UploadSettings {
287
+ const source = value && typeof value === "object" && !Array.isArray(value)
288
+ ? value as Record<string, unknown>
289
+ : null
290
+ if (value !== undefined && !source) {
291
+ warnings.push("uploads must be an object")
292
+ }
293
+
294
+ const rawSize = source?.maxFileSizeMb
295
+ let maxFileSizeMb = UPLOAD_DEFAULTS.maxFileSizeMb
296
+ if (rawSize !== undefined) {
297
+ if (typeof rawSize !== "number" || !Number.isFinite(rawSize)) {
298
+ warnings.push("uploads.maxFileSizeMb must be a number")
299
+ } else if (rawSize < UPLOAD_MAX_FILE_SIZE_MB_MIN || rawSize > UPLOAD_MAX_FILE_SIZE_MB_MAX) {
300
+ warnings.push(`uploads.maxFileSizeMb must be between ${UPLOAD_MAX_FILE_SIZE_MB_MIN} and ${UPLOAD_MAX_FILE_SIZE_MB_MAX}`)
301
+ maxFileSizeMb = clampNumber(rawSize, UPLOAD_DEFAULTS.maxFileSizeMb, UPLOAD_MAX_FILE_SIZE_MB_MIN, UPLOAD_MAX_FILE_SIZE_MB_MAX)
302
+ } else {
303
+ maxFileSizeMb = Math.round(rawSize)
304
+ }
305
+ }
306
+
307
+ return { maxFileSizeMb }
308
+ }
309
+
281
310
  function toFilePayload(state: AppSettingsState) {
282
311
  return {
283
312
  analyticsEnabled: state.analyticsEnabled,
@@ -292,6 +321,7 @@ function toFilePayload(state: AppSettingsState) {
292
321
  providerDefaults: state.providerDefaults,
293
322
  cloudflareTunnel: state.cloudflareTunnel,
294
323
  auth: state.auth,
324
+ uploads: state.uploads,
295
325
  }
296
326
  }
297
327
 
@@ -310,6 +340,7 @@ function toSnapshot(state: AppSettingsState): AppSettingsSnapshot {
310
340
  filePathDisplay: state.filePathDisplay,
311
341
  cloudflareTunnel: state.cloudflareTunnel,
312
342
  auth: state.auth,
343
+ uploads: state.uploads,
313
344
  }
314
345
  }
315
346
 
@@ -342,6 +373,7 @@ function normalizeAppSettings(
342
373
 
343
374
  const cloudflareTunnel = normalizeCloudflareTunnel(source?.cloudflareTunnel, warnings)
344
375
  const auth = normalizeAuthSettings(source?.auth, warnings)
376
+ const uploads = normalizeUploadSettings(source?.uploads, warnings)
345
377
 
346
378
  const editorPreset = normalizeEditorPreset(source?.editor?.preset)
347
379
  const state: AppSettingsState = {
@@ -365,6 +397,7 @@ function normalizeAppSettings(
365
397
  filePathDisplay: formatDisplayPath(filePath),
366
398
  cloudflareTunnel,
367
399
  auth,
400
+ uploads,
368
401
  }
369
402
 
370
403
  const shouldWrite = JSON.stringify(source ? toComparablePayload(source) : null) !== JSON.stringify(toFilePayload(state))
@@ -393,6 +426,7 @@ function toComparablePayload(source: AppSettingsFile) {
393
426
  providerDefaults: source.providerDefaults,
394
427
  cloudflareTunnel: source.cloudflareTunnel,
395
428
  auth: source.auth,
429
+ uploads: source.uploads,
396
430
  }
397
431
  }
398
432
 
@@ -434,6 +468,10 @@ function applyPatch(state: AppSettingsState, patch: AppSettingsPatch): AppSettin
434
468
  ...state.auth,
435
469
  ...patch.auth,
436
470
  },
471
+ uploads: {
472
+ ...state.uploads,
473
+ ...patch.uploads,
474
+ },
437
475
  }, state.filePathDisplay).payload
438
476
  }
439
477
 
@@ -528,6 +566,17 @@ export class AppSettingsManager {
528
566
  return this.writePatch({ auth: patch })
529
567
  }
530
568
 
569
+ async setUploads(patch: Partial<UploadSettings>) {
570
+ if (patch.maxFileSizeMb !== undefined) {
571
+ const value = patch.maxFileSizeMb
572
+ if (typeof value !== "number" || !Number.isFinite(value)
573
+ || value < UPLOAD_MAX_FILE_SIZE_MB_MIN || value > UPLOAD_MAX_FILE_SIZE_MB_MAX) {
574
+ throw new Error(`uploads.maxFileSizeMb must be between ${UPLOAD_MAX_FILE_SIZE_MB_MIN} and ${UPLOAD_MAX_FILE_SIZE_MB_MAX}`)
575
+ }
576
+ }
577
+ return this.writePatch({ uploads: patch })
578
+ }
579
+
531
580
  async writePatch(patch: AppSettingsPatch) {
532
581
  const nextState = {
533
582
  ...applyPatch(this.state, patch),
@@ -39,7 +39,29 @@ function extractCookie(response: Response) {
39
39
 
40
40
  describe("password auth", () => {
41
41
  test("serves the app shell to unauthenticated browser requests", async () => {
42
- const { server } = await startPasswordServer()
42
+ // Create a minimal client bundle fixture so the static file handler can serve it.
43
+ // In CI, `bun run build` produces dist/client/index.html before tests run.
44
+ // Locally (no prior build) we inject a temp distDir via the test-only option.
45
+ const distDir = await mkdtemp(path.join(tmpdir(), "kanna-dist-"))
46
+ tempDirs.push(distDir)
47
+ await writeFile(
48
+ path.join(distDir, "index.html"),
49
+ '<!DOCTYPE html><html><body><div id="root"></div></body></html>',
50
+ "utf8",
51
+ )
52
+
53
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-auth-test-"))
54
+ const dataDir = await mkdtemp(path.join(tmpdir(), "kanna-auth-data-"))
55
+ tempDirs.push(projectDir, dataDir)
56
+ const server = await startKannaServer({
57
+ dataDir,
58
+ distDir,
59
+ port: 4320,
60
+ strictPort: true,
61
+ password: "secret",
62
+ trustProxy: false,
63
+ })
64
+ await server.store.openProject(projectDir, "Project")
43
65
 
44
66
  try {
45
67
  const response = await fetch(`http://localhost:${server.port}/chat/demo`, { headers: { Accept: "text/html" } })