kaizenai 0.1.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 (74) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +246 -0
  3. package/bin/kaizen +15 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/index-D-ORCGrq.js +603 -0
  6. package/dist/client/assets/index-r28mcHqz.css +32 -0
  7. package/dist/client/favicon.png +0 -0
  8. package/dist/client/fonts/body-medium.woff2 +0 -0
  9. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  10. package/dist/client/fonts/body-regular.woff2 +0 -0
  11. package/dist/client/fonts/body-semibold.woff2 +0 -0
  12. package/dist/client/index.html +22 -0
  13. package/dist/client/manifest-dark.webmanifest +24 -0
  14. package/dist/client/manifest.webmanifest +24 -0
  15. package/dist/client/pwa-192.png +0 -0
  16. package/dist/client/pwa-512.png +0 -0
  17. package/dist/client/pwa-icon.svg +15 -0
  18. package/dist/client/pwa-splash.png +0 -0
  19. package/dist/client/pwa-splash.svg +15 -0
  20. package/package.json +103 -0
  21. package/src/server/acp-shared.ts +315 -0
  22. package/src/server/agent.ts +1120 -0
  23. package/src/server/attachments.ts +133 -0
  24. package/src/server/backgrounds.ts +74 -0
  25. package/src/server/cli-runtime.ts +333 -0
  26. package/src/server/cli-supervisor.ts +81 -0
  27. package/src/server/cli.ts +68 -0
  28. package/src/server/codex-app-server-protocol.ts +453 -0
  29. package/src/server/codex-app-server.ts +1350 -0
  30. package/src/server/cursor-acp.ts +819 -0
  31. package/src/server/discovery.ts +322 -0
  32. package/src/server/event-store.ts +1369 -0
  33. package/src/server/events.ts +244 -0
  34. package/src/server/external-open.ts +272 -0
  35. package/src/server/gemini-acp.ts +844 -0
  36. package/src/server/gemini-cli.ts +525 -0
  37. package/src/server/generate-title.ts +36 -0
  38. package/src/server/git-manager.ts +79 -0
  39. package/src/server/git-repository.ts +101 -0
  40. package/src/server/harness-types.ts +20 -0
  41. package/src/server/keybindings.ts +177 -0
  42. package/src/server/machine-name.ts +22 -0
  43. package/src/server/paths.ts +112 -0
  44. package/src/server/process-utils.ts +22 -0
  45. package/src/server/project-icon.ts +344 -0
  46. package/src/server/project-metadata.ts +10 -0
  47. package/src/server/provider-catalog.ts +85 -0
  48. package/src/server/provider-settings.ts +155 -0
  49. package/src/server/quick-response.ts +153 -0
  50. package/src/server/read-models.ts +275 -0
  51. package/src/server/recovery.ts +507 -0
  52. package/src/server/restart.ts +30 -0
  53. package/src/server/server.ts +244 -0
  54. package/src/server/terminal-manager.ts +350 -0
  55. package/src/server/theme-settings.ts +179 -0
  56. package/src/server/update-manager.ts +230 -0
  57. package/src/server/usage/base-provider-usage.ts +57 -0
  58. package/src/server/usage/claude-usage.ts +558 -0
  59. package/src/server/usage/codex-usage.ts +144 -0
  60. package/src/server/usage/cursor-browser.ts +120 -0
  61. package/src/server/usage/cursor-cookies.ts +390 -0
  62. package/src/server/usage/cursor-usage.ts +490 -0
  63. package/src/server/usage/gemini-usage.ts +24 -0
  64. package/src/server/usage/provider-usage.ts +61 -0
  65. package/src/server/usage/test-helpers.ts +9 -0
  66. package/src/server/usage/types.ts +54 -0
  67. package/src/server/usage/utils.ts +325 -0
  68. package/src/server/ws-router.ts +717 -0
  69. package/src/shared/branding.ts +83 -0
  70. package/src/shared/dev-ports.ts +43 -0
  71. package/src/shared/ports.ts +2 -0
  72. package/src/shared/protocol.ts +152 -0
  73. package/src/shared/tools.ts +251 -0
  74. package/src/shared/types.ts +1028 -0
@@ -0,0 +1,717 @@
1
+ import type { ServerWebSocket } from "bun"
2
+ import { DEFAULT_PROVIDER_SETTINGS, PROTOCOL_VERSION, getSelectableProviders, isProviderSelectable, type AgentProvider } from "../shared/types"
3
+ import type { ClientEnvelope, ServerEnvelope, SubscriptionTopic } from "../shared/protocol"
4
+ import { isClientEnvelope } from "../shared/protocol"
5
+ import type { AgentCoordinator } from "./agent"
6
+ import type { DiscoveredProject } from "./discovery"
7
+ import { EventStore } from "./event-store"
8
+ import { openExternal, openUrl } from "./external-open"
9
+ import { GitManager } from "./git-manager"
10
+ import { KeybindingsManager } from "./keybindings"
11
+ import { ProviderSettingsManager } from "./provider-settings"
12
+ import { ThemeSettingsManager } from "./theme-settings"
13
+ import { listProjectDirectories, requireProjectDirectory, ensureProjectDirectory } from "./paths"
14
+ import { importProjectHistory } from "./recovery"
15
+ import { TerminalManager } from "./terminal-manager"
16
+ import type { UpdateManager } from "./update-manager"
17
+ import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
18
+ import { refreshClaudeRateLimitFromCli } from "./usage/claude-usage"
19
+ import { importCursorUsageFromCurl, refreshCursorUsage, signInToCursorWithBrowser } from "./usage/cursor-usage"
20
+
21
+ export interface ClientState {
22
+ subscriptions: Map<string, SubscriptionTopic>
23
+ }
24
+
25
+ const PROVIDER_USAGE_POLL_INTERVAL_MS = 30 * 60 * 1000
26
+ const PROVIDER_USAGE_POLL_MAX_INTERVAL_MS = 31 * 60 * 1000
27
+
28
+ interface CreateWsRouterArgs {
29
+ store: EventStore
30
+ agent: AgentCoordinator
31
+ terminals: TerminalManager
32
+ git: GitManager
33
+ keybindings: KeybindingsManager
34
+ providerSettings?: ProviderSettingsManager
35
+ themeSettings: ThemeSettingsManager
36
+ refreshDiscovery: () => Promise<DiscoveredProject[]>
37
+ getDiscoveredProjects: () => DiscoveredProject[]
38
+ machineDisplayName: string
39
+ updateManager: UpdateManager | null
40
+ providerUsagePollIntervalMs?: number
41
+ refreshProviderUsage?: (provider?: AgentProvider, force?: boolean) => Promise<void>
42
+ openUrlCommand?: typeof openUrl
43
+ }
44
+
45
+ function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
46
+ ws.send(JSON.stringify(message))
47
+ }
48
+
49
+ export function createWsRouter({
50
+ store,
51
+ agent,
52
+ terminals,
53
+ git,
54
+ keybindings,
55
+ providerSettings,
56
+ themeSettings,
57
+ refreshDiscovery,
58
+ getDiscoveredProjects,
59
+ machineDisplayName,
60
+ updateManager,
61
+ providerUsagePollIntervalMs,
62
+ refreshProviderUsage = async (provider, force) => {
63
+ const selectableProviders = getSelectableProviders(providerSettings?.getSnapshot().settings ?? DEFAULT_PROVIDER_SETTINGS).map((entry) => entry.id)
64
+ if (((!provider && selectableProviders.includes("claude")) || provider === "claude") && selectableProviders.includes("claude")) {
65
+ await refreshClaudeRateLimitFromCli(store.dataDir, undefined, force).then(() => {})
66
+ }
67
+ if (((!provider && selectableProviders.includes("cursor")) || provider === "cursor") && selectableProviders.includes("cursor")) {
68
+ await refreshCursorUsage(store.dataDir, undefined, force).then(() => {})
69
+ }
70
+ },
71
+ openUrlCommand = openUrl,
72
+ }: CreateWsRouterArgs) {
73
+ const sockets = new Set<ServerWebSocket<ClientState>>()
74
+ let providerUsageRefreshInFlight: Promise<void> | null = null
75
+ let providerUsagePollTimer: Timer | null = null
76
+
77
+ function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
78
+ if (topic.type === "sidebar") {
79
+ return {
80
+ v: PROTOCOL_VERSION,
81
+ type: "snapshot",
82
+ id,
83
+ snapshot: {
84
+ type: "sidebar",
85
+ data: deriveSidebarData(store.state, agent.getActiveStatuses(), agent.getProviderUsage()),
86
+ },
87
+ }
88
+ }
89
+
90
+ if (topic.type === "local-projects") {
91
+ const discoveredProjects = getDiscoveredProjects()
92
+ const data = deriveLocalProjectsSnapshot(store.state, discoveredProjects, machineDisplayName)
93
+
94
+ return {
95
+ v: PROTOCOL_VERSION,
96
+ type: "snapshot",
97
+ id,
98
+ snapshot: {
99
+ type: "local-projects",
100
+ data,
101
+ },
102
+ }
103
+ }
104
+
105
+ if (topic.type === "keybindings") {
106
+ return {
107
+ v: PROTOCOL_VERSION,
108
+ type: "snapshot",
109
+ id,
110
+ snapshot: {
111
+ type: "keybindings",
112
+ data: keybindings.getSnapshot(),
113
+ },
114
+ }
115
+ }
116
+
117
+ if (topic.type === "theme-settings") {
118
+ return {
119
+ v: PROTOCOL_VERSION,
120
+ type: "snapshot",
121
+ id,
122
+ snapshot: {
123
+ type: "theme-settings",
124
+ data: themeSettings.getSnapshot(),
125
+ },
126
+ }
127
+ }
128
+
129
+ if (topic.type === "provider-settings") {
130
+ return {
131
+ v: PROTOCOL_VERSION,
132
+ type: "snapshot",
133
+ id,
134
+ snapshot: {
135
+ type: "provider-settings",
136
+ data: providerSettings?.getSnapshot() ?? {
137
+ settings: DEFAULT_PROVIDER_SETTINGS,
138
+ warning: null,
139
+ filePathDisplay: "",
140
+ },
141
+ },
142
+ }
143
+ }
144
+
145
+ if (topic.type === "update") {
146
+ return {
147
+ v: PROTOCOL_VERSION,
148
+ type: "snapshot",
149
+ id,
150
+ snapshot: {
151
+ type: "update",
152
+ data: updateManager?.getSnapshot() ?? {
153
+ currentVersion: "unknown",
154
+ latestVersion: null,
155
+ status: "idle",
156
+ updateAvailable: false,
157
+ lastCheckedAt: null,
158
+ error: null,
159
+ installAction: "restart",
160
+ },
161
+ },
162
+ }
163
+ }
164
+
165
+ if (topic.type === "terminal") {
166
+ return {
167
+ v: PROTOCOL_VERSION,
168
+ type: "snapshot",
169
+ id,
170
+ snapshot: {
171
+ type: "terminal",
172
+ data: terminals.getSnapshot(topic.terminalId),
173
+ },
174
+ }
175
+ }
176
+
177
+ return {
178
+ v: PROTOCOL_VERSION,
179
+ type: "snapshot",
180
+ id,
181
+ snapshot: {
182
+ type: "chat",
183
+ data: (() => {
184
+ return deriveChatSnapshot(
185
+ store.state,
186
+ agent.getActiveStatuses(),
187
+ topic.chatId,
188
+ (chatId) => store.getMessages(chatId),
189
+ agent.getChatPendingTool(topic.chatId),
190
+ agent.getLiveUsage(topic.chatId),
191
+ providerSettings?.getSnapshot().settings ?? DEFAULT_PROVIDER_SETTINGS
192
+ )
193
+ })(),
194
+ },
195
+ }
196
+ }
197
+
198
+ function pushSnapshots(ws: ServerWebSocket<ClientState>) {
199
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
200
+ send(ws, createEnvelope(id, topic))
201
+ }
202
+ }
203
+
204
+ function broadcastSnapshots() {
205
+ for (const ws of sockets) {
206
+ pushSnapshots(ws)
207
+ }
208
+ }
209
+
210
+ function broadcastSidebarSnapshots() {
211
+ for (const ws of sockets) {
212
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
213
+ if (topic.type !== "sidebar") continue
214
+ send(ws, createEnvelope(id, topic))
215
+ }
216
+ }
217
+ }
218
+
219
+ function hasSidebarSubscribers() {
220
+ for (const ws of sockets) {
221
+ for (const topic of ws.data.subscriptions.values()) {
222
+ if (topic.type === "sidebar") return true
223
+ }
224
+ }
225
+ return false
226
+ }
227
+
228
+ function pushTerminalSnapshot(terminalId: string) {
229
+ for (const ws of sockets) {
230
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
231
+ if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
232
+ send(ws, createEnvelope(id, topic))
233
+ }
234
+ }
235
+ }
236
+
237
+ function pushTerminalEvent(terminalId: string, event: Extract<ServerEnvelope, { type: "event" }>["event"]) {
238
+ for (const ws of sockets) {
239
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
240
+ if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
241
+ send(ws, {
242
+ v: PROTOCOL_VERSION,
243
+ type: "event",
244
+ id,
245
+ event,
246
+ })
247
+ }
248
+ }
249
+ }
250
+
251
+ const disposeTerminalEvents = terminals.onEvent((event) => {
252
+ pushTerminalEvent(event.terminalId, event)
253
+ })
254
+
255
+ const disposeKeybindingEvents = keybindings.onChange(() => {
256
+ for (const ws of sockets) {
257
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
258
+ if (topic.type !== "keybindings") continue
259
+ send(ws, createEnvelope(id, topic))
260
+ }
261
+ }
262
+ })
263
+
264
+ const disposeThemeSettingsEvents = themeSettings.onChange(() => {
265
+ for (const ws of sockets) {
266
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
267
+ if (topic.type !== "theme-settings") continue
268
+ send(ws, createEnvelope(id, topic))
269
+ }
270
+ }
271
+ })
272
+
273
+ const disposeProviderSettingsEvents = providerSettings?.onChange(() => {
274
+ for (const ws of sockets) {
275
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
276
+ if (topic.type !== "provider-settings") continue
277
+ send(ws, createEnvelope(id, topic))
278
+ }
279
+ }
280
+ broadcastSnapshots()
281
+ }) ?? (() => {})
282
+
283
+ const disposeUpdateEvents = updateManager?.onChange(() => {
284
+ for (const ws of sockets) {
285
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
286
+ if (topic.type !== "update") continue
287
+ send(ws, createEnvelope(id, topic))
288
+ }
289
+ }
290
+ }) ?? (() => {})
291
+
292
+ async function runProviderUsagePoll() {
293
+ if (!hasSidebarSubscribers()) return
294
+ if (!providerUsageRefreshInFlight) {
295
+ providerUsageRefreshInFlight = refreshProviderUsage().finally(() => {
296
+ providerUsageRefreshInFlight = null
297
+ })
298
+ }
299
+ await providerUsageRefreshInFlight
300
+ broadcastSidebarSnapshots()
301
+ }
302
+
303
+ function nextProviderUsagePollDelayMs() {
304
+ if (typeof providerUsagePollIntervalMs === "number") {
305
+ return providerUsagePollIntervalMs
306
+ }
307
+
308
+ return Math.floor(Math.random() * (PROVIDER_USAGE_POLL_MAX_INTERVAL_MS - PROVIDER_USAGE_POLL_INTERVAL_MS + 1))
309
+ + PROVIDER_USAGE_POLL_INTERVAL_MS
310
+ }
311
+
312
+ function scheduleProviderUsagePoll() {
313
+ if (providerUsagePollTimer) {
314
+ clearTimeout(providerUsagePollTimer)
315
+ }
316
+
317
+ providerUsagePollTimer = setTimeout(() => {
318
+ void runProviderUsagePoll().finally(() => {
319
+ scheduleProviderUsagePoll()
320
+ })
321
+ }, nextProviderUsagePollDelayMs())
322
+ }
323
+
324
+ scheduleProviderUsagePoll()
325
+
326
+ async function handleCommand(ws: ServerWebSocket<ClientState>, message: Extract<ClientEnvelope, { type: "command" }>) {
327
+ const { command, id } = message
328
+ try {
329
+ switch (command.type) {
330
+ case "system.ping": {
331
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
332
+ return
333
+ }
334
+ case "system.listDirectory": {
335
+ const directory = await listProjectDirectories(command.localPath)
336
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: directory })
337
+ return
338
+ }
339
+ case "update.check": {
340
+ const snapshot = updateManager
341
+ ? await updateManager.checkForUpdates({ force: command.force })
342
+ : {
343
+ currentVersion: "unknown",
344
+ latestVersion: null,
345
+ status: "error",
346
+ updateAvailable: false,
347
+ lastCheckedAt: Date.now(),
348
+ error: "Update manager unavailable.",
349
+ installAction: "restart",
350
+ }
351
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
352
+ return
353
+ }
354
+ case "update.install": {
355
+ if (!updateManager) {
356
+ throw new Error("Update manager unavailable.")
357
+ }
358
+ const result = await updateManager.installUpdate()
359
+ send(ws, {
360
+ v: PROTOCOL_VERSION,
361
+ type: "ack",
362
+ id,
363
+ result,
364
+ })
365
+ return
366
+ }
367
+ case "settings.readKeybindings": {
368
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: keybindings.getSnapshot() })
369
+ return
370
+ }
371
+ case "settings.writeKeybindings": {
372
+ const snapshot = await keybindings.write(command.bindings)
373
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
374
+ return
375
+ }
376
+ case "settings.writeThemeSettings": {
377
+ const snapshot = await themeSettings.write(command.settings)
378
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
379
+ return
380
+ }
381
+ case "settings.writeProviderSettings": {
382
+ if (!providerSettings) throw new Error("Provider settings unavailable.")
383
+ const snapshot = await providerSettings.write(command.settings)
384
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
385
+ return
386
+ }
387
+ case "project.open": {
388
+ await requireProjectDirectory(command.localPath)
389
+ await store.unhideProject(command.localPath)
390
+ const project = await store.openProject(command.localPath)
391
+ const imported = await importProjectHistory({
392
+ store,
393
+ projectId: project.id,
394
+ repoKey: project.repoKey,
395
+ localPath: project.localPath,
396
+ worktreePaths: project.worktreePaths,
397
+ })
398
+ await store.reconcileProjectFeatureState(project.id)
399
+ await refreshDiscovery()
400
+ send(ws, {
401
+ v: PROTOCOL_VERSION,
402
+ type: "ack",
403
+ id,
404
+ result: {
405
+ projectId: project.id,
406
+ chatId: imported.newestChatId,
407
+ importedChats: imported.importedChats,
408
+ },
409
+ })
410
+ break
411
+ }
412
+ case "project.create": {
413
+ await ensureProjectDirectory(command.localPath)
414
+ await store.unhideProject(command.localPath)
415
+ const project = await store.openProject(command.localPath, command.title)
416
+ const imported = await importProjectHistory({
417
+ store,
418
+ projectId: project.id,
419
+ repoKey: project.repoKey,
420
+ localPath: project.localPath,
421
+ worktreePaths: project.worktreePaths,
422
+ })
423
+ await store.reconcileProjectFeatureState(project.id)
424
+ await refreshDiscovery()
425
+ send(ws, {
426
+ v: PROTOCOL_VERSION,
427
+ type: "ack",
428
+ id,
429
+ result: {
430
+ projectId: project.id,
431
+ chatId: imported.newestChatId,
432
+ importedChats: imported.importedChats,
433
+ },
434
+ })
435
+ break
436
+ }
437
+ case "project.remove": {
438
+ const project = store.getProject(command.projectId)
439
+ for (const chat of store.listChatsByProject(command.projectId)) {
440
+ await agent.cancel(chat.id)
441
+ }
442
+ if (project) {
443
+ for (const worktreePath of project.worktreePaths) {
444
+ terminals.closeByCwd(worktreePath)
445
+ }
446
+ await store.hideProject(project.localPath)
447
+ }
448
+ await refreshDiscovery()
449
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
450
+ break
451
+ }
452
+ case "project.setBrowserState": {
453
+ await store.setProjectBrowserState(command.projectId, command.browserState)
454
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
455
+ break
456
+ }
457
+ case "project.setGeneralChatsBrowserState": {
458
+ await store.setProjectGeneralChatsBrowserState(command.projectId, command.browserState)
459
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
460
+ break
461
+ }
462
+ case "project.hide": {
463
+ const existingProject = store.listProjects().find((project) => project.localPath === command.localPath)
464
+ if (existingProject) {
465
+ for (const chat of store.listChatsByProject(existingProject.id)) {
466
+ await agent.cancel(chat.id)
467
+ }
468
+ for (const worktreePath of existingProject.worktreePaths) {
469
+ terminals.closeByCwd(worktreePath)
470
+ }
471
+ }
472
+ await store.hideProject(command.localPath)
473
+ await refreshDiscovery()
474
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
475
+ break
476
+ }
477
+ case "project.setProjectMetadataDirectoryCommitMode": {
478
+ const localPath = command.projectId
479
+ ? store.getProject(command.projectId)?.localPath
480
+ : command.localPath
481
+ if (!localPath) {
482
+ throw new Error("Project not found")
483
+ }
484
+ await git.setProjectMetadataDirectoryCommitMode(localPath, command.commitProjectMetadata)
485
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
486
+ break
487
+ }
488
+ case "system.openExternal": {
489
+ await openExternal(command)
490
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
491
+ break
492
+ }
493
+ case "system.openUrl": {
494
+ await openUrlCommand(command)
495
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
496
+ break
497
+ }
498
+ case "provider.refreshUsage": {
499
+ if (command.provider && !isProviderSelectable(command.provider, providerSettings?.getSnapshot().settings ?? DEFAULT_PROVIDER_SETTINGS)) {
500
+ throw new Error(`${command.provider} is inactive.`)
501
+ }
502
+ await refreshProviderUsage(command.provider, true)
503
+ broadcastSidebarSnapshots()
504
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
505
+ break
506
+ }
507
+ case "provider.browserLogin": {
508
+ if (command.provider !== "cursor") {
509
+ throw new Error(`Browser login is not supported for provider: ${command.provider}`)
510
+ }
511
+ const result = await signInToCursorWithBrowser(store.dataDir)
512
+ broadcastSidebarSnapshots()
513
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
514
+ break
515
+ }
516
+ case "provider.importUsageCurl": {
517
+ if (command.provider !== "cursor") {
518
+ throw new Error(`cURL import is not supported for provider: ${command.provider}`)
519
+ }
520
+ const result = await importCursorUsageFromCurl(store.dataDir, command.curlCommand)
521
+ broadcastSidebarSnapshots()
522
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
523
+ break
524
+ }
525
+ case "feature.create": {
526
+ const feature = await store.createFeature(command.projectId, command.title, command.description ?? "")
527
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { featureId: feature.id } })
528
+ break
529
+ }
530
+ case "feature.rename": {
531
+ await store.renameFeature(command.featureId, command.title)
532
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
533
+ break
534
+ }
535
+ case "feature.setBrowserState": {
536
+ await store.setFeatureBrowserState(command.featureId, command.browserState)
537
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
538
+ break
539
+ }
540
+ case "feature.setStage": {
541
+ await store.setFeatureStage(command.featureId, command.stage)
542
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
543
+ break
544
+ }
545
+ case "feature.reorder": {
546
+ await store.reorderFeatures(command.projectId, command.orderedFeatureIds)
547
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
548
+ break
549
+ }
550
+ case "feature.delete": {
551
+ await store.deleteFeature(command.featureId)
552
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
553
+ break
554
+ }
555
+ case "chat.create": {
556
+ // Reuse an existing empty chat for this project+feature if one exists
557
+ const existingChats = store.listChatsByProject(command.projectId)
558
+ const emptyChat = existingChats.find((chat) => {
559
+ if (chat.lastMessageAt != null) return false
560
+ const chatFeatureId = chat.featureId ?? undefined
561
+ return chatFeatureId === (command.featureId ?? undefined)
562
+ })
563
+
564
+ const chat = emptyChat ?? await store.createChat(command.projectId, command.featureId)
565
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
566
+ break
567
+ }
568
+ case "chat.setFeature": {
569
+ await store.setChatFeature(command.chatId, command.featureId)
570
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
571
+ break
572
+ }
573
+ case "chat.rename": {
574
+ await store.renameChat(command.chatId, command.title)
575
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
576
+ break
577
+ }
578
+ case "chat.delete": {
579
+ await agent.cancel(command.chatId)
580
+ await store.deleteChat(command.chatId)
581
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
582
+ break
583
+ }
584
+ case "chat.send": {
585
+ if (command.provider && !isProviderSelectable(command.provider, providerSettings?.getSnapshot().settings ?? DEFAULT_PROVIDER_SETTINGS)) {
586
+ throw new Error(`${command.provider} is inactive.`)
587
+ }
588
+ const result = await agent.send(command)
589
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
590
+ break
591
+ }
592
+ case "chat.cancel": {
593
+ await agent.cancel(command.chatId)
594
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
595
+ break
596
+ }
597
+ case "chat.respondTool": {
598
+ await agent.respondTool(command)
599
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
600
+ break
601
+ }
602
+ case "terminal.create": {
603
+ const project = store.getProject(command.projectId)
604
+ if (!project) {
605
+ throw new Error("Project not found")
606
+ }
607
+ const snapshot = terminals.createTerminal({
608
+ projectPath: project.localPath,
609
+ terminalId: command.terminalId,
610
+ cols: command.cols,
611
+ rows: command.rows,
612
+ scrollback: command.scrollback,
613
+ })
614
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
615
+ return
616
+ }
617
+ case "terminal.input": {
618
+ terminals.write(command.terminalId, command.data)
619
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
620
+ return
621
+ }
622
+ case "terminal.resize": {
623
+ terminals.resize(command.terminalId, command.cols, command.rows)
624
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
625
+ return
626
+ }
627
+ case "terminal.close": {
628
+ terminals.close(command.terminalId)
629
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
630
+ pushTerminalSnapshot(command.terminalId)
631
+ return
632
+ }
633
+ case "git.getBranches": {
634
+ const project = store.getProject(command.projectId)
635
+ if (!project) throw new Error("Project not found")
636
+ const result = await git.getBranches(project.localPath)
637
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
638
+ return
639
+ }
640
+ case "git.switchBranch": {
641
+ const project = store.getProject(command.projectId)
642
+ if (!project) throw new Error("Project not found")
643
+ const result = await git.switchBranch(project.localPath, command.branchName)
644
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
645
+ return
646
+ }
647
+ case "git.createBranch": {
648
+ const project = store.getProject(command.projectId)
649
+ if (!project) throw new Error("Project not found")
650
+ const result = await git.createBranch(project.localPath, command.branchName, command.checkout)
651
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
652
+ return
653
+ }
654
+ }
655
+
656
+ broadcastSnapshots()
657
+ } catch (error) {
658
+ const messageText = error instanceof Error ? error.message : String(error)
659
+ send(ws, { v: PROTOCOL_VERSION, type: "error", id, message: messageText })
660
+ }
661
+ }
662
+
663
+ return {
664
+ handleOpen(ws: ServerWebSocket<ClientState>) {
665
+ sockets.add(ws)
666
+ },
667
+ handleClose(ws: ServerWebSocket<ClientState>) {
668
+ sockets.delete(ws)
669
+ },
670
+ broadcastSnapshots,
671
+ handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
672
+ let parsed: unknown
673
+ try {
674
+ parsed = JSON.parse(String(raw))
675
+ } catch {
676
+ send(ws, { v: PROTOCOL_VERSION, type: "error", message: "Invalid JSON" })
677
+ return
678
+ }
679
+
680
+ if (!isClientEnvelope(parsed)) {
681
+ send(ws, { v: PROTOCOL_VERSION, type: "error", message: "Invalid envelope" })
682
+ return
683
+ }
684
+
685
+ if (parsed.type === "subscribe") {
686
+ ws.data.subscriptions.set(parsed.id, parsed.topic)
687
+ if (parsed.topic.type === "local-projects") {
688
+ void refreshDiscovery().then(() => {
689
+ if (ws.data.subscriptions.has(parsed.id)) {
690
+ send(ws, createEnvelope(parsed.id, parsed.topic))
691
+ }
692
+ })
693
+ }
694
+ send(ws, createEnvelope(parsed.id, parsed.topic))
695
+ return
696
+ }
697
+
698
+ if (parsed.type === "unsubscribe") {
699
+ ws.data.subscriptions.delete(parsed.id)
700
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id: parsed.id })
701
+ return
702
+ }
703
+
704
+ void handleCommand(ws, parsed)
705
+ },
706
+ dispose() {
707
+ if (providerUsagePollTimer) {
708
+ clearTimeout(providerUsagePollTimer)
709
+ }
710
+ disposeTerminalEvents()
711
+ disposeKeybindingEvents()
712
+ disposeProviderSettingsEvents()
713
+ disposeThemeSettingsEvents()
714
+ disposeUpdateEvents()
715
+ },
716
+ }
717
+ }