kanna-code 0.11.0 → 0.13.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.
@@ -0,0 +1,24 @@
1
+ export const CLI_CHILD_MODE_ENV_VAR = "KANNA_CLI_MODE"
2
+ export const CLI_CHILD_MODE = "child"
3
+ export const CLI_RESTART_EXIT_CODE = 75
4
+ export const CLI_CHILD_COMMAND_ENV_VAR = "KANNA_CLI_CHILD_COMMAND"
5
+ export const CLI_CHILD_ARGS_ENV_VAR = "KANNA_CLI_CHILD_ARGS"
6
+
7
+ export function shouldRestartCliProcess(code: number | null, signal: NodeJS.Signals | null) {
8
+ return signal === null && code === CLI_RESTART_EXIT_CODE
9
+ }
10
+
11
+ export function parseChildArgsEnv(value: string | undefined) {
12
+ if (!value) return []
13
+
14
+ try {
15
+ const parsed = JSON.parse(value) as unknown
16
+ if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
17
+ throw new Error("child args must be an array of strings")
18
+ }
19
+ return parsed
20
+ } catch (error) {
21
+ const message = error instanceof Error ? error.message : String(error)
22
+ throw new Error(`Invalid ${CLI_CHILD_ARGS_ENV_VAR}: ${message}`)
23
+ }
24
+ }
@@ -1,16 +1,22 @@
1
1
  import path from "node:path"
2
- import { APP_NAME } from "../shared/branding"
2
+ import { APP_NAME, getRuntimeProfile } from "../shared/branding"
3
3
  import { EventStore } from "./event-store"
4
4
  import { AgentCoordinator } from "./agent"
5
5
  import { discoverProjects, type DiscoveredProject } from "./discovery"
6
6
  import { KeybindingsManager } from "./keybindings"
7
7
  import { getMachineDisplayName } from "./machine-name"
8
8
  import { TerminalManager } from "./terminal-manager"
9
+ import { UpdateManager } from "./update-manager"
9
10
  import { createWsRouter, type ClientState } from "./ws-router"
10
11
 
11
12
  export interface StartKannaServerOptions {
12
13
  port?: number
13
14
  strictPort?: boolean
15
+ update?: {
16
+ version: string
17
+ fetchLatestVersion: (packageName: string) => Promise<string>
18
+ installLatest: (packageName: string) => boolean
19
+ }
14
20
  }
15
21
 
16
22
  export async function startKannaServer(options: StartKannaServerOptions = {}) {
@@ -33,6 +39,14 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
33
39
  const terminals = new TerminalManager()
34
40
  const keybindings = new KeybindingsManager()
35
41
  await keybindings.initialize()
42
+ const updateManager = options.update
43
+ ? new UpdateManager({
44
+ currentVersion: options.update.version,
45
+ fetchLatestVersion: options.update.fetchLatestVersion,
46
+ installLatest: options.update.installLatest,
47
+ devMode: getRuntimeProfile() === "dev",
48
+ })
49
+ : null
36
50
  const agent = new AgentCoordinator({
37
51
  store,
38
52
  onStateChange: () => {
@@ -47,6 +61,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
47
61
  refreshDiscovery,
48
62
  getDiscoveredProjects: () => discoveredProjects,
49
63
  machineDisplayName,
64
+ updateManager,
50
65
  })
51
66
 
52
67
  const distDir = path.join(import.meta.dir, "..", "..", "dist", "client")
@@ -114,6 +129,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
114
129
  return {
115
130
  port: actualPort,
116
131
  store,
132
+ updateManager,
117
133
  stop: shutdown,
118
134
  }
119
135
  }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { UpdateManager } from "./update-manager"
3
+
4
+ describe("UpdateManager", () => {
5
+ test("detects available updates", async () => {
6
+ const manager = new UpdateManager({
7
+ currentVersion: "0.12.0",
8
+ fetchLatestVersion: async () => "0.13.0",
9
+ installLatest: () => true,
10
+ })
11
+
12
+ const snapshot = await manager.checkForUpdates({ force: true })
13
+
14
+ expect(snapshot.status).toBe("available")
15
+ expect(snapshot.updateAvailable).toBe(true)
16
+ expect(snapshot.latestVersion).toBe("0.13.0")
17
+ expect(snapshot.installAction).toBe("restart")
18
+ })
19
+
20
+ test("bypasses cache when force is true", async () => {
21
+ let calls = 0
22
+ const manager = new UpdateManager({
23
+ currentVersion: "0.12.0",
24
+ fetchLatestVersion: async () => {
25
+ calls += 1
26
+ return calls === 1 ? "0.12.1" : "0.13.0"
27
+ },
28
+ installLatest: () => true,
29
+ })
30
+
31
+ await manager.checkForUpdates()
32
+ await manager.checkForUpdates({ force: true })
33
+
34
+ expect(calls).toBe(2)
35
+ expect(manager.getSnapshot().latestVersion).toBe("0.13.0")
36
+ })
37
+
38
+ test("surfaces install failures without clearing the running version", async () => {
39
+ const manager = new UpdateManager({
40
+ currentVersion: "0.12.0",
41
+ fetchLatestVersion: async () => "0.13.0",
42
+ installLatest: () => false,
43
+ })
44
+
45
+ const result = await manager.installUpdate()
46
+
47
+ expect(result).toEqual({ ok: false, action: "restart" })
48
+ expect(manager.getSnapshot().status).toBe("error")
49
+ expect(manager.getSnapshot().currentVersion).toBe("0.12.0")
50
+ })
51
+
52
+ test("always exposes an available reload action in dev mode", async () => {
53
+ const manager = new UpdateManager({
54
+ currentVersion: "0.12.0",
55
+ fetchLatestVersion: async () => "9.9.9",
56
+ installLatest: () => true,
57
+ devMode: true,
58
+ })
59
+
60
+ expect(manager.getSnapshot()).toMatchObject({
61
+ status: "available",
62
+ updateAvailable: true,
63
+ installAction: "restart",
64
+ })
65
+
66
+ const result = await manager.installUpdate()
67
+ expect(result).toEqual({ ok: true, action: "restart" })
68
+ expect(manager.getSnapshot().status).toBe("restart_pending")
69
+ })
70
+ })
@@ -0,0 +1,204 @@
1
+ import type { UpdateSnapshot } from "../shared/types"
2
+ import { PACKAGE_NAME } from "../shared/branding"
3
+ import { compareVersions } from "./cli-runtime"
4
+
5
+ const UPDATE_CACHE_TTL_MS = 5 * 60 * 1000
6
+
7
+ export interface UpdateManagerDeps {
8
+ currentVersion: string
9
+ fetchLatestVersion: (packageName: string) => Promise<string>
10
+ installLatest: (packageName: string) => boolean
11
+ devMode?: boolean
12
+ }
13
+
14
+ export interface UpdateInstallResult {
15
+ ok: boolean
16
+ action: "restart" | "reload"
17
+ }
18
+
19
+ export class UpdateManager {
20
+ private readonly deps: UpdateManagerDeps
21
+ private readonly listeners = new Set<(snapshot: UpdateSnapshot) => void>()
22
+ private snapshot: UpdateSnapshot
23
+ private checkPromise: Promise<UpdateSnapshot> | null = null
24
+ private installPromise: Promise<UpdateInstallResult> | null = null
25
+
26
+ constructor(deps: UpdateManagerDeps) {
27
+ this.deps = deps
28
+ this.snapshot = {
29
+ currentVersion: deps.currentVersion,
30
+ latestVersion: deps.devMode ? `${deps.currentVersion}-dev` : null,
31
+ status: deps.devMode ? "available" : "idle",
32
+ updateAvailable: Boolean(deps.devMode),
33
+ lastCheckedAt: deps.devMode ? Date.now() : null,
34
+ error: null,
35
+ installAction: "restart",
36
+ }
37
+ }
38
+
39
+ getSnapshot() {
40
+ return this.snapshot
41
+ }
42
+
43
+ onChange(listener: (snapshot: UpdateSnapshot) => void) {
44
+ this.listeners.add(listener)
45
+ return () => {
46
+ this.listeners.delete(listener)
47
+ }
48
+ }
49
+
50
+ async checkForUpdates(options: { force?: boolean } = {}) {
51
+ if (this.deps.devMode) {
52
+ return this.snapshot
53
+ }
54
+
55
+ if (this.snapshot.status === "updating" || this.snapshot.status === "restart_pending") {
56
+ return this.snapshot
57
+ }
58
+
59
+ if (this.checkPromise) {
60
+ return this.checkPromise
61
+ }
62
+
63
+ if (!options.force && this.snapshot.lastCheckedAt && Date.now() - this.snapshot.lastCheckedAt < UPDATE_CACHE_TTL_MS) {
64
+ return this.snapshot
65
+ }
66
+
67
+ this.setSnapshot({
68
+ ...this.snapshot,
69
+ status: "checking",
70
+ error: null,
71
+ })
72
+
73
+ const checkPromise = this.runCheck()
74
+ this.checkPromise = checkPromise
75
+
76
+ try {
77
+ return await checkPromise
78
+ } finally {
79
+ if (this.checkPromise === checkPromise) {
80
+ this.checkPromise = null
81
+ }
82
+ }
83
+ }
84
+
85
+ async installUpdate(): Promise<UpdateInstallResult> {
86
+ if (this.deps.devMode) {
87
+ this.setSnapshot({
88
+ ...this.snapshot,
89
+ status: "updating",
90
+ error: null,
91
+ })
92
+
93
+ this.setSnapshot({
94
+ ...this.snapshot,
95
+ status: "restart_pending",
96
+ updateAvailable: false,
97
+ error: null,
98
+ })
99
+
100
+ return {
101
+ ok: true,
102
+ action: "restart",
103
+ }
104
+ }
105
+
106
+ if (this.snapshot.status === "updating" || this.snapshot.status === "restart_pending") {
107
+ return {
108
+ ok: this.snapshot.updateAvailable,
109
+ action: "restart",
110
+ }
111
+ }
112
+
113
+ if (this.installPromise) {
114
+ return this.installPromise
115
+ }
116
+
117
+ const installPromise = this.runInstall()
118
+ this.installPromise = installPromise
119
+
120
+ try {
121
+ return await installPromise
122
+ } finally {
123
+ if (this.installPromise === installPromise) {
124
+ this.installPromise = null
125
+ }
126
+ }
127
+ }
128
+
129
+ private async runCheck() {
130
+ try {
131
+ const latestVersion = await this.deps.fetchLatestVersion(PACKAGE_NAME)
132
+ const updateAvailable = compareVersions(this.snapshot.currentVersion, latestVersion) < 0
133
+ const nextSnapshot: UpdateSnapshot = {
134
+ ...this.snapshot,
135
+ latestVersion,
136
+ updateAvailable,
137
+ status: updateAvailable ? "available" : "up_to_date",
138
+ lastCheckedAt: Date.now(),
139
+ error: null,
140
+ }
141
+ this.setSnapshot(nextSnapshot)
142
+ return nextSnapshot
143
+ } catch (error) {
144
+ const nextSnapshot: UpdateSnapshot = {
145
+ ...this.snapshot,
146
+ status: "error",
147
+ lastCheckedAt: Date.now(),
148
+ error: error instanceof Error ? error.message : String(error),
149
+ }
150
+ this.setSnapshot(nextSnapshot)
151
+ return nextSnapshot
152
+ }
153
+ }
154
+
155
+ private async runInstall(): Promise<UpdateInstallResult> {
156
+ if (!this.snapshot.updateAvailable) {
157
+ const snapshot = await this.checkForUpdates({ force: true })
158
+ if (!snapshot.updateAvailable) {
159
+ return {
160
+ ok: false,
161
+ action: "restart",
162
+ }
163
+ }
164
+ }
165
+
166
+ this.setSnapshot({
167
+ ...this.snapshot,
168
+ status: "updating",
169
+ error: null,
170
+ })
171
+
172
+ const installed = this.deps.installLatest(PACKAGE_NAME)
173
+ if (!installed) {
174
+ this.setSnapshot({
175
+ ...this.snapshot,
176
+ status: "error",
177
+ error: "Unable to install the latest version.",
178
+ })
179
+ return {
180
+ ok: false,
181
+ action: "restart",
182
+ }
183
+ }
184
+
185
+ this.setSnapshot({
186
+ ...this.snapshot,
187
+ currentVersion: this.snapshot.latestVersion ?? this.snapshot.currentVersion,
188
+ status: "restart_pending",
189
+ updateAvailable: false,
190
+ error: null,
191
+ })
192
+ return {
193
+ ok: true,
194
+ action: "restart",
195
+ }
196
+ }
197
+
198
+ private setSnapshot(snapshot: UpdateSnapshot) {
199
+ this.snapshot = snapshot
200
+ for (const listener of this.listeners) {
201
+ listener(snapshot)
202
+ }
203
+ }
204
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import type { KeybindingsSnapshot } from "../shared/types"
2
+ import type { KeybindingsSnapshot, UpdateSnapshot } from "../shared/types"
3
3
  import { PROTOCOL_VERSION } from "../shared/types"
4
4
  import { createEmptyState } from "./events"
5
5
  import { createWsRouter } from "./ws-router"
@@ -27,6 +27,16 @@ const DEFAULT_KEYBINDINGS_SNAPSHOT: KeybindingsSnapshot = {
27
27
  filePathDisplay: "~/.kanna/keybindings.json",
28
28
  }
29
29
 
30
+ const DEFAULT_UPDATE_SNAPSHOT: UpdateSnapshot = {
31
+ currentVersion: "0.12.0",
32
+ latestVersion: null,
33
+ status: "idle",
34
+ updateAvailable: false,
35
+ lastCheckedAt: null,
36
+ error: null,
37
+ installAction: "restart",
38
+ }
39
+
30
40
  describe("ws-router", () => {
31
41
  test("acks system.ping without broadcasting snapshots", () => {
32
42
  const router = createWsRouter({
@@ -43,6 +53,7 @@ describe("ws-router", () => {
43
53
  refreshDiscovery: async () => [],
44
54
  getDiscoveredProjects: () => [],
45
55
  machineDisplayName: "Local Machine",
56
+ updateManager: null,
46
57
  })
47
58
  const ws = new FakeWebSocket()
48
59
 
@@ -82,6 +93,7 @@ describe("ws-router", () => {
82
93
  refreshDiscovery: async () => [],
83
94
  getDiscoveredProjects: () => [],
84
95
  machineDisplayName: "Local Machine",
96
+ updateManager: null,
85
97
  })
86
98
  const ws = new FakeWebSocket()
87
99
 
@@ -124,6 +136,7 @@ describe("ws-router", () => {
124
136
  refreshDiscovery: async () => [],
125
137
  getDiscoveredProjects: () => [],
126
138
  machineDisplayName: "Local Machine",
139
+ updateManager: null,
127
140
  })
128
141
  const ws = new FakeWebSocket()
129
142
 
@@ -188,6 +201,7 @@ describe("ws-router", () => {
188
201
  refreshDiscovery: async () => [],
189
202
  getDiscoveredProjects: () => [],
190
203
  machineDisplayName: "Local Machine",
204
+ updateManager: null,
191
205
  })
192
206
  const ws = new FakeWebSocket()
193
207
 
@@ -235,17 +249,107 @@ describe("ws-router", () => {
235
249
  v: PROTOCOL_VERSION,
236
250
  type: "ack",
237
251
  id: "keybindings-write-1",
238
- result: {
239
- bindings: {
240
- toggleEmbeddedTerminal: ["cmd+k"],
241
- toggleRightSidebar: ["ctrl+shift+b"],
242
- openInFinder: ["cmd+shift+g"],
243
- openInEditor: ["cmd+shift+p"],
244
- addSplitTerminal: ["cmd+alt+j"],
245
- },
246
- warning: null,
247
- filePathDisplay: "~/.kanna/keybindings.json",
252
+ result: {
253
+ bindings: {
254
+ toggleEmbeddedTerminal: ["cmd+k"],
255
+ toggleRightSidebar: ["ctrl+shift+b"],
256
+ openInFinder: ["cmd+shift+g"],
257
+ openInEditor: ["cmd+shift+p"],
258
+ addSplitTerminal: ["cmd+alt+j"],
259
+ },
260
+ warning: null,
261
+ filePathDisplay: "~/.kanna/keybindings.json",
262
+ },
263
+ })
264
+ })
265
+
266
+ test("subscribes to update snapshots and handles update.check commands", async () => {
267
+ const updateManager = {
268
+ snapshot: { ...DEFAULT_UPDATE_SNAPSHOT },
269
+ getSnapshot() {
270
+ return this.snapshot
271
+ },
272
+ onChange: () => () => {},
273
+ async checkForUpdates({ force }: { force?: boolean }) {
274
+ this.snapshot = {
275
+ ...this.snapshot,
276
+ latestVersion: force ? "0.13.0" : "0.12.1",
277
+ status: "available",
278
+ updateAvailable: true,
279
+ lastCheckedAt: 123,
280
+ }
281
+ return this.snapshot
282
+ },
283
+ async installUpdate() {
284
+ return true
285
+ },
286
+ }
287
+
288
+ const router = createWsRouter({
289
+ store: { state: createEmptyState() } as never,
290
+ agent: { getActiveStatuses: () => new Map() } as never,
291
+ terminals: {
292
+ getSnapshot: () => null,
293
+ onEvent: () => () => {},
294
+ } as never,
295
+ keybindings: {
296
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
297
+ onChange: () => () => {},
298
+ } as never,
299
+ refreshDiscovery: async () => [],
300
+ getDiscoveredProjects: () => [],
301
+ machineDisplayName: "Local Machine",
302
+ updateManager: updateManager as never,
303
+ })
304
+ const ws = new FakeWebSocket()
305
+
306
+ router.handleMessage(
307
+ ws as never,
308
+ JSON.stringify({
309
+ v: 1,
310
+ type: "subscribe",
311
+ id: "update-sub-1",
312
+ topic: { type: "update" },
313
+ })
314
+ )
315
+
316
+ expect(ws.sent[0]).toEqual({
317
+ v: PROTOCOL_VERSION,
318
+ type: "snapshot",
319
+ id: "update-sub-1",
320
+ snapshot: {
321
+ type: "update",
322
+ data: DEFAULT_UPDATE_SNAPSHOT,
323
+ },
324
+ })
325
+
326
+ router.handleMessage(
327
+ ws as never,
328
+ JSON.stringify({
329
+ v: 1,
330
+ type: "command",
331
+ id: "update-check-1",
332
+ command: {
333
+ type: "update.check",
334
+ force: true,
248
335
  },
249
336
  })
337
+ )
338
+
339
+ await Promise.resolve()
340
+ expect(ws.sent[1]).toEqual({
341
+ v: PROTOCOL_VERSION,
342
+ type: "ack",
343
+ id: "update-check-1",
344
+ result: {
345
+ currentVersion: "0.12.0",
346
+ latestVersion: "0.13.0",
347
+ status: "available",
348
+ updateAvailable: true,
349
+ lastCheckedAt: 123,
350
+ error: null,
351
+ installAction: "restart",
352
+ },
353
+ })
250
354
  })
251
355
  })
@@ -9,6 +9,7 @@ import { openExternal } from "./external-open"
9
9
  import { KeybindingsManager } from "./keybindings"
10
10
  import { ensureProjectDirectory } from "./paths"
11
11
  import { TerminalManager } from "./terminal-manager"
12
+ import type { UpdateManager } from "./update-manager"
12
13
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
13
14
 
14
15
  export interface ClientState {
@@ -23,6 +24,7 @@ interface CreateWsRouterArgs {
23
24
  refreshDiscovery: () => Promise<DiscoveredProject[]>
24
25
  getDiscoveredProjects: () => DiscoveredProject[]
25
26
  machineDisplayName: string
27
+ updateManager: UpdateManager | null
26
28
  }
27
29
 
28
30
  function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
@@ -37,6 +39,7 @@ export function createWsRouter({
37
39
  refreshDiscovery,
38
40
  getDiscoveredProjects,
39
41
  machineDisplayName,
42
+ updateManager,
40
43
  }: CreateWsRouterArgs) {
41
44
  const sockets = new Set<ServerWebSocket<ClientState>>()
42
45
 
@@ -80,6 +83,26 @@ export function createWsRouter({
80
83
  }
81
84
  }
82
85
 
86
+ if (topic.type === "update") {
87
+ return {
88
+ v: PROTOCOL_VERSION,
89
+ type: "snapshot",
90
+ id,
91
+ snapshot: {
92
+ type: "update",
93
+ data: updateManager?.getSnapshot() ?? {
94
+ currentVersion: "unknown",
95
+ latestVersion: null,
96
+ status: "idle",
97
+ updateAvailable: false,
98
+ lastCheckedAt: null,
99
+ error: null,
100
+ installAction: "restart",
101
+ },
102
+ },
103
+ }
104
+ }
105
+
83
106
  if (topic.type === "terminal") {
84
107
  return {
85
108
  v: PROTOCOL_VERSION,
@@ -151,6 +174,15 @@ export function createWsRouter({
151
174
  }
152
175
  })
153
176
 
177
+ const disposeUpdateEvents = updateManager?.onChange(() => {
178
+ for (const ws of sockets) {
179
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
180
+ if (topic.type !== "update") continue
181
+ send(ws, createEnvelope(id, topic))
182
+ }
183
+ }
184
+ }) ?? (() => {})
185
+
154
186
  async function handleCommand(ws: ServerWebSocket<ClientState>, message: Extract<ClientEnvelope, { type: "command" }>) {
155
187
  const { command, id } = message
156
188
  try {
@@ -159,6 +191,34 @@ export function createWsRouter({
159
191
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
160
192
  return
161
193
  }
194
+ case "update.check": {
195
+ const snapshot = updateManager
196
+ ? await updateManager.checkForUpdates({ force: command.force })
197
+ : {
198
+ currentVersion: "unknown",
199
+ latestVersion: null,
200
+ status: "error",
201
+ updateAvailable: false,
202
+ lastCheckedAt: Date.now(),
203
+ error: "Update manager unavailable.",
204
+ installAction: "restart",
205
+ }
206
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
207
+ return
208
+ }
209
+ case "update.install": {
210
+ if (!updateManager) {
211
+ throw new Error("Update manager unavailable.")
212
+ }
213
+ const result = await updateManager.installUpdate()
214
+ send(ws, {
215
+ v: PROTOCOL_VERSION,
216
+ type: "ack",
217
+ id,
218
+ result,
219
+ })
220
+ return
221
+ }
162
222
  case "settings.readKeybindings": {
163
223
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: keybindings.getSnapshot() })
164
224
  return
@@ -316,6 +376,7 @@ export function createWsRouter({
316
376
  dispose() {
317
377
  disposeTerminalEvents()
318
378
  disposeKeybindingEvents()
379
+ disposeUpdateEvents()
319
380
  },
320
381
  }
321
382
  }
@@ -5,6 +5,7 @@ import type {
5
5
  LocalProjectsSnapshot,
6
6
  ModelOptions,
7
7
  SidebarData,
8
+ UpdateSnapshot,
8
9
  } from "./types"
9
10
 
10
11
  export type EditorPreset = "cursor" | "vscode" | "windsurf" | "custom"
@@ -17,6 +18,7 @@ export interface EditorOpenSettings {
17
18
  export type SubscriptionTopic =
18
19
  | { type: "sidebar" }
19
20
  | { type: "local-projects" }
21
+ | { type: "update" }
20
22
  | { type: "keybindings" }
21
23
  | { type: "chat"; chatId: string }
22
24
  | { type: "terminal"; terminalId: string }
@@ -44,6 +46,8 @@ export type ClientCommand =
44
46
  | { type: "project.create"; localPath: string; title: string }
45
47
  | { type: "project.remove"; projectId: string }
46
48
  | { type: "system.ping" }
49
+ | { type: "update.check"; force?: boolean }
50
+ | { type: "update.install" }
47
51
  | { type: "settings.readKeybindings" }
48
52
  | { type: "settings.writeKeybindings"; bindings: KeybindingsSnapshot["bindings"] }
49
53
  | {
@@ -83,6 +87,7 @@ export type ClientEnvelope =
83
87
  export type ServerSnapshot =
84
88
  | { type: "sidebar"; data: SidebarData }
85
89
  | { type: "local-projects"; data: LocalProjectsSnapshot }
90
+ | { type: "update"; data: UpdateSnapshot }
86
91
  | { type: "keybindings"; data: KeybindingsSnapshot }
87
92
  | { type: "chat"; data: ChatSnapshot | null }
88
93
  | { type: "terminal"; data: TerminalSnapshot | null }
@@ -167,6 +167,25 @@ export interface LocalProjectsSnapshot {
167
167
  projects: LocalProjectSummary[]
168
168
  }
169
169
 
170
+ export type UpdateStatus =
171
+ | "idle"
172
+ | "checking"
173
+ | "available"
174
+ | "up_to_date"
175
+ | "updating"
176
+ | "restart_pending"
177
+ | "error"
178
+
179
+ export interface UpdateSnapshot {
180
+ currentVersion: string
181
+ latestVersion: string | null
182
+ status: UpdateStatus
183
+ updateAvailable: boolean
184
+ lastCheckedAt: number | null
185
+ error: string | null
186
+ installAction: "restart" | "reload"
187
+ }
188
+
170
189
  export type KeybindingAction =
171
190
  | "toggleEmbeddedTerminal"
172
191
  | "toggleRightSidebar"