@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
@@ -0,0 +1,293 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { spawn } from "bun"
3
+ import { BackgroundTaskRegistry, type BackgroundTask } from "./background-tasks"
4
+ import type { AnalyticsReporter } from "./analytics"
5
+
6
+ function makeAnalytics() {
7
+ const calls: Array<{ name: string; properties?: Record<string, unknown> }> = []
8
+ const reporter: AnalyticsReporter = {
9
+ track: (name, properties) => { calls.push({ name, properties }) },
10
+ trackLaunch: () => {},
11
+ }
12
+ return { reporter, calls }
13
+ }
14
+
15
+ const drainingSample = (
16
+ overrides: Partial<Extract<BackgroundTask, { kind: "draining_stream" }>> = {},
17
+ ): Extract<BackgroundTask, { kind: "draining_stream" }> => ({
18
+ kind: "draining_stream",
19
+ id: "ds-1",
20
+ chatId: "chat-1",
21
+ startedAt: 1_700_000_000_000,
22
+ lastOutput: "",
23
+ ...overrides,
24
+ })
25
+
26
+ describe("BackgroundTaskRegistry", () => {
27
+ it("registers and lists a task", () => {
28
+ const r = new BackgroundTaskRegistry()
29
+ r.register(drainingSample())
30
+ expect(r.list()).toHaveLength(1)
31
+ expect(r.list()[0].id).toBe("ds-1")
32
+ })
33
+
34
+ it("filters by chatId", () => {
35
+ const r = new BackgroundTaskRegistry()
36
+ r.register(drainingSample())
37
+ r.register(drainingSample({ id: "ds-2", chatId: "chat-2" }))
38
+ expect(r.listByChat("chat-1").map((t) => t.id)).toEqual(["ds-1"])
39
+ })
40
+
41
+ it("unregisters a task", () => {
42
+ const r = new BackgroundTaskRegistry()
43
+ r.register(drainingSample())
44
+ r.unregister("ds-1")
45
+ expect(r.list()).toHaveLength(0)
46
+ })
47
+
48
+ it("emits added/updated/removed events in order", () => {
49
+ const r = new BackgroundTaskRegistry()
50
+ const events: string[] = []
51
+ r.on("added", () => events.push("added"))
52
+ r.on("updated", () => events.push("updated"))
53
+ r.on("removed", () => events.push("removed"))
54
+ r.register(drainingSample())
55
+ r.update("ds-1", { lastOutput: "hi" })
56
+ r.unregister("ds-1")
57
+ expect(events).toEqual(["added", "updated", "removed"])
58
+ })
59
+
60
+ it("update() throws when patch.kind mismatches the stored task kind", () => {
61
+ const r = new BackgroundTaskRegistry()
62
+ r.register(drainingSample())
63
+ expect(() =>
64
+ r.update("ds-1", { kind: "bash_shell" } as Partial<BackgroundTask>),
65
+ ).toThrow("BackgroundTaskRegistry.update: kind mismatch (draining_stream -> bash_shell)")
66
+ })
67
+
68
+ it("listByChat excludes terminal_pty tasks (no chatId field)", () => {
69
+ const r = new BackgroundTaskRegistry()
70
+ r.register(drainingSample({ id: "ds-1", chatId: "chat-1" }))
71
+ r.register({
72
+ kind: "terminal_pty",
73
+ id: "pty-1",
74
+ ptyId: "p1",
75
+ cwd: "/tmp",
76
+ startedAt: 1_700_000_000_000,
77
+ lastOutput: "",
78
+ })
79
+ const ids = r.listByChat("chat-1").map((t) => t.id)
80
+ expect(ids).toEqual(["ds-1"])
81
+ })
82
+ })
83
+
84
+ describe("BackgroundTaskRegistry.stop", () => {
85
+ it("sends SIGTERM, then SIGKILL after grace, on a real process", async () => {
86
+ // Spawn a Bun script that ignores SIGTERM and stays alive.
87
+ const child = spawn({
88
+ cmd: ["bun", "-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"],
89
+ stdin: "ignore",
90
+ })
91
+ // Wait for the child process to finish initializing its signal handlers
92
+ // before calling stop(), otherwise the SIGTERM arrives before registration.
93
+ await Bun.sleep(300)
94
+ const r = new BackgroundTaskRegistry()
95
+ r.register({
96
+ kind: "bash_shell",
97
+ id: "sh-1",
98
+ chatId: null,
99
+ command: "bun",
100
+ shellId: "shell-1",
101
+ pid: child.pid!,
102
+ startedAt: Date.now(),
103
+ lastOutput: "",
104
+ status: "running",
105
+ })
106
+ const result = await r.stop("sh-1", { graceMs: 200 })
107
+ expect(result.ok).toBe(true)
108
+ if (result.ok) {
109
+ expect(result.method).toBe("sigkill")
110
+ }
111
+ await child.exited
112
+ }, 5000)
113
+
114
+ it("force: true uses SIGKILL immediately", async () => {
115
+ const child = spawn({
116
+ cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
117
+ stdin: "ignore",
118
+ })
119
+ const r = new BackgroundTaskRegistry()
120
+ r.register({
121
+ kind: "bash_shell",
122
+ id: "sh-2",
123
+ chatId: null,
124
+ command: "bun",
125
+ shellId: "shell-2",
126
+ pid: child.pid!,
127
+ startedAt: Date.now(),
128
+ lastOutput: "",
129
+ status: "running",
130
+ })
131
+ const result = await r.stop("sh-2", { force: true })
132
+ expect(result.ok).toBe(true)
133
+ if (result.ok) {
134
+ expect(result.method).toBe("sigkill")
135
+ }
136
+ await child.exited
137
+ }, 5000)
138
+
139
+ it("PID-reuse guard: returns ok:false when comm does not match", async () => {
140
+ const r = new BackgroundTaskRegistry()
141
+ r.register({
142
+ kind: "bash_shell",
143
+ id: "sh-3",
144
+ chatId: null,
145
+ command: "definitely-not-this-one",
146
+ shellId: "shell-3",
147
+ pid: 1, // init/launchd, never matches "definitely-not-this-one"
148
+ startedAt: Date.now(),
149
+ lastOutput: "",
150
+ status: "running",
151
+ })
152
+ const result = await r.stop("sh-3")
153
+ expect(result.ok).toBe(false)
154
+ if (!result.ok) {
155
+ expect(result.error).toContain("PID mismatch")
156
+ }
157
+ expect(r.list()).toHaveLength(0) // dropped from registry
158
+ })
159
+
160
+ it("word-boundary guard: 'bunbar' does not match a 'bun' process", async () => {
161
+ // Spawn a real bun process so we have a live PID that ps shows as "bun ..."
162
+ const child = spawn({
163
+ cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
164
+ stdin: "ignore",
165
+ })
166
+ await Bun.sleep(200)
167
+ const r = new BackgroundTaskRegistry()
168
+ r.register({
169
+ kind: "bash_shell",
170
+ id: "sh-wb",
171
+ chatId: null,
172
+ // "bunbar" starts with "bun" — the old substring match would wrongly accept it
173
+ command: "bunbar",
174
+ shellId: "shell-wb",
175
+ pid: child.pid!,
176
+ startedAt: Date.now(),
177
+ lastOutput: "",
178
+ status: "running",
179
+ })
180
+ const result = await r.stop("sh-wb")
181
+ // verifyComm should reject because no word boundary match for "bunbar"
182
+ expect(result.ok).toBe(false)
183
+ if (!result.ok) {
184
+ expect(result.error).toContain("PID mismatch")
185
+ }
186
+ // Clean up the orphaned child
187
+ try {
188
+ process.kill(child.pid!, "SIGKILL")
189
+ } catch {
190
+ // already dead
191
+ }
192
+ await child.exited
193
+ }, 5000)
194
+
195
+ it("analytics: bg_task_stopped emitted after successful stop", async () => {
196
+ const { reporter, calls } = makeAnalytics()
197
+ const child = spawn({
198
+ cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
199
+ stdin: "ignore",
200
+ })
201
+ await Bun.sleep(200)
202
+ const r = new BackgroundTaskRegistry({ analytics: reporter })
203
+ const startedAt = Date.now() - 500
204
+ r.register({
205
+ kind: "bash_shell",
206
+ id: "sh-analytics",
207
+ chatId: null,
208
+ command: "bun",
209
+ shellId: "shell-analytics",
210
+ pid: child.pid!,
211
+ startedAt,
212
+ lastOutput: "",
213
+ status: "running",
214
+ })
215
+ // bg_task_registered is emitted on register
216
+ expect(calls).toHaveLength(1)
217
+ expect(calls[0]?.name).toBe("bg_task_registered")
218
+ expect(calls[0]?.properties?.task_kind).toBe("bash_shell")
219
+
220
+ const result = await r.stop("sh-analytics", { force: true })
221
+ expect(result.ok).toBe(true)
222
+ // bg_task_stopped is emitted after successful stop
223
+ expect(calls).toHaveLength(2)
224
+ expect(calls[1]?.name).toBe("bg_task_stopped")
225
+ expect(calls[1]?.properties?.task_kind).toBe("bash_shell")
226
+ expect(calls[1]?.properties?.force).toBe(true)
227
+ expect(typeof calls[1]?.properties?.age_ms).toBe("number")
228
+ await child.exited
229
+ }, 5000)
230
+
231
+ it("analytics: bg_task_stopped not emitted on failed stop (PID mismatch)", async () => {
232
+ const { reporter, calls } = makeAnalytics()
233
+ const r = new BackgroundTaskRegistry({ analytics: reporter })
234
+ r.register({
235
+ kind: "bash_shell",
236
+ id: "sh-no-emit",
237
+ chatId: null,
238
+ command: "definitely-not-this-one",
239
+ shellId: "shell-no-emit",
240
+ pid: 1,
241
+ startedAt: Date.now(),
242
+ lastOutput: "",
243
+ status: "running",
244
+ })
245
+ const result = await r.stop("sh-no-emit")
246
+ expect(result.ok).toBe(false)
247
+ // Only bg_task_registered, no bg_task_stopped
248
+ expect(calls).toHaveLength(1)
249
+ expect(calls[0]?.name).toBe("bg_task_registered")
250
+ })
251
+
252
+ it("killShell strategy hook: invoked instead of POSIX signals", async () => {
253
+ const child = spawn({
254
+ cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
255
+ stdin: "ignore",
256
+ })
257
+ await Bun.sleep(200)
258
+ const r = new BackgroundTaskRegistry()
259
+ r.register({
260
+ kind: "bash_shell",
261
+ id: "sh-ks",
262
+ chatId: null,
263
+ command: "bun",
264
+ shellId: "shell-ks",
265
+ pid: child.pid!,
266
+ startedAt: Date.now(),
267
+ lastOutput: "",
268
+ status: "running",
269
+ })
270
+
271
+ let strategyCalled = 0
272
+ r.setStrategies({
273
+ killShell: async (task) => {
274
+ strategyCalled++
275
+ // Actually kill so the test doesn't leave a zombie
276
+ try {
277
+ process.kill(task.pid!, "SIGKILL")
278
+ } catch {
279
+ // already gone
280
+ }
281
+ },
282
+ })
283
+
284
+ const result = await r.stop("sh-ks")
285
+ expect(result.ok).toBe(true)
286
+ if (result.ok) {
287
+ expect(result.method).toBe("sigterm")
288
+ }
289
+ expect(strategyCalled).toBe(1)
290
+ expect(r.list()).toHaveLength(0)
291
+ await child.exited
292
+ }, 5000)
293
+ })
@@ -0,0 +1,219 @@
1
+ import type { BackgroundTask } from "../shared/types"
2
+ import type { AnalyticsReporter } from "./analytics"
3
+
4
+ export type { BackgroundTask }
5
+
6
+ export type RegistryEvent = "added" | "updated" | "removed"
7
+ export type Listener = (task: BackgroundTask) => void
8
+ export type Unsubscribe = () => void
9
+
10
+ export type StopResult =
11
+ | { ok: true; method: "sigterm" | "sigkill" | "close" | "shutdown" }
12
+ | { ok: false; error: string }
13
+
14
+ export type StopOptions = { force?: boolean; graceMs?: number }
15
+
16
+ export type StopStrategies = {
17
+ killShell?: (task: Extract<BackgroundTask, { kind: "bash_shell" }>) => Promise<void>
18
+ closeStream?: (task: Extract<BackgroundTask, { kind: "draining_stream" }>) => Promise<void>
19
+ killPty?: (task: Extract<BackgroundTask, { kind: "terminal_pty" }>) => Promise<void>
20
+ shutdownCodex?: (task: Extract<BackgroundTask, { kind: "codex_session" }>) => Promise<void>
21
+ }
22
+
23
+ async function safeKill(pid: number, signal: "SIGTERM" | "SIGKILL"): Promise<void> {
24
+ // Try the process group first so child processes (e.g. spawned by a shell) are
25
+ // also signalled. Fall back to single-pid kill if the group signal fails.
26
+ // Note: ESRCH from -pid means "no such process group" (the pid is not a
27
+ // group leader), so we always fall through to the single-pid kill in that case.
28
+ let groupKilled = false
29
+ try {
30
+ process.kill(-pid, signal)
31
+ groupKilled = true
32
+ } catch {
33
+ // Any error (ESRCH = no group, EPERM, EINVAL) means group kill failed;
34
+ // fall through to single-pid kill below.
35
+ }
36
+ if (groupKilled) return
37
+ try {
38
+ process.kill(pid, signal)
39
+ } catch (err) {
40
+ const code = (err as NodeJS.ErrnoException).code
41
+ if (code === "ESRCH") return
42
+ throw err
43
+ }
44
+ }
45
+
46
+ async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
47
+ const start = Date.now()
48
+ while (Date.now() - start < timeoutMs) {
49
+ try {
50
+ process.kill(pid, 0)
51
+ } catch (err) {
52
+ const code = (err as NodeJS.ErrnoException).code
53
+ if (code === "ESRCH") return true
54
+ // EPERM and other codes mean the process still exists; keep polling.
55
+ }
56
+ await Bun.sleep(50)
57
+ }
58
+ return false
59
+ }
60
+
61
+ async function verifyComm(pid: number, expectedCommand: string): Promise<boolean> {
62
+ try {
63
+ const proc = Bun.spawn({
64
+ cmd: ["ps", "-p", String(pid), "-o", "command="],
65
+ stdin: "ignore",
66
+ stdout: "pipe",
67
+ stderr: "ignore",
68
+ })
69
+ const out = (await new Response(proc.stdout).text()).trim()
70
+ await proc.exited
71
+ if (!out) return false
72
+ const cmdToken = expectedCommand.split(/\s+/)[0] ?? ""
73
+ if (!cmdToken) return true
74
+ const escaped = cmdToken.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
75
+ return new RegExp(`(?:^|[/\\s])${escaped}(?:\\s|$)`).test(out)
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ export interface BackgroundTaskRegistryOptions {
82
+ analytics?: AnalyticsReporter
83
+ }
84
+
85
+ export class BackgroundTaskRegistry {
86
+ private tasks = new Map<string, BackgroundTask>()
87
+ private listeners: Record<RegistryEvent, Set<Listener>> = {
88
+ added: new Set(),
89
+ updated: new Set(),
90
+ removed: new Set(),
91
+ }
92
+ private strategies: StopStrategies = {}
93
+ private readonly analytics: AnalyticsReporter | undefined
94
+
95
+ constructor(options: BackgroundTaskRegistryOptions = {}) {
96
+ this.analytics = options.analytics
97
+ }
98
+
99
+ setStrategies(strategies: StopStrategies): void {
100
+ this.strategies = { ...this.strategies, ...strategies }
101
+ }
102
+
103
+ list(): BackgroundTask[] {
104
+ return Array.from(this.tasks.values())
105
+ }
106
+
107
+ /**
108
+ * Returns tasks whose chatId matches the given value.
109
+ * terminal_pty tasks are intentionally excluded because they have no chatId field.
110
+ */
111
+ listByChat(chatId: string): BackgroundTask[] {
112
+ return this.list().filter((t) => "chatId" in t && t.chatId === chatId)
113
+ }
114
+
115
+ register(task: BackgroundTask): void {
116
+ this.tasks.set(task.id, task)
117
+ this.emit("added", task)
118
+ this.analytics?.track("bg_task_registered", { task_kind: task.kind })
119
+ }
120
+
121
+ update(id: string, patch: Partial<BackgroundTask>): void {
122
+ const prev = this.tasks.get(id)
123
+ if (!prev) return
124
+ if (patch.kind !== undefined && patch.kind !== prev.kind) {
125
+ throw new Error(`BackgroundTaskRegistry.update: kind mismatch (${prev.kind} -> ${patch.kind})`)
126
+ }
127
+ const next = { ...prev, ...patch } as BackgroundTask
128
+ this.tasks.set(id, next)
129
+ this.emit("updated", next)
130
+ }
131
+
132
+ unregister(id: string): void {
133
+ const prev = this.tasks.get(id)
134
+ if (!prev) return
135
+ this.tasks.delete(id)
136
+ this.emit("removed", prev)
137
+ }
138
+
139
+ on(event: RegistryEvent, cb: Listener): Unsubscribe {
140
+ this.listeners[event].add(cb)
141
+ return () => this.listeners[event].delete(cb)
142
+ }
143
+
144
+ async stop(id: string, opts: StopOptions = {}): Promise<StopResult> {
145
+ const task = this.tasks.get(id)
146
+ if (!task) return { ok: false, error: "task not found" }
147
+
148
+ const result = await this.runStop(task, opts)
149
+
150
+ if (result.ok) {
151
+ this.analytics?.track("bg_task_stopped", {
152
+ task_kind: task.kind,
153
+ age_ms: Date.now() - task.startedAt,
154
+ force: opts.force ?? false,
155
+ })
156
+ }
157
+
158
+ return result
159
+ }
160
+
161
+ private async runStop(task: BackgroundTask, opts: StopOptions): Promise<StopResult> {
162
+ if (task.kind === "draining_stream") {
163
+ await this.strategies.closeStream?.(task)
164
+ this.unregister(task.id)
165
+ return { ok: true, method: "close" }
166
+ }
167
+ if (task.kind === "terminal_pty") {
168
+ await this.strategies.killPty?.(task)
169
+ this.unregister(task.id)
170
+ return { ok: true, method: "close" }
171
+ }
172
+ if (task.kind === "codex_session") {
173
+ await this.strategies.shutdownCodex?.(task)
174
+ this.unregister(task.id)
175
+ return { ok: true, method: "shutdown" }
176
+ }
177
+
178
+ // bash_shell: signal lifecycle with PID-reuse guard
179
+ if (task.pid == null) return { ok: false, error: "no pid recorded" }
180
+
181
+ const commOk = await verifyComm(task.pid, task.command)
182
+ if (!commOk) {
183
+ this.unregister(task.id)
184
+ return { ok: false, error: "PID mismatch (process reused)" }
185
+ }
186
+
187
+ if (opts.force) {
188
+ this.update(task.id, { status: "stopping" })
189
+ await safeKill(task.pid, "SIGKILL")
190
+ this.unregister(task.id)
191
+ return { ok: true, method: "sigkill" }
192
+ }
193
+
194
+ // If the SDK provides a custom kill strategy, delegate to it.
195
+ if (this.strategies.killShell) {
196
+ this.update(task.id, { status: "stopping" })
197
+ await this.strategies.killShell(task)
198
+ this.unregister(task.id)
199
+ return { ok: true, method: "sigterm" }
200
+ }
201
+
202
+ this.update(task.id, { status: "stopping" })
203
+ await safeKill(task.pid, "SIGTERM")
204
+ const grace = opts.graceMs ?? 3000
205
+ const exited = await waitForExit(task.pid, grace)
206
+ if (exited) {
207
+ this.unregister(task.id)
208
+ return { ok: true, method: "sigterm" }
209
+ }
210
+ await safeKill(task.pid, "SIGKILL")
211
+ await waitForExit(task.pid, 1000)
212
+ this.unregister(task.id)
213
+ return { ok: true, method: "sigkill" }
214
+ }
215
+
216
+ private emit(event: RegistryEvent, task: BackgroundTask): void {
217
+ for (const cb of this.listeners[event]) cb(task)
218
+ }
219
+ }
@@ -1,9 +1,14 @@
1
- import { afterEach, describe, expect, test } from "bun:test"
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { compareVersions, classifyInstallVersionFailure, parseArgs, runCli } from "./cli-runtime"
3
3
  import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
4
4
 
5
5
  const originalRuntimeProfile = process.env.KANNA_RUNTIME_PROFILE
6
6
  const originalSuppressOpen = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]
7
+ const originalDisableSelfUpdate = process.env.KANNA_DISABLE_SELF_UPDATE
8
+
9
+ beforeEach(() => {
10
+ delete process.env.KANNA_DISABLE_SELF_UPDATE
11
+ })
7
12
 
8
13
  afterEach(() => {
9
14
  if (originalRuntimeProfile === undefined) {
@@ -16,6 +21,11 @@ afterEach(() => {
16
21
  } else {
17
22
  process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] = originalSuppressOpen
18
23
  }
24
+ if (originalDisableSelfUpdate === undefined) {
25
+ delete process.env.KANNA_DISABLE_SELF_UPDATE
26
+ } else {
27
+ process.env.KANNA_DISABLE_SELF_UPDATE = originalDisableSelfUpdate
28
+ }
19
29
  })
20
30
 
21
31
  function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
@@ -277,6 +287,7 @@ describe("runCli", () => {
277
287
  test("starts normally when no newer version exists", async () => {
278
288
  const { calls, deps } = createDeps()
279
289
  process.env.KANNA_RUNTIME_PROFILE = "prod"
290
+ delete process.env.KANNA_DISABLE_SELF_UPDATE
280
291
 
281
292
  const result = await runCli(["--port", "4000", "--no-open"], deps)
282
293
 
@@ -468,6 +479,7 @@ describe("runCli", () => {
468
479
  })
469
480
 
470
481
  test("returns restarting when a newer version is available", async () => {
482
+ delete process.env.KANNA_DISABLE_SELF_UPDATE
471
483
  const { calls, deps } = createDeps({
472
484
  fetchLatestVersion: async (packageName) => {
473
485
  calls.fetchLatestVersion.push(packageName)
@@ -483,6 +495,7 @@ describe("runCli", () => {
483
495
  })
484
496
 
485
497
  test("falls back to current version when install fails", async () => {
498
+ delete process.env.KANNA_DISABLE_SELF_UPDATE
486
499
  const { calls, deps } = createDeps({
487
500
  fetchLatestVersion: async (packageName) => {
488
501
  calls.fetchLatestVersion.push(packageName)
@@ -507,6 +520,7 @@ describe("runCli", () => {
507
520
  })
508
521
 
509
522
  test("falls back to current version when the registry check fails", async () => {
523
+ delete process.env.KANNA_DISABLE_SELF_UPDATE
510
524
  const { calls, deps } = createDeps({
511
525
  fetchLatestVersion: async (packageName) => {
512
526
  calls.fetchLatestVersion.push(packageName)
@@ -51,6 +51,7 @@ describe("cloudflare tunnel e2e", () => {
51
51
  let gateway: TunnelGateway
52
52
  let pendingChildren: FakeChild[]
53
53
  let broadcasts: string[]
54
+ let pendingWrites: Promise<void>[]
54
55
 
55
56
  beforeEach(async () => {
56
57
  dataDir = await mkdtemp(join(tmpdir(), "kanna-tunnel-e2e-"))
@@ -70,6 +71,7 @@ describe("cloudflare tunnel e2e", () => {
70
71
 
71
72
  pendingChildren = []
72
73
  broadcasts = []
74
+ pendingWrites = []
73
75
 
74
76
  manager = new TunnelManager({
75
77
  cloudflaredPath: "cloudflared",
@@ -79,7 +81,7 @@ describe("cloudflare tunnel e2e", () => {
79
81
  return child
80
82
  },
81
83
  onEvent: (event: CloudflareTunnelEvent) => {
82
- void store.appendTunnelEvent(event)
84
+ pendingWrites.push(store.appendTunnelEvent(event))
83
85
  broadcasts.push(event.chatId)
84
86
  },
85
87
  })
@@ -102,6 +104,7 @@ describe("cloudflare tunnel e2e", () => {
102
104
  afterEach(async () => {
103
105
  gateway.shutdown()
104
106
  appSettings.dispose()
107
+ await Promise.all(pendingWrites)
105
108
  await rm(dataDir, { recursive: true, force: true })
106
109
  })
107
110
 
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
2
2
  import { EventEmitter } from "node:events"
3
3
  import { PassThrough } from "node:stream"
4
4
  import { CodexAppServerManager } from "./codex-app-server"
5
+ import { BackgroundTaskRegistry } from "./background-tasks"
5
6
 
6
7
  class FakeCodexProcess extends EventEmitter {
7
8
  readonly stdin = new PassThrough()
@@ -1813,4 +1814,72 @@ describe("CodexAppServerManager", () => {
1813
1814
  expect(resultEvent?.entry.subtype).toBe("error")
1814
1815
  expect(resultEvent?.entry.result).toContain("fatal: app-server crashed")
1815
1816
  })
1817
+
1818
+ test("registers codex_session entry in BackgroundTaskRegistry on startSession", async () => {
1819
+ const fakeProcess = new FakeCodexProcess((message, child) => {
1820
+ if (message.method === "initialize") {
1821
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1822
+ } else if (message.method === "thread/start") {
1823
+ child.writeServerMessage({
1824
+ id: message.id,
1825
+ result: { thread: { id: "thread-reg-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1826
+ })
1827
+ }
1828
+ })
1829
+
1830
+ const registry = new BackgroundTaskRegistry()
1831
+ const manager = new CodexAppServerManager({
1832
+ spawnProcess: () => fakeProcess as never,
1833
+ backgroundTasks: registry,
1834
+ })
1835
+
1836
+ await manager.startSession({
1837
+ chatId: "chat-reg-1",
1838
+ cwd: "/tmp/project",
1839
+ model: "gpt-5.4",
1840
+ sessionToken: null,
1841
+ })
1842
+
1843
+ const tasks = registry.list()
1844
+ const task = tasks.find((t) => t.id === "codex:chat-reg-1")
1845
+ expect(task).toBeDefined()
1846
+ expect(task?.kind).toBe("codex_session")
1847
+ if (task?.kind === "codex_session") {
1848
+ expect(task.chatId).toBe("chat-reg-1")
1849
+ expect(task.pid).toBeNull()
1850
+ expect(typeof task.startedAt).toBe("number")
1851
+ }
1852
+ })
1853
+
1854
+ test("unregisters codex_session entry from BackgroundTaskRegistry on stopSession", async () => {
1855
+ const fakeProcess = new FakeCodexProcess((message, child) => {
1856
+ if (message.method === "initialize") {
1857
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1858
+ } else if (message.method === "thread/start") {
1859
+ child.writeServerMessage({
1860
+ id: message.id,
1861
+ result: { thread: { id: "thread-reg-2" }, model: "gpt-5.4", reasoningEffort: "high" },
1862
+ })
1863
+ }
1864
+ })
1865
+
1866
+ const registry = new BackgroundTaskRegistry()
1867
+ const manager = new CodexAppServerManager({
1868
+ spawnProcess: () => fakeProcess as never,
1869
+ backgroundTasks: registry,
1870
+ })
1871
+
1872
+ await manager.startSession({
1873
+ chatId: "chat-reg-2",
1874
+ cwd: "/tmp/project",
1875
+ model: "gpt-5.4",
1876
+ sessionToken: null,
1877
+ })
1878
+
1879
+ expect(registry.list().find((t) => t.id === "codex:chat-reg-2")).toBeDefined()
1880
+
1881
+ manager.stopSession("chat-reg-2")
1882
+
1883
+ expect(registry.list().find((t) => t.id === "codex:chat-reg-2")).toBeUndefined()
1884
+ })
1816
1885
  })