codepiper 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 (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,2745 @@
1
+ import { Database as BunDatabase, type SQLQueryBindings } from "bun:sqlite";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import type { ProviderId, SessionHandle, SessionStatus } from "@codepiper/core";
5
+
6
+ /**
7
+ * Event source types
8
+ */
9
+ export type EventSource = "pty" | "hook" | "transcript" | "statusline";
10
+
11
+ /**
12
+ * Database event record
13
+ */
14
+ export interface EventRecord {
15
+ id: number;
16
+ sessionId: string;
17
+ timestamp: Date;
18
+ source: EventSource;
19
+ type: string;
20
+ payload: unknown;
21
+ }
22
+
23
+ export interface SessionNotificationRecord {
24
+ id: number;
25
+ sessionId: string;
26
+ provider: string;
27
+ eventType: string;
28
+ sourceEventId: number | null;
29
+ title: string;
30
+ body: string | null;
31
+ payload: Record<string, unknown>;
32
+ createdAt: Date;
33
+ readAt: Date | null;
34
+ readSource: string | null;
35
+ }
36
+
37
+ export interface InsertSessionNotificationParams {
38
+ sessionId: string;
39
+ provider: string;
40
+ eventType: string;
41
+ sourceEventId?: number;
42
+ title: string;
43
+ body?: string;
44
+ payload: Record<string, unknown>;
45
+ createdAt?: Date;
46
+ readAt?: Date;
47
+ readSource?: string;
48
+ }
49
+
50
+ export interface InsertSessionNotificationResult {
51
+ id: number;
52
+ inserted: boolean;
53
+ }
54
+
55
+ export interface ListSessionNotificationsOptions {
56
+ sessionId?: string;
57
+ eventType?: string;
58
+ unreadOnly?: boolean;
59
+ before?: number;
60
+ limit?: number;
61
+ }
62
+
63
+ export interface MarkSessionNotificationsReadOptions {
64
+ sessionId?: string;
65
+ readSource?: string;
66
+ readAt?: Date;
67
+ }
68
+
69
+ export interface SessionNotificationCounts {
70
+ totalUnread: number;
71
+ bySession: Record<string, number>;
72
+ }
73
+
74
+ export interface SessionNotificationPrefsRecord {
75
+ sessionId: string;
76
+ enabled: boolean | null;
77
+ updatedAt: Date;
78
+ }
79
+
80
+ export interface PushSubscriptionRecord {
81
+ endpoint: string;
82
+ keys: {
83
+ p256dh: string;
84
+ auth: string;
85
+ };
86
+ expirationTime: number | null;
87
+ createdAt: Date;
88
+ updatedAt: Date;
89
+ }
90
+
91
+ export interface UpsertPushSubscriptionParams {
92
+ endpoint: string;
93
+ keys: {
94
+ p256dh: string;
95
+ auth: string;
96
+ };
97
+ expirationTime?: number | null;
98
+ }
99
+
100
+ /**
101
+ * Session creation parameters
102
+ */
103
+ export interface CreateSessionParams {
104
+ id: string;
105
+ provider: ProviderId;
106
+ cwd: string;
107
+ status: SessionStatus;
108
+ pid?: number;
109
+ ptyRows?: number;
110
+ ptyCols?: number;
111
+ transcriptPath?: string;
112
+ metadata?: Record<string, unknown>;
113
+ }
114
+
115
+ /**
116
+ * Session update parameters
117
+ */
118
+ export interface UpdateSessionParams {
119
+ status?: SessionStatus;
120
+ pid?: number;
121
+ ptyRows?: number;
122
+ ptyCols?: number;
123
+ transcriptPath?: string;
124
+ metadata?: Record<string, unknown>;
125
+ }
126
+
127
+ /**
128
+ * Session list filter options
129
+ */
130
+ export interface ListSessionsOptions {
131
+ status?: SessionStatus;
132
+ provider?: ProviderId;
133
+ }
134
+
135
+ /**
136
+ * Event insertion parameters
137
+ */
138
+ export interface InsertEventParams {
139
+ sessionId: string;
140
+ source: EventSource;
141
+ type: string;
142
+ payload: unknown;
143
+ timestamp?: Date;
144
+ }
145
+
146
+ /**
147
+ * Event query options
148
+ */
149
+ export interface GetEventsOptions {
150
+ since?: number;
151
+ before?: number;
152
+ limit?: number;
153
+ type?: string;
154
+ source?: EventSource;
155
+ order?: "asc" | "desc";
156
+ }
157
+
158
+ /**
159
+ * Transcript offset record
160
+ */
161
+ export interface TranscriptOffset {
162
+ byteOffset: number;
163
+ lastLineHash: string | null;
164
+ }
165
+
166
+ /**
167
+ * Policy record
168
+ */
169
+ export interface PolicyRecord {
170
+ id: string;
171
+ name: string;
172
+ description?: string;
173
+ enabled: boolean;
174
+ priority: number;
175
+ sessionId?: string;
176
+ rules: PolicyRule[];
177
+ createdAt: Date;
178
+ updatedAt: Date;
179
+ }
180
+
181
+ /**
182
+ * Policy rule
183
+ */
184
+ export interface PolicyRule {
185
+ id: string;
186
+ action: "allow" | "deny" | "ask";
187
+ tool?: string | string[];
188
+ args?: Record<string, string | string[]>;
189
+ cwd?: string | string[];
190
+ session?: string | string[];
191
+ reason?: string;
192
+ }
193
+
194
+ /**
195
+ * Policy creation parameters
196
+ */
197
+ export interface CreatePolicyParams {
198
+ id: string;
199
+ name: string;
200
+ description?: string;
201
+ enabled?: boolean;
202
+ priority?: number;
203
+ sessionId?: string;
204
+ rules: PolicyRule[];
205
+ }
206
+
207
+ /**
208
+ * Policy update parameters
209
+ */
210
+ export interface UpdatePolicyParams {
211
+ name?: string;
212
+ description?: string;
213
+ enabled?: boolean;
214
+ priority?: number;
215
+ rules?: PolicyRule[];
216
+ }
217
+
218
+ /**
219
+ * Policy list filter options
220
+ */
221
+ export interface ListPoliciesOptions {
222
+ sessionId?: string;
223
+ enabled?: boolean;
224
+ }
225
+
226
+ /**
227
+ * Policy decision record (audit log)
228
+ */
229
+ export interface PolicyDecisionRecord {
230
+ id: number;
231
+ sessionId: string;
232
+ eventId?: number;
233
+ policyId?: string;
234
+ toolName: string;
235
+ args?: Record<string, unknown>;
236
+ decision: "allow" | "deny" | "ask";
237
+ reason?: string;
238
+ timestamp: Date;
239
+ }
240
+
241
+ /**
242
+ * Policy decision insertion parameters
243
+ */
244
+ export interface InsertPolicyDecisionParams {
245
+ sessionId: string;
246
+ eventId?: number;
247
+ policyId?: string;
248
+ toolName: string;
249
+ args?: Record<string, unknown>;
250
+ decision: "allow" | "deny" | "ask";
251
+ reason?: string;
252
+ timestamp?: Date;
253
+ }
254
+
255
+ /**
256
+ * Policy decision query options
257
+ */
258
+ export interface GetPolicyDecisionsOptions {
259
+ since?: number;
260
+ limit?: number;
261
+ decision?: "allow" | "deny" | "ask";
262
+ }
263
+
264
+ /**
265
+ * Policy set record
266
+ */
267
+ export interface PolicySetRecord {
268
+ id: string;
269
+ name: string;
270
+ description?: string;
271
+ isDefault: boolean;
272
+ createdAt: Date;
273
+ updatedAt: Date;
274
+ }
275
+
276
+ /**
277
+ * Policy set summary (with counts from joins)
278
+ */
279
+ export interface PolicySetSummary {
280
+ id: string;
281
+ name: string;
282
+ description?: string;
283
+ isDefault: boolean;
284
+ policyCount: number;
285
+ sessionCount: number;
286
+ createdAt: Date;
287
+ updatedAt: Date;
288
+ }
289
+
290
+ /**
291
+ * Policy set creation parameters
292
+ */
293
+ export interface CreatePolicySetParams {
294
+ id: string;
295
+ name: string;
296
+ description?: string;
297
+ isDefault?: boolean;
298
+ policyIds?: string[];
299
+ }
300
+
301
+ /**
302
+ * Policy set update parameters
303
+ */
304
+ export interface UpdatePolicySetParams {
305
+ name?: string;
306
+ description?: string;
307
+ isDefault?: boolean;
308
+ }
309
+
310
+ /**
311
+ * Token usage record
312
+ */
313
+ export interface TokenUsageRecord {
314
+ id: number;
315
+ sessionId: string;
316
+ eventId?: number;
317
+ timestamp: Date;
318
+ model: string;
319
+ promptTokens: number;
320
+ completionTokens: number;
321
+ cacheCreationInputTokens: number;
322
+ cacheReadInputTokens: number;
323
+ totalTokens: number;
324
+ estimatedCostUsd?: number;
325
+ actualCostUsd?: number;
326
+ costDifferenceUsd?: number;
327
+ }
328
+
329
+ /**
330
+ * Token usage insertion parameters
331
+ */
332
+ export interface InsertTokenUsageParams {
333
+ sessionId: string;
334
+ eventId?: number;
335
+ timestamp?: Date;
336
+ model: string;
337
+ promptTokens: number;
338
+ completionTokens: number;
339
+ cacheCreationInputTokens?: number;
340
+ cacheReadInputTokens?: number;
341
+ totalTokens: number;
342
+ estimatedCostUsd?: number;
343
+ actualCostUsd?: number;
344
+ costDifferenceUsd?: number;
345
+ }
346
+
347
+ /**
348
+ * Token usage query options
349
+ */
350
+ export interface GetTokenUsageOptions {
351
+ since?: Date;
352
+ until?: Date;
353
+ limit?: number;
354
+ model?: string;
355
+ }
356
+
357
+ /**
358
+ * Model switch record
359
+ */
360
+ export interface ModelSwitchRecord {
361
+ id: number;
362
+ sessionId: string;
363
+ timestamp: Date;
364
+ fromModel?: string;
365
+ toModel: string;
366
+ reason?: string;
367
+ }
368
+
369
+ /**
370
+ * Model switch insertion parameters
371
+ */
372
+ export interface InsertModelSwitchParams {
373
+ sessionId: string;
374
+ timestamp?: Date;
375
+ fromModel?: string;
376
+ toModel: string;
377
+ reason?: string;
378
+ }
379
+
380
+ /**
381
+ * Transcript content record
382
+ */
383
+ export interface TranscriptContentRecord {
384
+ id: number;
385
+ sessionId: string;
386
+ eventId?: number;
387
+ role: "user" | "assistant" | "system";
388
+ content: string;
389
+ timestamp: Date;
390
+ }
391
+
392
+ /**
393
+ * Transcript content insertion parameters
394
+ */
395
+ export interface InsertTranscriptContentParams {
396
+ sessionId: string;
397
+ eventId?: number;
398
+ role: "user" | "assistant" | "system";
399
+ content: string;
400
+ timestamp?: Date;
401
+ }
402
+
403
+ /**
404
+ * Transcript content query options
405
+ */
406
+ export interface GetTranscriptContentOptions {
407
+ since?: Date;
408
+ until?: Date;
409
+ limit?: number;
410
+ role?: "user" | "assistant" | "system";
411
+ }
412
+
413
+ /**
414
+ * Workspace record
415
+ */
416
+ export interface WorkspaceRecord {
417
+ id: string;
418
+ name: string;
419
+ path: string;
420
+ createdAt: Date;
421
+ updatedAt: Date;
422
+ }
423
+
424
+ /**
425
+ * Workspace creation parameters
426
+ */
427
+ export interface CreateWorkspaceParams {
428
+ id: string;
429
+ name: string;
430
+ path: string;
431
+ }
432
+
433
+ /**
434
+ * Workspace update parameters
435
+ */
436
+ export interface UpdateWorkspaceParams {
437
+ name?: string;
438
+ path?: string;
439
+ }
440
+
441
+ /**
442
+ * Env set record (with masked values)
443
+ */
444
+ export interface EnvSetRecord {
445
+ id: string;
446
+ name: string;
447
+ description?: string;
448
+ maskedVars: Record<string, string>;
449
+ varCount: number;
450
+ createdAt: Date;
451
+ updatedAt: Date;
452
+ }
453
+
454
+ /**
455
+ * Env set creation parameters
456
+ */
457
+ export interface CreateEnvSetParams {
458
+ id: string;
459
+ name: string;
460
+ description?: string;
461
+ vars: Record<string, string>;
462
+ }
463
+
464
+ /**
465
+ * Env set update parameters
466
+ */
467
+ export interface UpdateEnvSetParams {
468
+ name?: string;
469
+ description?: string;
470
+ vars?: Record<string, string>;
471
+ }
472
+
473
+ /**
474
+ * Auth config record
475
+ */
476
+ export interface AuthConfigRecord {
477
+ passwordHash: string;
478
+ totpSecretEncrypted: string | null;
479
+ totpEnabled: boolean;
480
+ mfaSetupPending: boolean;
481
+ onboardingTokenHash: string | null;
482
+ onboardingTokenExpiresAt: number | null;
483
+ recoveryCodesEncrypted: string | null;
484
+ createdAt: Date;
485
+ updatedAt: Date;
486
+ }
487
+
488
+ /**
489
+ * Auth session record
490
+ */
491
+ export interface AuthSessionRecord {
492
+ tokenHash: string;
493
+ createdAt: number;
494
+ expiresAt: number;
495
+ lastUsedAt: number;
496
+ ipAddress: string | null;
497
+ userAgent: string | null;
498
+ }
499
+
500
+ /**
501
+ * Daemon settings record
502
+ */
503
+ export interface DaemonTerminalFeatureSettings {
504
+ wsPtyPasteEnabled: boolean;
505
+ latencyProbesEnabled: boolean;
506
+ diagnosticsPanelEnabled: boolean;
507
+ codexAppServerSpikeEnabled: boolean;
508
+ wsPtyPasteCanaryPercent: number;
509
+ latencyProbesCanaryPercent: number;
510
+ diagnosticsPanelCanaryPercent: number;
511
+ }
512
+
513
+ export interface DaemonSettingsRecord {
514
+ preserveSessions: boolean;
515
+ defaultPolicyAction: "ask" | "deny";
516
+ forwardSshAuthSock: boolean;
517
+ codexHostAccessProfileEnabled: boolean;
518
+ terminalFeatures: DaemonTerminalFeatureSettings;
519
+ notificationsEnabled: boolean;
520
+ systemNotificationsEnabled: boolean;
521
+ notificationSoundsEnabled: boolean;
522
+ notificationEventDefaults: Record<string, boolean>;
523
+ notificationSoundMap: Record<string, string>;
524
+ updatedAt: Date;
525
+ }
526
+
527
+ /**
528
+ * Database interface for session and event management
529
+ */
530
+ export interface IDatabase {
531
+ init(): Promise<void>;
532
+ close(): void;
533
+
534
+ // Session operations
535
+ createSession(params: CreateSessionParams): void;
536
+ getSession(id: string): SessionHandle | undefined;
537
+ updateSession(id: string, params: UpdateSessionParams): void;
538
+ deleteSession(id: string): void;
539
+ listSessions(options?: ListSessionsOptions): SessionHandle[];
540
+ cleanupOldSessions(olderThanMs: number): number;
541
+
542
+ // Event operations
543
+ insertEvent(params: InsertEventParams): number;
544
+ getEventsBySessionId(sessionId: string, options?: GetEventsOptions): EventRecord[];
545
+
546
+ // Session notification operations
547
+ insertSessionNotification(params: InsertSessionNotificationParams): number;
548
+ insertSessionNotificationWithStatus(
549
+ params: InsertSessionNotificationParams
550
+ ): InsertSessionNotificationResult;
551
+ listSessionNotifications(options?: ListSessionNotificationsOptions): SessionNotificationRecord[];
552
+ getSessionNotificationCounts(): SessionNotificationCounts;
553
+ markSessionNotificationRead(notificationId: number, readSource?: string, readAt?: Date): boolean;
554
+ markSessionNotificationsRead(options?: MarkSessionNotificationsReadOptions): number;
555
+ getSessionNotificationPrefs(sessionId: string): SessionNotificationPrefsRecord;
556
+ setSessionNotificationPrefs(
557
+ sessionId: string,
558
+ enabled: boolean | null
559
+ ): SessionNotificationPrefsRecord;
560
+ listPushSubscriptions(): PushSubscriptionRecord[];
561
+ upsertPushSubscription(params: UpsertPushSubscriptionParams): PushSubscriptionRecord;
562
+ deletePushSubscription(endpoint: string): boolean;
563
+
564
+ // Transcript offset operations
565
+ getTranscriptOffset(sessionId: string, path: string): TranscriptOffset;
566
+ updateTranscriptOffset(sessionId: string, path: string, offset: TranscriptOffset): void;
567
+
568
+ // Policy operations
569
+ createPolicy(params: CreatePolicyParams): void;
570
+ getPolicy(id: string): PolicyRecord | undefined;
571
+ updatePolicy(id: string, params: UpdatePolicyParams): void;
572
+ deletePolicy(id: string): void;
573
+ listPolicies(options?: ListPoliciesOptions): PolicyRecord[];
574
+
575
+ // Policy decision operations (audit log)
576
+ insertPolicyDecision(params: InsertPolicyDecisionParams): number;
577
+ getPolicyDecisionsBySessionId(
578
+ sessionId: string,
579
+ options?: GetPolicyDecisionsOptions
580
+ ): PolicyDecisionRecord[];
581
+
582
+ // Policy set operations
583
+ createPolicySet(params: CreatePolicySetParams): void;
584
+ getPolicySet(id: string): PolicySetRecord | undefined;
585
+ updatePolicySet(id: string, params: UpdatePolicySetParams): void;
586
+ deletePolicySet(id: string): void;
587
+ listPolicySets(): PolicySetSummary[];
588
+ addPolicyToSet(setId: string, policyId: string): void;
589
+ removePolicyFromSet(setId: string, policyId: string): void;
590
+ getPolicySetMembers(setId: string): PolicyRecord[];
591
+ applyPolicySetToSession(sessionId: string, setId: string): void;
592
+ removePolicySetFromSession(sessionId: string, setId: string): void;
593
+ getSessionPolicySets(sessionId: string): PolicySetSummary[];
594
+ getEffectivePolicies(sessionId: string): PolicyRecord[];
595
+ getDefaultPolicySet(): PolicySetRecord | undefined;
596
+
597
+ // Token usage operations
598
+ insertTokenUsage(params: InsertTokenUsageParams): number;
599
+ getTokenUsageBySessionId(sessionId: string, options?: GetTokenUsageOptions): TokenUsageRecord[];
600
+ getTotalTokensBySessionId(sessionId: string): {
601
+ totalTokens: number;
602
+ totalCost: number;
603
+ byModel: Record<string, { tokens: number; cost: number }>;
604
+ };
605
+
606
+ // Model switch operations
607
+ insertModelSwitch(params: InsertModelSwitchParams): number;
608
+ getModelSwitchesBySessionId(sessionId: string): ModelSwitchRecord[];
609
+
610
+ // Transcript content operations
611
+ insertTranscriptContent(params: InsertTranscriptContentParams): number;
612
+ getTranscriptContentBySessionId(
613
+ sessionId: string,
614
+ options?: GetTranscriptContentOptions
615
+ ): TranscriptContentRecord[];
616
+
617
+ // Workspace operations
618
+ createWorkspace(params: CreateWorkspaceParams): void;
619
+ getWorkspace(id: string): WorkspaceRecord | undefined;
620
+ updateWorkspace(id: string, params: UpdateWorkspaceParams): void;
621
+ deleteWorkspace(id: string): void;
622
+ listWorkspaces(): WorkspaceRecord[];
623
+
624
+ // Env set operations
625
+ createEnvSet(params: CreateEnvSetParams): void;
626
+ getEnvSet(id: string): EnvSetRecord | undefined;
627
+ updateEnvSet(id: string, params: UpdateEnvSetParams): void;
628
+ deleteEnvSet(id: string): void;
629
+ listEnvSets(): EnvSetRecord[];
630
+ decryptEnvSetVars(id: string): Record<string, string>;
631
+
632
+ // Auth operations
633
+ hasAuthConfig(): boolean;
634
+ getAuthConfig(): AuthConfigRecord | null;
635
+ createAuthConfig(
636
+ passwordHash: string,
637
+ options?: {
638
+ mfaSetupPending?: boolean;
639
+ onboardingTokenHash?: string | null;
640
+ onboardingTokenExpiresAt?: number | null;
641
+ }
642
+ ): void;
643
+ updateAuthPassword(passwordHash: string): void;
644
+ updateAuthTotp(
645
+ encryptedSecret: string | null,
646
+ enabled: boolean,
647
+ recoveryCodes: string | null
648
+ ): void;
649
+ updateAuthOnboardingState(
650
+ mfaSetupPending: boolean,
651
+ onboardingTokenHash: string | null,
652
+ onboardingTokenExpiresAt: number | null
653
+ ): void;
654
+ createAuthSession(
655
+ tokenHash: string,
656
+ ip: string | null,
657
+ userAgent: string | null,
658
+ expiresAt: number
659
+ ): void;
660
+ getAuthSession(tokenHash: string): AuthSessionRecord | null;
661
+ touchAuthSession(tokenHash: string, newExpiresAt: number): void;
662
+ deleteAuthSession(tokenHash: string): void;
663
+ deleteAllAuthSessions(): void;
664
+ listAuthSessions(): AuthSessionRecord[];
665
+ cleanupExpiredAuthSessions(): number;
666
+
667
+ // Daemon settings operations
668
+ getDaemonSettings(): DaemonSettingsRecord;
669
+ updateDaemonSettings(params: {
670
+ preserveSessions?: boolean;
671
+ defaultPolicyAction?: "ask" | "deny";
672
+ forwardSshAuthSock?: boolean;
673
+ codexHostAccessProfileEnabled?: boolean;
674
+ terminalFeatures?: Partial<DaemonTerminalFeatureSettings>;
675
+ notificationsEnabled?: boolean;
676
+ systemNotificationsEnabled?: boolean;
677
+ notificationSoundsEnabled?: boolean;
678
+ notificationEventDefaults?: Record<string, boolean>;
679
+ notificationSoundMap?: Record<string, string>;
680
+ }): void;
681
+
682
+ // Utility (for testing)
683
+ query(sql: string): unknown[];
684
+ }
685
+
686
+ /**
687
+ * SQLite-backed database implementation
688
+ */
689
+ export class Database implements IDatabase {
690
+ public readonly db: BunDatabase;
691
+ private schemaPath: string;
692
+
693
+ constructor(dbPath: string = ":memory:") {
694
+ this.db = new BunDatabase(dbPath);
695
+ this.schemaPath = join(dirname(__filename), "schema.sql");
696
+
697
+ // Enable foreign keys
698
+ this.db.run("PRAGMA foreign_keys = ON");
699
+ }
700
+
701
+ /**
702
+ * Initialize database schema
703
+ */
704
+ async init(): Promise<void> {
705
+ const schema = readFileSync(this.schemaPath, "utf-8");
706
+ this.db.exec(schema);
707
+
708
+ // Migrate: add default_policy_action column to daemon_settings (for existing databases)
709
+ try {
710
+ this.db.exec(
711
+ "ALTER TABLE daemon_settings ADD COLUMN default_policy_action TEXT NOT NULL DEFAULT 'ask'"
712
+ );
713
+ } catch (err) {
714
+ const msg = err instanceof Error ? err.message : String(err);
715
+ if (!msg.includes("duplicate column name")) {
716
+ throw new Error(`Failed to migrate daemon_settings table: ${msg}`);
717
+ }
718
+ }
719
+
720
+ const daemonSettingsMigrations = [
721
+ "ALTER TABLE daemon_settings ADD COLUMN forward_ssh_auth_sock INTEGER NOT NULL DEFAULT 1",
722
+ "ALTER TABLE daemon_settings ADD COLUMN codex_host_access_profile_enabled INTEGER NOT NULL DEFAULT 0",
723
+ "ALTER TABLE daemon_settings ADD COLUMN terminal_ws_pty_paste_enabled INTEGER NOT NULL DEFAULT 1",
724
+ "ALTER TABLE daemon_settings ADD COLUMN terminal_latency_probes_enabled INTEGER NOT NULL DEFAULT 1",
725
+ "ALTER TABLE daemon_settings ADD COLUMN terminal_diagnostics_panel_enabled INTEGER NOT NULL DEFAULT 0",
726
+ "ALTER TABLE daemon_settings ADD COLUMN terminal_codex_app_server_spike_enabled INTEGER NOT NULL DEFAULT 0",
727
+ "ALTER TABLE daemon_settings ADD COLUMN terminal_ws_pty_paste_canary_percent INTEGER NOT NULL DEFAULT 100",
728
+ "ALTER TABLE daemon_settings ADD COLUMN terminal_latency_probes_canary_percent INTEGER NOT NULL DEFAULT 100",
729
+ "ALTER TABLE daemon_settings ADD COLUMN terminal_diagnostics_panel_canary_percent INTEGER NOT NULL DEFAULT 0",
730
+ "ALTER TABLE daemon_settings ADD COLUMN notifications_enabled INTEGER NOT NULL DEFAULT 0",
731
+ "ALTER TABLE daemon_settings ADD COLUMN system_notifications_enabled INTEGER NOT NULL DEFAULT 0",
732
+ "ALTER TABLE daemon_settings ADD COLUMN notification_sounds_enabled INTEGER NOT NULL DEFAULT 1",
733
+ "ALTER TABLE daemon_settings ADD COLUMN notification_event_defaults_json TEXT NOT NULL DEFAULT '{}'",
734
+ "ALTER TABLE daemon_settings ADD COLUMN notification_sound_map_json TEXT NOT NULL DEFAULT '{}'",
735
+ ] as const;
736
+
737
+ for (const migration of daemonSettingsMigrations) {
738
+ try {
739
+ this.db.exec(migration);
740
+ } catch (err) {
741
+ const msg = err instanceof Error ? err.message : String(err);
742
+ if (!msg.includes("duplicate column name")) {
743
+ throw new Error(`Failed to migrate daemon_settings table: ${msg}`);
744
+ }
745
+ }
746
+ }
747
+
748
+ const authConfigMigrations = [
749
+ "ALTER TABLE auth_config ADD COLUMN mfa_setup_pending INTEGER NOT NULL DEFAULT 0",
750
+ "ALTER TABLE auth_config ADD COLUMN onboarding_token_hash TEXT",
751
+ "ALTER TABLE auth_config ADD COLUMN onboarding_token_expires_at INTEGER",
752
+ ] as const;
753
+
754
+ for (const migration of authConfigMigrations) {
755
+ try {
756
+ this.db.exec(migration);
757
+ } catch (err) {
758
+ const msg = err instanceof Error ? err.message : String(err);
759
+ if (!msg.includes("duplicate column name")) {
760
+ throw new Error(`Failed to migrate auth_config table: ${msg}`);
761
+ }
762
+ }
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Close database connection
768
+ */
769
+ close(): void {
770
+ this.db.close();
771
+ }
772
+
773
+ /**
774
+ * Execute raw query (for testing)
775
+ */
776
+ query(sql: string): unknown[] {
777
+ return this.db.query(sql).all();
778
+ }
779
+
780
+ /**
781
+ * Create a new session
782
+ */
783
+ createSession(params: CreateSessionParams): void {
784
+ const now = Date.now();
785
+ const stmt = this.db.prepare(`
786
+ INSERT INTO sessions (
787
+ id, provider, cwd, status, created_at, updated_at,
788
+ pid, pty_cols, pty_rows, transcript_path, metadata_json
789
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
790
+ `);
791
+
792
+ stmt.run(
793
+ params.id,
794
+ params.provider,
795
+ params.cwd,
796
+ params.status,
797
+ now,
798
+ now,
799
+ params.pid ?? null,
800
+ params.ptyCols ?? null,
801
+ params.ptyRows ?? null,
802
+ params.transcriptPath ?? null,
803
+ params.metadata ? JSON.stringify(params.metadata) : null
804
+ );
805
+ }
806
+
807
+ /**
808
+ * Get session by ID
809
+ */
810
+ getSession(id: string): SessionHandle | undefined {
811
+ const stmt = this.db.prepare(`
812
+ SELECT * FROM sessions WHERE id = ?
813
+ `);
814
+
815
+ const row = stmt.get(id) as any;
816
+ if (!row) return undefined;
817
+
818
+ return this.mapRowToSessionHandle(row);
819
+ }
820
+
821
+ /**
822
+ * Update session fields
823
+ */
824
+ updateSession(id: string, params: UpdateSessionParams): void {
825
+ const updates: string[] = [];
826
+ const values: SQLQueryBindings[] = [];
827
+
828
+ if (params.status !== undefined) {
829
+ updates.push("status = ?");
830
+ values.push(params.status);
831
+ }
832
+ if (params.pid !== undefined) {
833
+ updates.push("pid = ?");
834
+ values.push(params.pid);
835
+ }
836
+ if (params.ptyRows !== undefined) {
837
+ updates.push("pty_rows = ?");
838
+ values.push(params.ptyRows);
839
+ }
840
+ if (params.ptyCols !== undefined) {
841
+ updates.push("pty_cols = ?");
842
+ values.push(params.ptyCols);
843
+ }
844
+ if (params.transcriptPath !== undefined) {
845
+ updates.push("transcript_path = ?");
846
+ values.push(params.transcriptPath);
847
+ }
848
+ if (params.metadata !== undefined) {
849
+ updates.push("metadata_json = ?");
850
+ values.push(JSON.stringify(params.metadata));
851
+ }
852
+
853
+ // Always update updated_at
854
+ updates.push("updated_at = ?");
855
+ values.push(Date.now());
856
+
857
+ if (updates.length === 1) {
858
+ // Only updated_at changed, still execute
859
+ }
860
+
861
+ values.push(id);
862
+
863
+ const sql = `UPDATE sessions SET ${updates.join(", ")} WHERE id = ?`;
864
+ const stmt = this.db.prepare(sql);
865
+ stmt.run(...values);
866
+ }
867
+
868
+ /**
869
+ * Delete session
870
+ */
871
+ deleteSession(id: string): void {
872
+ const stmt = this.db.prepare("DELETE FROM sessions WHERE id = ?");
873
+ stmt.run(id);
874
+ }
875
+
876
+ /**
877
+ * Clean up old STOPPED or CRASHED sessions
878
+ * @param olderThanMs - Delete sessions older than this many milliseconds
879
+ * @returns Number of sessions deleted
880
+ */
881
+ cleanupOldSessions(olderThanMs: number): number {
882
+ const cutoffTimestamp = Date.now() - olderThanMs;
883
+
884
+ const stmt = this.db.prepare(`
885
+ DELETE FROM sessions
886
+ WHERE (status = 'STOPPED' OR status = 'CRASHED')
887
+ AND updated_at < ?
888
+ `);
889
+
890
+ const result = stmt.run(cutoffTimestamp);
891
+ return result.changes;
892
+ }
893
+
894
+ /**
895
+ * List sessions with optional filters
896
+ */
897
+ listSessions(options: ListSessionsOptions = {}): SessionHandle[] {
898
+ let sql = "SELECT * FROM sessions WHERE 1=1";
899
+ const values: SQLQueryBindings[] = [];
900
+
901
+ if (options.status) {
902
+ sql += " AND status = ?";
903
+ values.push(options.status);
904
+ }
905
+ if (options.provider) {
906
+ sql += " AND provider = ?";
907
+ values.push(options.provider);
908
+ }
909
+
910
+ sql += " ORDER BY created_at DESC";
911
+
912
+ const stmt = this.db.prepare(sql);
913
+ const rows = stmt.all(...values) as any[];
914
+
915
+ return rows.map((row) => this.mapRowToSessionHandle(row));
916
+ }
917
+
918
+ /**
919
+ * Insert a new event
920
+ */
921
+ insertEvent(params: InsertEventParams): number {
922
+ const timestamp = params.timestamp ?? new Date();
923
+ const stmt = this.db.prepare(`
924
+ INSERT INTO events (session_id, ts, source, type, payload_json)
925
+ VALUES (?, ?, ?, ?, ?)
926
+ `);
927
+
928
+ stmt.run(
929
+ params.sessionId,
930
+ timestamp.getTime(),
931
+ params.source,
932
+ params.type,
933
+ JSON.stringify(params.payload)
934
+ );
935
+
936
+ // Get last inserted row id
937
+ const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
938
+ return result.id;
939
+ }
940
+
941
+ /**
942
+ * Get events for a session with optional filters
943
+ */
944
+ getEventsBySessionId(sessionId: string, options: GetEventsOptions = {}): EventRecord[] {
945
+ let sql = "SELECT * FROM events WHERE session_id = ?";
946
+ const values: SQLQueryBindings[] = [sessionId];
947
+
948
+ if (options.since !== undefined) {
949
+ sql += " AND id > ?";
950
+ values.push(options.since);
951
+ }
952
+ if (options.before !== undefined) {
953
+ sql += " AND id < ?";
954
+ values.push(options.before);
955
+ }
956
+ if (options.type) {
957
+ sql += " AND type = ?";
958
+ values.push(options.type);
959
+ }
960
+ if (options.source) {
961
+ sql += " AND source = ?";
962
+ values.push(options.source);
963
+ }
964
+
965
+ const order = options.order === "desc" ? "DESC" : "ASC";
966
+ sql += ` ORDER BY id ${order}`;
967
+
968
+ if (options.limit) {
969
+ sql += " LIMIT ?";
970
+ values.push(options.limit);
971
+ }
972
+
973
+ const stmt = this.db.prepare(sql);
974
+ const rows = stmt.all(...values) as any[];
975
+
976
+ return rows.map((row) => this.mapRowToEventRecord(row));
977
+ }
978
+
979
+ /**
980
+ * Insert a user-facing session notification
981
+ */
982
+ insertSessionNotification(params: InsertSessionNotificationParams): number {
983
+ return this.insertSessionNotificationWithStatus(params).id;
984
+ }
985
+
986
+ /**
987
+ * Insert a user-facing session notification and report whether it was inserted.
988
+ * When sourceEventId is present, treat (sourceEventId, eventType) as idempotent.
989
+ */
990
+ insertSessionNotificationWithStatus(
991
+ params: InsertSessionNotificationParams
992
+ ): InsertSessionNotificationResult {
993
+ const sourceEventId = this.normalizeSourceEventId(params.sourceEventId);
994
+ if (sourceEventId !== null) {
995
+ const existingId = this.findSessionNotificationIdBySourceEvent(
996
+ sourceEventId,
997
+ params.eventType
998
+ );
999
+ if (existingId !== null) {
1000
+ return { id: existingId, inserted: false };
1001
+ }
1002
+ }
1003
+
1004
+ const createdAt = params.createdAt ?? new Date();
1005
+ const stmt = this.db.prepare(`
1006
+ INSERT INTO session_notifications (
1007
+ session_id,
1008
+ provider,
1009
+ event_type,
1010
+ source_event_id,
1011
+ title,
1012
+ body,
1013
+ payload_json,
1014
+ created_at,
1015
+ read_at,
1016
+ read_source
1017
+ )
1018
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1019
+ `);
1020
+
1021
+ stmt.run(
1022
+ params.sessionId,
1023
+ params.provider,
1024
+ params.eventType,
1025
+ sourceEventId,
1026
+ params.title,
1027
+ params.body ?? null,
1028
+ JSON.stringify(params.payload),
1029
+ createdAt.getTime(),
1030
+ params.readAt ? params.readAt.getTime() : null,
1031
+ params.readSource ?? null
1032
+ );
1033
+
1034
+ const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
1035
+ return { id: result.id, inserted: true };
1036
+ }
1037
+
1038
+ /**
1039
+ * List session notifications with optional filters
1040
+ */
1041
+ listSessionNotifications(
1042
+ options: ListSessionNotificationsOptions = {}
1043
+ ): SessionNotificationRecord[] {
1044
+ let sql = "SELECT * FROM session_notifications WHERE 1=1";
1045
+ const values: SQLQueryBindings[] = [];
1046
+
1047
+ if (options.sessionId) {
1048
+ sql += " AND session_id = ?";
1049
+ values.push(options.sessionId);
1050
+ }
1051
+
1052
+ if (options.eventType) {
1053
+ sql += " AND event_type = ?";
1054
+ values.push(options.eventType);
1055
+ }
1056
+
1057
+ if (options.unreadOnly) {
1058
+ sql += " AND read_at IS NULL";
1059
+ }
1060
+
1061
+ if (options.before !== undefined) {
1062
+ sql += " AND id < ?";
1063
+ values.push(options.before);
1064
+ }
1065
+
1066
+ sql += " ORDER BY id DESC";
1067
+
1068
+ if (options.limit !== undefined) {
1069
+ const rawLimit = Number.isFinite(options.limit) ? options.limit : 0;
1070
+ const normalizedLimit = Math.max(0, Math.min(Math.floor(rawLimit), 200));
1071
+ sql += " LIMIT ?";
1072
+ values.push(normalizedLimit);
1073
+ }
1074
+
1075
+ const rows = this.db.prepare(sql).all(...values) as any[];
1076
+ return rows.map((row) => this.mapRowToSessionNotificationRecord(row));
1077
+ }
1078
+
1079
+ /**
1080
+ * Get unread notification counts globally and by session
1081
+ */
1082
+ getSessionNotificationCounts(): SessionNotificationCounts {
1083
+ const totalRow = this.db
1084
+ .prepare(
1085
+ `
1086
+ SELECT COUNT(*) as count
1087
+ FROM session_notifications
1088
+ WHERE read_at IS NULL
1089
+ `
1090
+ )
1091
+ .get() as any;
1092
+
1093
+ const bySessionRows = this.db
1094
+ .prepare(
1095
+ `
1096
+ SELECT session_id, COUNT(*) as count
1097
+ FROM session_notifications
1098
+ WHERE read_at IS NULL
1099
+ GROUP BY session_id
1100
+ `
1101
+ )
1102
+ .all() as any[];
1103
+
1104
+ const bySession: Record<string, number> = {};
1105
+ for (const row of bySessionRows) {
1106
+ bySession[row.session_id] = Number(row.count ?? 0);
1107
+ }
1108
+
1109
+ return {
1110
+ totalUnread: Number(totalRow?.count ?? 0),
1111
+ bySession,
1112
+ };
1113
+ }
1114
+
1115
+ /**
1116
+ * Mark one notification as read (idempotent)
1117
+ */
1118
+ markSessionNotificationRead(
1119
+ notificationId: number,
1120
+ readSource: string = "click",
1121
+ readAt: Date = new Date()
1122
+ ): boolean {
1123
+ const result = this.db
1124
+ .prepare(
1125
+ `
1126
+ UPDATE session_notifications
1127
+ SET read_at = ?, read_source = ?
1128
+ WHERE id = ? AND read_at IS NULL
1129
+ `
1130
+ )
1131
+ .run(readAt.getTime(), readSource, notificationId);
1132
+
1133
+ return result.changes > 0;
1134
+ }
1135
+
1136
+ /**
1137
+ * Mark notifications as read in bulk (idempotent)
1138
+ */
1139
+ markSessionNotificationsRead(options: MarkSessionNotificationsReadOptions = {}): number {
1140
+ let sql = `
1141
+ UPDATE session_notifications
1142
+ SET read_at = ?, read_source = ?
1143
+ WHERE read_at IS NULL
1144
+ `;
1145
+ const values: SQLQueryBindings[] = [
1146
+ (options.readAt ?? new Date()).getTime(),
1147
+ options.readSource ?? "bulk",
1148
+ ];
1149
+
1150
+ if (options.sessionId) {
1151
+ sql += " AND session_id = ?";
1152
+ values.push(options.sessionId);
1153
+ }
1154
+
1155
+ const result = this.db.prepare(sql).run(...values);
1156
+ return result.changes;
1157
+ }
1158
+
1159
+ private normalizeSourceEventId(sourceEventId: unknown): number | null {
1160
+ if (!Number.isInteger(sourceEventId) || (sourceEventId as number) <= 0) {
1161
+ return null;
1162
+ }
1163
+ return sourceEventId as number;
1164
+ }
1165
+
1166
+ private findSessionNotificationIdBySourceEvent(
1167
+ sourceEventId: number,
1168
+ eventType: string
1169
+ ): number | null {
1170
+ const row = this.db
1171
+ .prepare(
1172
+ `
1173
+ SELECT id
1174
+ FROM session_notifications
1175
+ WHERE source_event_id = ? AND event_type = ?
1176
+ ORDER BY id ASC
1177
+ LIMIT 1
1178
+ `
1179
+ )
1180
+ .get(sourceEventId, eventType) as { id?: unknown } | null;
1181
+
1182
+ if (!(row && Number.isInteger(row.id))) {
1183
+ return null;
1184
+ }
1185
+
1186
+ return row.id as number;
1187
+ }
1188
+
1189
+ /**
1190
+ * Get per-session notification preference override
1191
+ */
1192
+ getSessionNotificationPrefs(sessionId: string): SessionNotificationPrefsRecord {
1193
+ const row = this.db
1194
+ .prepare(
1195
+ `
1196
+ SELECT session_id, enabled, updated_at
1197
+ FROM session_notification_prefs
1198
+ WHERE session_id = ?
1199
+ `
1200
+ )
1201
+ .get(sessionId) as any;
1202
+
1203
+ if (!row) {
1204
+ return {
1205
+ sessionId,
1206
+ enabled: null,
1207
+ updatedAt: new Date(0),
1208
+ };
1209
+ }
1210
+
1211
+ return {
1212
+ sessionId: row.session_id,
1213
+ enabled: row.enabled === null ? null : row.enabled !== 0,
1214
+ updatedAt: new Date(row.updated_at),
1215
+ };
1216
+ }
1217
+
1218
+ /**
1219
+ * Upsert per-session notification preference override
1220
+ */
1221
+ setSessionNotificationPrefs(
1222
+ sessionId: string,
1223
+ enabled: boolean | null
1224
+ ): SessionNotificationPrefsRecord {
1225
+ const now = Date.now();
1226
+ this.db
1227
+ .prepare(
1228
+ `
1229
+ INSERT INTO session_notification_prefs (session_id, enabled, updated_at)
1230
+ VALUES (?, ?, ?)
1231
+ ON CONFLICT(session_id) DO UPDATE SET
1232
+ enabled = excluded.enabled,
1233
+ updated_at = excluded.updated_at
1234
+ `
1235
+ )
1236
+ .run(sessionId, enabled === null ? null : enabled ? 1 : 0, now);
1237
+
1238
+ return {
1239
+ sessionId,
1240
+ enabled,
1241
+ updatedAt: new Date(now),
1242
+ };
1243
+ }
1244
+
1245
+ listPushSubscriptions(): PushSubscriptionRecord[] {
1246
+ const rows = this.db
1247
+ .prepare(
1248
+ `
1249
+ SELECT endpoint, p256dh, auth, expiration_time, created_at, updated_at
1250
+ FROM push_subscriptions
1251
+ ORDER BY updated_at DESC
1252
+ `
1253
+ )
1254
+ .all() as any[];
1255
+
1256
+ return rows.map((row) => this.mapRowToPushSubscriptionRecord(row));
1257
+ }
1258
+
1259
+ upsertPushSubscription(params: UpsertPushSubscriptionParams): PushSubscriptionRecord {
1260
+ const now = Date.now();
1261
+ this.db
1262
+ .prepare(
1263
+ `
1264
+ INSERT INTO push_subscriptions (
1265
+ endpoint,
1266
+ p256dh,
1267
+ auth,
1268
+ expiration_time,
1269
+ created_at,
1270
+ updated_at
1271
+ ) VALUES (?, ?, ?, ?, ?, ?)
1272
+ ON CONFLICT(endpoint) DO UPDATE SET
1273
+ p256dh = excluded.p256dh,
1274
+ auth = excluded.auth,
1275
+ expiration_time = excluded.expiration_time,
1276
+ updated_at = excluded.updated_at
1277
+ `
1278
+ )
1279
+ .run(
1280
+ params.endpoint,
1281
+ params.keys.p256dh,
1282
+ params.keys.auth,
1283
+ params.expirationTime ?? null,
1284
+ now,
1285
+ now
1286
+ );
1287
+
1288
+ const row = this.db
1289
+ .prepare(
1290
+ `
1291
+ SELECT endpoint, p256dh, auth, expiration_time, created_at, updated_at
1292
+ FROM push_subscriptions
1293
+ WHERE endpoint = ?
1294
+ `
1295
+ )
1296
+ .get(params.endpoint) as any;
1297
+
1298
+ return this.mapRowToPushSubscriptionRecord(row);
1299
+ }
1300
+
1301
+ deletePushSubscription(endpoint: string): boolean {
1302
+ const result = this.db
1303
+ .prepare("DELETE FROM push_subscriptions WHERE endpoint = ?")
1304
+ .run(endpoint);
1305
+ return result.changes > 0;
1306
+ }
1307
+
1308
+ /**
1309
+ * Get transcript offset for a session and path
1310
+ */
1311
+ getTranscriptOffset(sessionId: string, path: string): TranscriptOffset {
1312
+ const stmt = this.db.prepare(`
1313
+ SELECT byte_offset, last_line_hash
1314
+ FROM transcript_offsets
1315
+ WHERE session_id = ? AND path = ?
1316
+ `);
1317
+
1318
+ const row = stmt.get(sessionId, path) as any;
1319
+
1320
+ if (!row) {
1321
+ return { byteOffset: 0, lastLineHash: null };
1322
+ }
1323
+
1324
+ return {
1325
+ byteOffset: row.byte_offset,
1326
+ lastLineHash: row.last_line_hash,
1327
+ };
1328
+ }
1329
+
1330
+ /**
1331
+ * Update transcript offset for a session and path
1332
+ */
1333
+ updateTranscriptOffset(sessionId: string, path: string, offset: TranscriptOffset): void {
1334
+ const stmt = this.db.prepare(`
1335
+ INSERT INTO transcript_offsets (session_id, path, byte_offset, last_line_hash)
1336
+ VALUES (?, ?, ?, ?)
1337
+ ON CONFLICT(session_id, path) DO UPDATE SET
1338
+ byte_offset = excluded.byte_offset,
1339
+ last_line_hash = excluded.last_line_hash
1340
+ `);
1341
+
1342
+ stmt.run(sessionId, path, offset.byteOffset, offset.lastLineHash);
1343
+ }
1344
+
1345
+ /**
1346
+ * Map database row to SessionHandle
1347
+ */
1348
+ private mapRowToSessionHandle(row: any): SessionHandle {
1349
+ return {
1350
+ id: row.id,
1351
+ provider: row.provider as ProviderId,
1352
+ cwd: row.cwd,
1353
+ status: row.status as SessionStatus,
1354
+ createdAt: new Date(row.created_at),
1355
+ updatedAt: new Date(row.updated_at),
1356
+ pid: row.pid ?? undefined,
1357
+ ptyRows: row.pty_rows ?? undefined,
1358
+ ptyCols: row.pty_cols ?? undefined,
1359
+ transcriptPath: row.transcript_path ?? undefined,
1360
+ metadata: row.metadata_json ? JSON.parse(row.metadata_json) : undefined,
1361
+ };
1362
+ }
1363
+
1364
+ /**
1365
+ * Map database row to EventRecord
1366
+ */
1367
+ private mapRowToEventRecord(row: any): EventRecord {
1368
+ return {
1369
+ id: row.id,
1370
+ sessionId: row.session_id,
1371
+ timestamp: new Date(row.ts),
1372
+ source: row.source as EventSource,
1373
+ type: row.type,
1374
+ payload: JSON.parse(row.payload_json),
1375
+ };
1376
+ }
1377
+
1378
+ /**
1379
+ * Map database row to SessionNotificationRecord
1380
+ */
1381
+ private mapRowToSessionNotificationRecord(row: any): SessionNotificationRecord {
1382
+ return {
1383
+ id: row.id,
1384
+ sessionId: row.session_id,
1385
+ provider: row.provider,
1386
+ eventType: row.event_type,
1387
+ sourceEventId: row.source_event_id ?? null,
1388
+ title: row.title,
1389
+ body: row.body ?? null,
1390
+ payload: JSON.parse(row.payload_json),
1391
+ createdAt: new Date(row.created_at),
1392
+ readAt: row.read_at == null ? null : new Date(row.read_at),
1393
+ readSource: row.read_source ?? null,
1394
+ };
1395
+ }
1396
+
1397
+ private mapRowToPushSubscriptionRecord(row: any): PushSubscriptionRecord {
1398
+ return {
1399
+ endpoint: row.endpoint,
1400
+ keys: {
1401
+ p256dh: row.p256dh,
1402
+ auth: row.auth,
1403
+ },
1404
+ expirationTime:
1405
+ row.expiration_time === null || row.expiration_time === undefined
1406
+ ? null
1407
+ : Number(row.expiration_time),
1408
+ createdAt: new Date(row.created_at),
1409
+ updatedAt: new Date(row.updated_at),
1410
+ };
1411
+ }
1412
+
1413
+ /**
1414
+ * Create a new policy
1415
+ */
1416
+ createPolicy(params: CreatePolicyParams): void {
1417
+ const now = Date.now();
1418
+ const stmt = this.db.prepare(`
1419
+ INSERT INTO policies (
1420
+ id, name, description, enabled, priority, session_id, rules_json, created_at, updated_at
1421
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1422
+ `);
1423
+
1424
+ stmt.run(
1425
+ params.id,
1426
+ params.name,
1427
+ params.description ?? null,
1428
+ (params.enabled ?? true) ? 1 : 0,
1429
+ params.priority ?? 0,
1430
+ params.sessionId ?? null,
1431
+ JSON.stringify(params.rules),
1432
+ now,
1433
+ now
1434
+ );
1435
+ }
1436
+
1437
+ /**
1438
+ * Get policy by ID
1439
+ */
1440
+ getPolicy(id: string): PolicyRecord | undefined {
1441
+ const stmt = this.db.prepare("SELECT * FROM policies WHERE id = ?");
1442
+ const row = stmt.get(id) as any;
1443
+ if (!row) return undefined;
1444
+ return this.mapRowToPolicyRecord(row);
1445
+ }
1446
+
1447
+ /**
1448
+ * Update policy fields
1449
+ */
1450
+ updatePolicy(id: string, params: UpdatePolicyParams): void {
1451
+ const updates: string[] = [];
1452
+ const values: SQLQueryBindings[] = [];
1453
+
1454
+ if (params.name !== undefined) {
1455
+ updates.push("name = ?");
1456
+ values.push(params.name);
1457
+ }
1458
+ if (params.description !== undefined) {
1459
+ updates.push("description = ?");
1460
+ values.push(params.description);
1461
+ }
1462
+ if (params.enabled !== undefined) {
1463
+ updates.push("enabled = ?");
1464
+ values.push(params.enabled ? 1 : 0);
1465
+ }
1466
+ if (params.priority !== undefined) {
1467
+ updates.push("priority = ?");
1468
+ values.push(params.priority);
1469
+ }
1470
+ if (params.rules !== undefined) {
1471
+ updates.push("rules_json = ?");
1472
+ values.push(JSON.stringify(params.rules));
1473
+ }
1474
+
1475
+ // Always update updated_at
1476
+ updates.push("updated_at = ?");
1477
+ values.push(Date.now());
1478
+
1479
+ if (updates.length === 1) {
1480
+ // Only updated_at changed, still execute
1481
+ }
1482
+
1483
+ values.push(id);
1484
+
1485
+ const sql = `UPDATE policies SET ${updates.join(", ")} WHERE id = ?`;
1486
+ const stmt = this.db.prepare(sql);
1487
+ stmt.run(...values);
1488
+ }
1489
+
1490
+ /**
1491
+ * Delete policy
1492
+ */
1493
+ deletePolicy(id: string): void {
1494
+ const stmt = this.db.prepare("DELETE FROM policies WHERE id = ?");
1495
+ stmt.run(id);
1496
+ }
1497
+
1498
+ /**
1499
+ * List policies with optional filters
1500
+ */
1501
+ listPolicies(options: ListPoliciesOptions = {}): PolicyRecord[] {
1502
+ let sql = "SELECT * FROM policies WHERE 1=1";
1503
+ const values: SQLQueryBindings[] = [];
1504
+
1505
+ if (options.sessionId !== undefined) {
1506
+ if (options.sessionId === null) {
1507
+ sql += " AND session_id IS NULL";
1508
+ } else {
1509
+ sql += " AND session_id = ?";
1510
+ values.push(options.sessionId);
1511
+ }
1512
+ }
1513
+ if (options.enabled !== undefined) {
1514
+ sql += " AND enabled = ?";
1515
+ values.push(options.enabled ? 1 : 0);
1516
+ }
1517
+
1518
+ sql += " ORDER BY priority DESC, created_at DESC";
1519
+
1520
+ const stmt = this.db.prepare(sql);
1521
+ const rows = stmt.all(...values) as any[];
1522
+
1523
+ return rows.map((row) => this.mapRowToPolicyRecord(row));
1524
+ }
1525
+
1526
+ /**
1527
+ * Insert a policy decision (audit log)
1528
+ */
1529
+ insertPolicyDecision(params: InsertPolicyDecisionParams): number {
1530
+ const timestamp = params.timestamp ?? new Date();
1531
+ const stmt = this.db.prepare(`
1532
+ INSERT INTO policy_decisions (
1533
+ session_id, event_id, policy_id, tool_name, args_json, decision, reason, timestamp
1534
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1535
+ `);
1536
+
1537
+ stmt.run(
1538
+ params.sessionId,
1539
+ params.eventId ?? null,
1540
+ params.policyId ?? null,
1541
+ params.toolName,
1542
+ params.args ? JSON.stringify(params.args) : null,
1543
+ params.decision,
1544
+ params.reason ?? null,
1545
+ timestamp.getTime()
1546
+ );
1547
+
1548
+ // Get last inserted row id
1549
+ const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
1550
+ return result.id;
1551
+ }
1552
+
1553
+ /**
1554
+ * Get policy decisions for a session
1555
+ */
1556
+ getPolicyDecisionsBySessionId(
1557
+ sessionId: string,
1558
+ options: GetPolicyDecisionsOptions = {}
1559
+ ): PolicyDecisionRecord[] {
1560
+ let sql = "SELECT * FROM policy_decisions WHERE session_id = ?";
1561
+ const values: SQLQueryBindings[] = [sessionId];
1562
+
1563
+ if (options.since !== undefined) {
1564
+ sql += " AND id > ?";
1565
+ values.push(options.since);
1566
+ }
1567
+ if (options.decision) {
1568
+ sql += " AND decision = ?";
1569
+ values.push(options.decision);
1570
+ }
1571
+
1572
+ sql += " ORDER BY id ASC";
1573
+
1574
+ if (options.limit) {
1575
+ sql += " LIMIT ?";
1576
+ values.push(options.limit);
1577
+ }
1578
+
1579
+ const stmt = this.db.prepare(sql);
1580
+ const rows = stmt.all(...values) as any[];
1581
+
1582
+ return rows.map((row) => this.mapRowToPolicyDecisionRecord(row));
1583
+ }
1584
+
1585
+ /**
1586
+ * Map database row to PolicyRecord
1587
+ */
1588
+ private mapRowToPolicyRecord(row: any): PolicyRecord {
1589
+ return {
1590
+ id: row.id,
1591
+ name: row.name,
1592
+ description: row.description ?? undefined,
1593
+ enabled: row.enabled === 1,
1594
+ priority: row.priority,
1595
+ sessionId: row.session_id ?? undefined,
1596
+ rules: JSON.parse(row.rules_json),
1597
+ createdAt: new Date(row.created_at),
1598
+ updatedAt: new Date(row.updated_at),
1599
+ };
1600
+ }
1601
+
1602
+ /**
1603
+ * Map database row to PolicyDecisionRecord
1604
+ */
1605
+ private mapRowToPolicyDecisionRecord(row: any): PolicyDecisionRecord {
1606
+ return {
1607
+ id: row.id,
1608
+ sessionId: row.session_id,
1609
+ eventId: row.event_id ?? undefined,
1610
+ policyId: row.policy_id ?? undefined,
1611
+ toolName: row.tool_name,
1612
+ args: row.args_json ? JSON.parse(row.args_json) : undefined,
1613
+ decision: row.decision,
1614
+ reason: row.reason ?? undefined,
1615
+ timestamp: new Date(row.timestamp),
1616
+ };
1617
+ }
1618
+
1619
+ // ─── Policy Set Operations ─────────────────────────────────────────
1620
+
1621
+ createPolicySet(params: CreatePolicySetParams): void {
1622
+ const now = Date.now();
1623
+
1624
+ // If setting as default, clear any existing default first
1625
+ if (params.isDefault) {
1626
+ this.db.prepare("UPDATE policy_sets SET is_default = 0 WHERE is_default = 1").run();
1627
+ }
1628
+
1629
+ this.db
1630
+ .prepare(
1631
+ `INSERT INTO policy_sets (id, name, description, is_default, created_at, updated_at)
1632
+ VALUES (?, ?, ?, ?, ?, ?)`
1633
+ )
1634
+ .run(params.id, params.name, params.description ?? null, params.isDefault ? 1 : 0, now, now);
1635
+
1636
+ // Add initial member policies
1637
+ if (params.policyIds?.length) {
1638
+ const addStmt = this.db.prepare(
1639
+ `INSERT OR IGNORE INTO policy_set_members (policy_set_id, policy_id, added_at)
1640
+ VALUES (?, ?, ?)`
1641
+ );
1642
+ for (const policyId of params.policyIds) {
1643
+ addStmt.run(params.id, policyId, now);
1644
+ }
1645
+ }
1646
+ }
1647
+
1648
+ getPolicySet(id: string): PolicySetRecord | undefined {
1649
+ const row = this.db.prepare("SELECT * FROM policy_sets WHERE id = ?").get(id) as any;
1650
+ if (!row) return undefined;
1651
+ return this.mapRowToPolicySetRecord(row);
1652
+ }
1653
+
1654
+ updatePolicySet(id: string, params: UpdatePolicySetParams): void {
1655
+ const updates: string[] = [];
1656
+ const values: SQLQueryBindings[] = [];
1657
+
1658
+ if (params.name !== undefined) {
1659
+ updates.push("name = ?");
1660
+ values.push(params.name);
1661
+ }
1662
+ if (params.description !== undefined) {
1663
+ updates.push("description = ?");
1664
+ values.push(params.description);
1665
+ }
1666
+ if (params.isDefault !== undefined) {
1667
+ // Clear existing default if setting this one
1668
+ if (params.isDefault) {
1669
+ this.db.prepare("UPDATE policy_sets SET is_default = 0 WHERE is_default = 1").run();
1670
+ }
1671
+ updates.push("is_default = ?");
1672
+ values.push(params.isDefault ? 1 : 0);
1673
+ }
1674
+
1675
+ updates.push("updated_at = ?");
1676
+ values.push(Date.now());
1677
+ values.push(id);
1678
+
1679
+ this.db.prepare(`UPDATE policy_sets SET ${updates.join(", ")} WHERE id = ?`).run(...values);
1680
+ }
1681
+
1682
+ deletePolicySet(id: string): void {
1683
+ this.db.prepare("DELETE FROM policy_sets WHERE id = ?").run(id);
1684
+ }
1685
+
1686
+ listPolicySets(): PolicySetSummary[] {
1687
+ const rows = this.db
1688
+ .prepare(
1689
+ `SELECT ps.*,
1690
+ COUNT(DISTINCT psm.policy_id) as policy_count,
1691
+ COUNT(DISTINCT sps.session_id) as session_count
1692
+ FROM policy_sets ps
1693
+ LEFT JOIN policy_set_members psm ON ps.id = psm.policy_set_id
1694
+ LEFT JOIN session_policy_sets sps ON ps.id = sps.policy_set_id
1695
+ GROUP BY ps.id
1696
+ ORDER BY ps.name`
1697
+ )
1698
+ .all() as any[];
1699
+
1700
+ return rows.map((row) => ({
1701
+ id: row.id,
1702
+ name: row.name,
1703
+ description: row.description ?? undefined,
1704
+ isDefault: row.is_default === 1,
1705
+ policyCount: row.policy_count,
1706
+ sessionCount: row.session_count,
1707
+ createdAt: new Date(row.created_at),
1708
+ updatedAt: new Date(row.updated_at),
1709
+ }));
1710
+ }
1711
+
1712
+ addPolicyToSet(setId: string, policyId: string): void {
1713
+ this.db
1714
+ .prepare(
1715
+ `INSERT OR IGNORE INTO policy_set_members (policy_set_id, policy_id, added_at)
1716
+ VALUES (?, ?, ?)`
1717
+ )
1718
+ .run(setId, policyId, Date.now());
1719
+ }
1720
+
1721
+ removePolicyFromSet(setId: string, policyId: string): void {
1722
+ this.db
1723
+ .prepare("DELETE FROM policy_set_members WHERE policy_set_id = ? AND policy_id = ?")
1724
+ .run(setId, policyId);
1725
+ }
1726
+
1727
+ getPolicySetMembers(setId: string): PolicyRecord[] {
1728
+ const rows = this.db
1729
+ .prepare(
1730
+ `SELECT p.* FROM policies p
1731
+ JOIN policy_set_members psm ON p.id = psm.policy_id
1732
+ WHERE psm.policy_set_id = ?
1733
+ ORDER BY p.priority DESC, p.name`
1734
+ )
1735
+ .all(setId) as any[];
1736
+
1737
+ return rows.map((row) => this.mapRowToPolicyRecord(row));
1738
+ }
1739
+
1740
+ applyPolicySetToSession(sessionId: string, setId: string): void {
1741
+ this.db
1742
+ .prepare(
1743
+ `INSERT OR IGNORE INTO session_policy_sets (session_id, policy_set_id, applied_at)
1744
+ VALUES (?, ?, ?)`
1745
+ )
1746
+ .run(sessionId, setId, Date.now());
1747
+ }
1748
+
1749
+ removePolicySetFromSession(sessionId: string, setId: string): void {
1750
+ this.db
1751
+ .prepare("DELETE FROM session_policy_sets WHERE session_id = ? AND policy_set_id = ?")
1752
+ .run(sessionId, setId);
1753
+ }
1754
+
1755
+ getSessionPolicySets(sessionId: string): PolicySetSummary[] {
1756
+ const rows = this.db
1757
+ .prepare(
1758
+ `SELECT ps.*,
1759
+ COUNT(DISTINCT psm.policy_id) as policy_count,
1760
+ (SELECT COUNT(*) FROM session_policy_sets WHERE policy_set_id = ps.id) as session_count
1761
+ FROM policy_sets ps
1762
+ JOIN session_policy_sets sps ON ps.id = sps.policy_set_id
1763
+ LEFT JOIN policy_set_members psm ON ps.id = psm.policy_set_id
1764
+ WHERE sps.session_id = ?
1765
+ GROUP BY ps.id
1766
+ ORDER BY ps.name`
1767
+ )
1768
+ .all(sessionId) as any[];
1769
+
1770
+ return rows.map((row) => ({
1771
+ id: row.id,
1772
+ name: row.name,
1773
+ description: row.description ?? undefined,
1774
+ isDefault: row.is_default === 1,
1775
+ policyCount: row.policy_count,
1776
+ sessionCount: row.session_count,
1777
+ createdAt: new Date(row.created_at),
1778
+ updatedAt: new Date(row.updated_at),
1779
+ }));
1780
+ }
1781
+
1782
+ getEffectivePolicies(sessionId: string): PolicyRecord[] {
1783
+ // 1. Direct per-session policies
1784
+ const directPolicies = this.listPolicies({ sessionId, enabled: true });
1785
+
1786
+ // 2. Policies from applied policy sets
1787
+ const setPolicyRows = this.db
1788
+ .prepare(
1789
+ `SELECT DISTINCT p.* FROM policies p
1790
+ JOIN policy_set_members psm ON p.id = psm.policy_id
1791
+ JOIN session_policy_sets sps ON psm.policy_set_id = sps.policy_set_id
1792
+ WHERE sps.session_id = ? AND p.enabled = 1`
1793
+ )
1794
+ .all(sessionId) as any[];
1795
+ const setPolicies = setPolicyRows.map((row) => this.mapRowToPolicyRecord(row));
1796
+
1797
+ // 3. Global policies (session_id IS NULL)
1798
+ const globalPolicies = this.listPolicies({ sessionId: null as any, enabled: true });
1799
+
1800
+ // Deduplicate: direct > set > global (order determines precedence for same ID)
1801
+ const seen = new Set<string>();
1802
+ const all: PolicyRecord[] = [];
1803
+ for (const policy of [...directPolicies, ...setPolicies, ...globalPolicies]) {
1804
+ if (!seen.has(policy.id)) {
1805
+ seen.add(policy.id);
1806
+ all.push(policy);
1807
+ }
1808
+ }
1809
+
1810
+ // Sort by priority DESC (PolicyEngine also sorts, but this keeps the output consistent)
1811
+ all.sort((a, b) => b.priority - a.priority);
1812
+ return all;
1813
+ }
1814
+
1815
+ getDefaultPolicySet(): PolicySetRecord | undefined {
1816
+ const row = this.db.prepare("SELECT * FROM policy_sets WHERE is_default = 1").get() as any;
1817
+ if (!row) return undefined;
1818
+ return this.mapRowToPolicySetRecord(row);
1819
+ }
1820
+
1821
+ private mapRowToPolicySetRecord(row: any): PolicySetRecord {
1822
+ return {
1823
+ id: row.id,
1824
+ name: row.name,
1825
+ description: row.description ?? undefined,
1826
+ isDefault: row.is_default === 1,
1827
+ createdAt: new Date(row.created_at),
1828
+ updatedAt: new Date(row.updated_at),
1829
+ };
1830
+ }
1831
+
1832
+ /**
1833
+ * Insert token usage record
1834
+ */
1835
+ insertTokenUsage(params: InsertTokenUsageParams): number {
1836
+ const timestamp = params.timestamp ?? new Date();
1837
+ const stmt = this.db.prepare(`
1838
+ INSERT INTO token_usage (
1839
+ session_id, event_id, timestamp, model,
1840
+ prompt_tokens, completion_tokens,
1841
+ cache_creation_input_tokens, cache_read_input_tokens,
1842
+ total_tokens, estimated_cost_usd, actual_cost_usd, cost_difference_usd
1843
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1844
+ `);
1845
+
1846
+ stmt.run(
1847
+ params.sessionId,
1848
+ params.eventId ?? null,
1849
+ timestamp.getTime(),
1850
+ params.model,
1851
+ params.promptTokens,
1852
+ params.completionTokens,
1853
+ params.cacheCreationInputTokens ?? 0,
1854
+ params.cacheReadInputTokens ?? 0,
1855
+ params.totalTokens,
1856
+ params.estimatedCostUsd ?? null,
1857
+ params.actualCostUsd ?? null,
1858
+ params.costDifferenceUsd ?? null
1859
+ );
1860
+
1861
+ const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
1862
+ return result.id;
1863
+ }
1864
+
1865
+ /**
1866
+ * Get token usage records for a session
1867
+ */
1868
+ getTokenUsageBySessionId(
1869
+ sessionId: string,
1870
+ options: GetTokenUsageOptions = {}
1871
+ ): TokenUsageRecord[] {
1872
+ let sql = "SELECT * FROM token_usage WHERE session_id = ?";
1873
+ const values: SQLQueryBindings[] = [sessionId];
1874
+
1875
+ if (options.since) {
1876
+ sql += " AND timestamp >= ?";
1877
+ values.push(options.since.getTime());
1878
+ }
1879
+ if (options.until) {
1880
+ sql += " AND timestamp <= ?";
1881
+ values.push(options.until.getTime());
1882
+ }
1883
+ if (options.model) {
1884
+ sql += " AND model = ?";
1885
+ values.push(options.model);
1886
+ }
1887
+
1888
+ sql += " ORDER BY timestamp ASC";
1889
+
1890
+ if (options.limit) {
1891
+ sql += " LIMIT ?";
1892
+ values.push(options.limit);
1893
+ }
1894
+
1895
+ const stmt = this.db.prepare(sql);
1896
+ const rows = stmt.all(...values) as any[];
1897
+
1898
+ return rows.map((row) => this.mapRowToTokenUsageRecord(row));
1899
+ }
1900
+
1901
+ /**
1902
+ * Get total tokens and cost by session
1903
+ */
1904
+ getTotalTokensBySessionId(sessionId: string): {
1905
+ totalTokens: number;
1906
+ totalCost: number;
1907
+ byModel: Record<string, { tokens: number; cost: number }>;
1908
+ } {
1909
+ const stmt = this.db.prepare(`
1910
+ SELECT
1911
+ model,
1912
+ SUM(total_tokens) as total_tokens,
1913
+ SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)) as total_cost
1914
+ FROM token_usage
1915
+ WHERE session_id = ?
1916
+ GROUP BY model
1917
+ `);
1918
+
1919
+ const rows = stmt.all(sessionId) as any[];
1920
+
1921
+ const byModel: Record<string, { tokens: number; cost: number }> = {};
1922
+ let totalTokens = 0;
1923
+ let totalCost = 0;
1924
+
1925
+ for (const row of rows) {
1926
+ const tokens = row.total_tokens || 0;
1927
+ const cost = row.total_cost || 0;
1928
+
1929
+ byModel[row.model] = { tokens, cost };
1930
+ totalTokens += tokens;
1931
+ totalCost += cost;
1932
+ }
1933
+
1934
+ return { totalTokens, totalCost, byModel };
1935
+ }
1936
+
1937
+ /**
1938
+ * Insert model switch record
1939
+ */
1940
+ insertModelSwitch(params: InsertModelSwitchParams): number {
1941
+ const timestamp = params.timestamp ?? new Date();
1942
+ const stmt = this.db.prepare(`
1943
+ INSERT INTO model_switches (
1944
+ session_id, timestamp, from_model, to_model, reason
1945
+ ) VALUES (?, ?, ?, ?, ?)
1946
+ `);
1947
+
1948
+ stmt.run(
1949
+ params.sessionId,
1950
+ timestamp.getTime(),
1951
+ params.fromModel ?? null,
1952
+ params.toModel,
1953
+ params.reason ?? null
1954
+ );
1955
+
1956
+ const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
1957
+ return result.id;
1958
+ }
1959
+
1960
+ /**
1961
+ * Get model switches for a session
1962
+ */
1963
+ getModelSwitchesBySessionId(sessionId: string): ModelSwitchRecord[] {
1964
+ const stmt = this.db.prepare(`
1965
+ SELECT * FROM model_switches
1966
+ WHERE session_id = ?
1967
+ ORDER BY timestamp ASC
1968
+ `);
1969
+
1970
+ const rows = stmt.all(sessionId) as any[];
1971
+ return rows.map((row) => this.mapRowToModelSwitchRecord(row));
1972
+ }
1973
+
1974
+ /**
1975
+ * Insert transcript content record
1976
+ */
1977
+ insertTranscriptContent(params: InsertTranscriptContentParams): number {
1978
+ const timestamp = params.timestamp ?? new Date();
1979
+ const stmt = this.db.prepare(`
1980
+ INSERT INTO transcript_content (
1981
+ session_id, event_id, role, content, timestamp
1982
+ ) VALUES (?, ?, ?, ?, ?)
1983
+ `);
1984
+
1985
+ stmt.run(
1986
+ params.sessionId,
1987
+ params.eventId ?? null,
1988
+ params.role,
1989
+ params.content,
1990
+ timestamp.getTime()
1991
+ );
1992
+
1993
+ const result = this.db.query("SELECT last_insert_rowid() as id").get() as { id: number };
1994
+ return result.id;
1995
+ }
1996
+
1997
+ /**
1998
+ * Get transcript content for a session
1999
+ */
2000
+ getTranscriptContentBySessionId(
2001
+ sessionId: string,
2002
+ options: GetTranscriptContentOptions = {}
2003
+ ): TranscriptContentRecord[] {
2004
+ let sql = "SELECT * FROM transcript_content WHERE session_id = ?";
2005
+ const values: SQLQueryBindings[] = [sessionId];
2006
+
2007
+ if (options.since) {
2008
+ sql += " AND timestamp >= ?";
2009
+ values.push(options.since.getTime());
2010
+ }
2011
+ if (options.until) {
2012
+ sql += " AND timestamp <= ?";
2013
+ values.push(options.until.getTime());
2014
+ }
2015
+ if (options.role) {
2016
+ sql += " AND role = ?";
2017
+ values.push(options.role);
2018
+ }
2019
+
2020
+ sql += " ORDER BY timestamp ASC";
2021
+
2022
+ if (options.limit) {
2023
+ sql += " LIMIT ?";
2024
+ values.push(options.limit);
2025
+ }
2026
+
2027
+ const stmt = this.db.prepare(sql);
2028
+ const rows = stmt.all(...values) as any[];
2029
+
2030
+ return rows.map((row) => this.mapRowToTranscriptContentRecord(row));
2031
+ }
2032
+
2033
+ /**
2034
+ * Map database row to TokenUsageRecord
2035
+ */
2036
+ private mapRowToTokenUsageRecord(row: any): TokenUsageRecord {
2037
+ return {
2038
+ id: row.id,
2039
+ sessionId: row.session_id,
2040
+ eventId: row.event_id ?? undefined,
2041
+ timestamp: new Date(row.timestamp),
2042
+ model: row.model,
2043
+ promptTokens: row.prompt_tokens,
2044
+ completionTokens: row.completion_tokens,
2045
+ cacheCreationInputTokens: row.cache_creation_input_tokens,
2046
+ cacheReadInputTokens: row.cache_read_input_tokens,
2047
+ totalTokens: row.total_tokens,
2048
+ estimatedCostUsd: row.estimated_cost_usd ?? undefined,
2049
+ actualCostUsd: row.actual_cost_usd ?? undefined,
2050
+ costDifferenceUsd: row.cost_difference_usd ?? undefined,
2051
+ };
2052
+ }
2053
+
2054
+ /**
2055
+ * Map database row to ModelSwitchRecord
2056
+ */
2057
+ private mapRowToModelSwitchRecord(row: any): ModelSwitchRecord {
2058
+ return {
2059
+ id: row.id,
2060
+ sessionId: row.session_id,
2061
+ timestamp: new Date(row.timestamp),
2062
+ fromModel: row.from_model ?? undefined,
2063
+ toModel: row.to_model,
2064
+ reason: row.reason ?? undefined,
2065
+ };
2066
+ }
2067
+
2068
+ /**
2069
+ * Map database row to TranscriptContentRecord
2070
+ */
2071
+ private mapRowToTranscriptContentRecord(row: any): TranscriptContentRecord {
2072
+ return {
2073
+ id: row.id,
2074
+ sessionId: row.session_id,
2075
+ eventId: row.event_id ?? undefined,
2076
+ role: row.role as "user" | "assistant" | "system",
2077
+ content: row.content,
2078
+ timestamp: new Date(row.timestamp),
2079
+ };
2080
+ }
2081
+
2082
+ // ─── Workspace Operations ──────────────────────────────────────────
2083
+
2084
+ createWorkspace(params: CreateWorkspaceParams): void {
2085
+ const now = Date.now();
2086
+ this.db
2087
+ .prepare(
2088
+ `INSERT INTO workspaces (id, name, path, created_at, updated_at)
2089
+ VALUES (?, ?, ?, ?, ?)`
2090
+ )
2091
+ .run(params.id, params.name, params.path, now, now);
2092
+ }
2093
+
2094
+ getWorkspace(id: string): WorkspaceRecord | undefined {
2095
+ const row = this.db.prepare("SELECT * FROM workspaces WHERE id = ?").get(id) as any;
2096
+ if (!row) return undefined;
2097
+ return this.mapRowToWorkspaceRecord(row);
2098
+ }
2099
+
2100
+ updateWorkspace(id: string, params: UpdateWorkspaceParams): void {
2101
+ const updates: string[] = [];
2102
+ const values: SQLQueryBindings[] = [];
2103
+
2104
+ if (params.name !== undefined) {
2105
+ updates.push("name = ?");
2106
+ values.push(params.name);
2107
+ }
2108
+ if (params.path !== undefined) {
2109
+ updates.push("path = ?");
2110
+ values.push(params.path);
2111
+ }
2112
+
2113
+ updates.push("updated_at = ?");
2114
+ values.push(Date.now());
2115
+ values.push(id);
2116
+
2117
+ this.db.prepare(`UPDATE workspaces SET ${updates.join(", ")} WHERE id = ?`).run(...values);
2118
+ }
2119
+
2120
+ deleteWorkspace(id: string): void {
2121
+ this.db.prepare("DELETE FROM workspaces WHERE id = ?").run(id);
2122
+ }
2123
+
2124
+ listWorkspaces(): WorkspaceRecord[] {
2125
+ const rows = this.db.prepare("SELECT * FROM workspaces ORDER BY name ASC").all() as any[];
2126
+ return rows.map((row) => this.mapRowToWorkspaceRecord(row));
2127
+ }
2128
+
2129
+ private mapRowToWorkspaceRecord(row: any): WorkspaceRecord {
2130
+ return {
2131
+ id: row.id,
2132
+ name: row.name,
2133
+ path: row.path,
2134
+ createdAt: new Date(row.created_at),
2135
+ updatedAt: new Date(row.updated_at),
2136
+ };
2137
+ }
2138
+
2139
+ // ─── Env Set Operations ────────────────────────────────────────────
2140
+
2141
+ private encryptionKey: Buffer | null = null;
2142
+
2143
+ private getEncryptionKey(): Buffer {
2144
+ if (this.encryptionKey) return this.encryptionKey;
2145
+ // Lazy-load encryption module
2146
+ const { getOrCreateEncryptionKey } = require("../crypto/encryption");
2147
+ this.encryptionKey = getOrCreateEncryptionKey();
2148
+ // biome-ignore lint/style/noNonNullAssertion: assigned on the line above
2149
+ return this.encryptionKey!;
2150
+ }
2151
+
2152
+ createEnvSet(params: CreateEnvSetParams): void {
2153
+ const now = Date.now();
2154
+ const { encryptVars } = require("../crypto/encryption");
2155
+ const key = this.getEncryptionKey();
2156
+ const encryptedJson = encryptVars(params.vars, key);
2157
+
2158
+ this.db
2159
+ .prepare(
2160
+ `INSERT INTO env_sets (id, name, description, encrypted_vars_json, created_at, updated_at)
2161
+ VALUES (?, ?, ?, ?, ?, ?)`
2162
+ )
2163
+ .run(params.id, params.name, params.description ?? null, encryptedJson, now, now);
2164
+ }
2165
+
2166
+ getEnvSet(id: string): EnvSetRecord | undefined {
2167
+ const row = this.db.prepare("SELECT * FROM env_sets WHERE id = ?").get(id) as any;
2168
+ if (!row) return undefined;
2169
+ return this.mapRowToEnvSetRecord(row);
2170
+ }
2171
+
2172
+ updateEnvSet(id: string, params: UpdateEnvSetParams): void {
2173
+ const updates: string[] = [];
2174
+ const values: SQLQueryBindings[] = [];
2175
+
2176
+ if (params.name !== undefined) {
2177
+ updates.push("name = ?");
2178
+ values.push(params.name);
2179
+ }
2180
+ if (params.description !== undefined) {
2181
+ updates.push("description = ?");
2182
+ values.push(params.description);
2183
+ }
2184
+ if (params.vars !== undefined) {
2185
+ const { encryptVars } = require("../crypto/encryption");
2186
+ const key = this.getEncryptionKey();
2187
+ updates.push("encrypted_vars_json = ?");
2188
+ values.push(encryptVars(params.vars, key));
2189
+ }
2190
+
2191
+ updates.push("updated_at = ?");
2192
+ values.push(Date.now());
2193
+ values.push(id);
2194
+
2195
+ this.db.prepare(`UPDATE env_sets SET ${updates.join(", ")} WHERE id = ?`).run(...values);
2196
+ }
2197
+
2198
+ deleteEnvSet(id: string): void {
2199
+ this.db.prepare("DELETE FROM env_sets WHERE id = ?").run(id);
2200
+ }
2201
+
2202
+ listEnvSets(): EnvSetRecord[] {
2203
+ const rows = this.db.prepare("SELECT * FROM env_sets ORDER BY name ASC").all() as any[];
2204
+ return rows.map((row) => this.mapRowToEnvSetRecord(row));
2205
+ }
2206
+
2207
+ decryptEnvSetVars(id: string): Record<string, string> {
2208
+ const row = this.db
2209
+ .prepare("SELECT encrypted_vars_json FROM env_sets WHERE id = ?")
2210
+ .get(id) as any;
2211
+ if (!row) throw new Error(`Env set not found: ${id}`);
2212
+
2213
+ const { decryptVars } = require("../crypto/encryption");
2214
+ const key = this.getEncryptionKey();
2215
+ return decryptVars(row.encrypted_vars_json, key);
2216
+ }
2217
+
2218
+ // ─── Auth Operations ───────────────────────────────────────────────
2219
+
2220
+ hasAuthConfig(): boolean {
2221
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM auth_config").get() as any;
2222
+ return row.count > 0;
2223
+ }
2224
+
2225
+ getAuthConfig(): AuthConfigRecord | null {
2226
+ const row = this.db.prepare("SELECT * FROM auth_config WHERE id = 1").get() as any;
2227
+ if (!row) return null;
2228
+ return {
2229
+ passwordHash: row.password_hash,
2230
+ totpSecretEncrypted: row.totp_secret_encrypted ?? null,
2231
+ totpEnabled: row.totp_enabled === 1,
2232
+ mfaSetupPending: row.mfa_setup_pending === 1,
2233
+ onboardingTokenHash: row.onboarding_token_hash ?? null,
2234
+ onboardingTokenExpiresAt:
2235
+ typeof row.onboarding_token_expires_at === "number"
2236
+ ? row.onboarding_token_expires_at
2237
+ : null,
2238
+ recoveryCodesEncrypted: row.recovery_codes_encrypted ?? null,
2239
+ createdAt: new Date(row.created_at),
2240
+ updatedAt: new Date(row.updated_at),
2241
+ };
2242
+ }
2243
+
2244
+ createAuthConfig(
2245
+ passwordHash: string,
2246
+ options: {
2247
+ mfaSetupPending?: boolean;
2248
+ onboardingTokenHash?: string | null;
2249
+ onboardingTokenExpiresAt?: number | null;
2250
+ } = {}
2251
+ ): void {
2252
+ const now = Date.now();
2253
+ const mfaSetupPending = options.mfaSetupPending ?? false;
2254
+ const onboardingTokenHash = options.onboardingTokenHash ?? null;
2255
+ const onboardingTokenExpiresAt = options.onboardingTokenExpiresAt ?? null;
2256
+ this.db
2257
+ .prepare(
2258
+ `INSERT INTO auth_config (
2259
+ id,
2260
+ password_hash,
2261
+ mfa_setup_pending,
2262
+ onboarding_token_hash,
2263
+ onboarding_token_expires_at,
2264
+ created_at,
2265
+ updated_at
2266
+ ) VALUES (1, ?, ?, ?, ?, ?, ?)`
2267
+ )
2268
+ .run(
2269
+ passwordHash,
2270
+ mfaSetupPending ? 1 : 0,
2271
+ onboardingTokenHash,
2272
+ onboardingTokenExpiresAt,
2273
+ now,
2274
+ now
2275
+ );
2276
+ }
2277
+
2278
+ updateAuthPassword(passwordHash: string): void {
2279
+ this.db
2280
+ .prepare("UPDATE auth_config SET password_hash = ?, updated_at = ? WHERE id = 1")
2281
+ .run(passwordHash, Date.now());
2282
+ }
2283
+
2284
+ updateAuthTotp(
2285
+ encryptedSecret: string | null,
2286
+ enabled: boolean,
2287
+ recoveryCodes: string | null
2288
+ ): void {
2289
+ this.db
2290
+ .prepare(
2291
+ `UPDATE auth_config SET
2292
+ totp_secret_encrypted = ?,
2293
+ totp_enabled = ?,
2294
+ recovery_codes_encrypted = ?,
2295
+ updated_at = ?
2296
+ WHERE id = 1`
2297
+ )
2298
+ .run(encryptedSecret, enabled ? 1 : 0, recoveryCodes, Date.now());
2299
+ }
2300
+
2301
+ updateAuthOnboardingState(
2302
+ mfaSetupPending: boolean,
2303
+ onboardingTokenHash: string | null,
2304
+ onboardingTokenExpiresAt: number | null
2305
+ ): void {
2306
+ this.db
2307
+ .prepare(
2308
+ `UPDATE auth_config SET
2309
+ mfa_setup_pending = ?,
2310
+ onboarding_token_hash = ?,
2311
+ onboarding_token_expires_at = ?,
2312
+ updated_at = ?
2313
+ WHERE id = 1`
2314
+ )
2315
+ .run(mfaSetupPending ? 1 : 0, onboardingTokenHash, onboardingTokenExpiresAt, Date.now());
2316
+ }
2317
+
2318
+ createAuthSession(
2319
+ tokenHash: string,
2320
+ ip: string | null,
2321
+ userAgent: string | null,
2322
+ expiresAt: number
2323
+ ): void {
2324
+ const now = Date.now();
2325
+ this.db
2326
+ .prepare(
2327
+ `INSERT INTO auth_sessions (token_hash, created_at, expires_at, last_used_at, ip_address, user_agent)
2328
+ VALUES (?, ?, ?, ?, ?, ?)`
2329
+ )
2330
+ .run(tokenHash, now, expiresAt, now, ip, userAgent);
2331
+ }
2332
+
2333
+ getAuthSession(tokenHash: string): AuthSessionRecord | null {
2334
+ const row = this.db
2335
+ .prepare("SELECT * FROM auth_sessions WHERE token_hash = ?")
2336
+ .get(tokenHash) as any;
2337
+ if (!row) return null;
2338
+ return {
2339
+ tokenHash: row.token_hash,
2340
+ createdAt: row.created_at,
2341
+ expiresAt: row.expires_at,
2342
+ lastUsedAt: row.last_used_at,
2343
+ ipAddress: row.ip_address ?? null,
2344
+ userAgent: row.user_agent ?? null,
2345
+ };
2346
+ }
2347
+
2348
+ touchAuthSession(tokenHash: string, newExpiresAt: number): void {
2349
+ this.db
2350
+ .prepare("UPDATE auth_sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?")
2351
+ .run(Date.now(), newExpiresAt, tokenHash);
2352
+ }
2353
+
2354
+ deleteAuthSession(tokenHash: string): void {
2355
+ this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
2356
+ }
2357
+
2358
+ deleteAllAuthSessions(): void {
2359
+ this.db.prepare("DELETE FROM auth_sessions").run();
2360
+ }
2361
+
2362
+ listAuthSessions(): AuthSessionRecord[] {
2363
+ const rows = this.db
2364
+ .prepare("SELECT * FROM auth_sessions ORDER BY last_used_at DESC")
2365
+ .all() as any[];
2366
+ return rows.map((row) => ({
2367
+ tokenHash: row.token_hash,
2368
+ createdAt: row.created_at,
2369
+ expiresAt: row.expires_at,
2370
+ lastUsedAt: row.last_used_at,
2371
+ ipAddress: row.ip_address ?? null,
2372
+ userAgent: row.user_agent ?? null,
2373
+ }));
2374
+ }
2375
+
2376
+ cleanupExpiredAuthSessions(): number {
2377
+ const result = this.db
2378
+ .prepare("DELETE FROM auth_sessions WHERE expires_at < ?")
2379
+ .run(Date.now());
2380
+ return result.changes;
2381
+ }
2382
+
2383
+ // ─── Daemon Settings Operations ──────────────────────────────────
2384
+
2385
+ getDaemonSettings(): DaemonSettingsRecord {
2386
+ const row = this.db.prepare("SELECT * FROM daemon_settings WHERE id = 1").get() as any;
2387
+ if (!row) {
2388
+ return {
2389
+ preserveSessions: false,
2390
+ defaultPolicyAction: "ask",
2391
+ forwardSshAuthSock: true,
2392
+ codexHostAccessProfileEnabled: false,
2393
+ terminalFeatures: {
2394
+ wsPtyPasteEnabled: true,
2395
+ latencyProbesEnabled: true,
2396
+ diagnosticsPanelEnabled: false,
2397
+ codexAppServerSpikeEnabled: false,
2398
+ wsPtyPasteCanaryPercent: 100,
2399
+ latencyProbesCanaryPercent: 100,
2400
+ diagnosticsPanelCanaryPercent: 0,
2401
+ },
2402
+ notificationsEnabled: false,
2403
+ systemNotificationsEnabled: false,
2404
+ notificationSoundsEnabled: true,
2405
+ notificationEventDefaults: {},
2406
+ notificationSoundMap: {},
2407
+ updatedAt: new Date(),
2408
+ };
2409
+ }
2410
+ return {
2411
+ preserveSessions: row.preserve_sessions === 1,
2412
+ defaultPolicyAction: row.default_policy_action === "deny" ? "deny" : "ask",
2413
+ forwardSshAuthSock: row.forward_ssh_auth_sock !== 0,
2414
+ codexHostAccessProfileEnabled: row.codex_host_access_profile_enabled !== 0,
2415
+ terminalFeatures: {
2416
+ wsPtyPasteEnabled: row.terminal_ws_pty_paste_enabled !== 0,
2417
+ latencyProbesEnabled: row.terminal_latency_probes_enabled !== 0,
2418
+ diagnosticsPanelEnabled: row.terminal_diagnostics_panel_enabled !== 0,
2419
+ codexAppServerSpikeEnabled: row.terminal_codex_app_server_spike_enabled !== 0,
2420
+ wsPtyPasteCanaryPercent: this.normalizeCanaryPercent(
2421
+ row.terminal_ws_pty_paste_canary_percent,
2422
+ 100
2423
+ ),
2424
+ latencyProbesCanaryPercent: this.normalizeCanaryPercent(
2425
+ row.terminal_latency_probes_canary_percent,
2426
+ 100
2427
+ ),
2428
+ diagnosticsPanelCanaryPercent: this.normalizeCanaryPercent(
2429
+ row.terminal_diagnostics_panel_canary_percent,
2430
+ 0
2431
+ ),
2432
+ },
2433
+ notificationsEnabled: row.notifications_enabled !== 0,
2434
+ systemNotificationsEnabled: row.system_notifications_enabled !== 0,
2435
+ notificationSoundsEnabled: row.notification_sounds_enabled !== 0,
2436
+ notificationEventDefaults: this.parseBooleanRecord(row.notification_event_defaults_json),
2437
+ notificationSoundMap: this.parseStringRecord(row.notification_sound_map_json),
2438
+ updatedAt: new Date(row.updated_at),
2439
+ };
2440
+ }
2441
+
2442
+ updateDaemonSettings(params: {
2443
+ preserveSessions?: boolean;
2444
+ defaultPolicyAction?: "ask" | "deny";
2445
+ forwardSshAuthSock?: boolean;
2446
+ codexHostAccessProfileEnabled?: boolean;
2447
+ terminalFeatures?: Partial<DaemonTerminalFeatureSettings>;
2448
+ notificationsEnabled?: boolean;
2449
+ systemNotificationsEnabled?: boolean;
2450
+ notificationSoundsEnabled?: boolean;
2451
+ notificationEventDefaults?: Record<string, boolean>;
2452
+ notificationSoundMap?: Record<string, string>;
2453
+ }): void {
2454
+ const now = Date.now();
2455
+ const existing = this.db.prepare("SELECT id FROM daemon_settings WHERE id = 1").get();
2456
+ const normalizedTerminalFeatures = params.terminalFeatures
2457
+ ? {
2458
+ wsPtyPasteEnabled:
2459
+ params.terminalFeatures.wsPtyPasteEnabled !== undefined
2460
+ ? params.terminalFeatures.wsPtyPasteEnabled
2461
+ : undefined,
2462
+ latencyProbesEnabled:
2463
+ params.terminalFeatures.latencyProbesEnabled !== undefined
2464
+ ? params.terminalFeatures.latencyProbesEnabled
2465
+ : undefined,
2466
+ diagnosticsPanelEnabled:
2467
+ params.terminalFeatures.diagnosticsPanelEnabled !== undefined
2468
+ ? params.terminalFeatures.diagnosticsPanelEnabled
2469
+ : undefined,
2470
+ codexAppServerSpikeEnabled:
2471
+ params.terminalFeatures.codexAppServerSpikeEnabled !== undefined
2472
+ ? params.terminalFeatures.codexAppServerSpikeEnabled
2473
+ : undefined,
2474
+ wsPtyPasteCanaryPercent:
2475
+ params.terminalFeatures.wsPtyPasteCanaryPercent !== undefined
2476
+ ? this.normalizeCanaryPercent(params.terminalFeatures.wsPtyPasteCanaryPercent, 100)
2477
+ : undefined,
2478
+ latencyProbesCanaryPercent:
2479
+ params.terminalFeatures.latencyProbesCanaryPercent !== undefined
2480
+ ? this.normalizeCanaryPercent(params.terminalFeatures.latencyProbesCanaryPercent, 100)
2481
+ : undefined,
2482
+ diagnosticsPanelCanaryPercent:
2483
+ params.terminalFeatures.diagnosticsPanelCanaryPercent !== undefined
2484
+ ? this.normalizeCanaryPercent(
2485
+ params.terminalFeatures.diagnosticsPanelCanaryPercent,
2486
+ 0
2487
+ )
2488
+ : undefined,
2489
+ }
2490
+ : undefined;
2491
+
2492
+ if (!existing) {
2493
+ const terminalFeatures = {
2494
+ wsPtyPasteEnabled: normalizedTerminalFeatures?.wsPtyPasteEnabled ?? true,
2495
+ latencyProbesEnabled: normalizedTerminalFeatures?.latencyProbesEnabled ?? true,
2496
+ diagnosticsPanelEnabled: normalizedTerminalFeatures?.diagnosticsPanelEnabled ?? false,
2497
+ codexAppServerSpikeEnabled: normalizedTerminalFeatures?.codexAppServerSpikeEnabled ?? false,
2498
+ wsPtyPasteCanaryPercent: normalizedTerminalFeatures?.wsPtyPasteCanaryPercent ?? 100,
2499
+ latencyProbesCanaryPercent: normalizedTerminalFeatures?.latencyProbesCanaryPercent ?? 100,
2500
+ diagnosticsPanelCanaryPercent:
2501
+ normalizedTerminalFeatures?.diagnosticsPanelCanaryPercent ?? 0,
2502
+ };
2503
+ this.db
2504
+ .prepare(
2505
+ `INSERT INTO daemon_settings (
2506
+ id,
2507
+ preserve_sessions,
2508
+ default_policy_action,
2509
+ forward_ssh_auth_sock,
2510
+ codex_host_access_profile_enabled,
2511
+ terminal_ws_pty_paste_enabled,
2512
+ terminal_latency_probes_enabled,
2513
+ terminal_diagnostics_panel_enabled,
2514
+ terminal_codex_app_server_spike_enabled,
2515
+ terminal_ws_pty_paste_canary_percent,
2516
+ terminal_latency_probes_canary_percent,
2517
+ terminal_diagnostics_panel_canary_percent,
2518
+ notifications_enabled,
2519
+ system_notifications_enabled,
2520
+ notification_sounds_enabled,
2521
+ notification_event_defaults_json,
2522
+ notification_sound_map_json,
2523
+ updated_at
2524
+ ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2525
+ )
2526
+ .run(
2527
+ params.preserveSessions ? 1 : 0,
2528
+ params.defaultPolicyAction ?? "ask",
2529
+ params.forwardSshAuthSock === undefined ? 1 : params.forwardSshAuthSock ? 1 : 0,
2530
+ params.codexHostAccessProfileEnabled ? 1 : 0,
2531
+ terminalFeatures.wsPtyPasteEnabled ? 1 : 0,
2532
+ terminalFeatures.latencyProbesEnabled ? 1 : 0,
2533
+ terminalFeatures.diagnosticsPanelEnabled ? 1 : 0,
2534
+ terminalFeatures.codexAppServerSpikeEnabled ? 1 : 0,
2535
+ terminalFeatures.wsPtyPasteCanaryPercent,
2536
+ terminalFeatures.latencyProbesCanaryPercent,
2537
+ terminalFeatures.diagnosticsPanelCanaryPercent,
2538
+ params.notificationsEnabled ? 1 : 0,
2539
+ params.systemNotificationsEnabled ? 1 : 0,
2540
+ params.notificationSoundsEnabled === undefined
2541
+ ? 1
2542
+ : params.notificationSoundsEnabled
2543
+ ? 1
2544
+ : 0,
2545
+ this.toJsonRecord(params.notificationEventDefaults, "boolean"),
2546
+ this.toJsonRecord(params.notificationSoundMap, "string"),
2547
+ now
2548
+ );
2549
+ } else {
2550
+ const updates: string[] = [];
2551
+ const values: SQLQueryBindings[] = [];
2552
+
2553
+ if (params.preserveSessions !== undefined) {
2554
+ updates.push("preserve_sessions = ?");
2555
+ values.push(params.preserveSessions ? 1 : 0);
2556
+ }
2557
+
2558
+ if (params.defaultPolicyAction !== undefined) {
2559
+ updates.push("default_policy_action = ?");
2560
+ values.push(params.defaultPolicyAction);
2561
+ }
2562
+
2563
+ if (params.forwardSshAuthSock !== undefined) {
2564
+ updates.push("forward_ssh_auth_sock = ?");
2565
+ values.push(params.forwardSshAuthSock ? 1 : 0);
2566
+ }
2567
+
2568
+ if (params.codexHostAccessProfileEnabled !== undefined) {
2569
+ updates.push("codex_host_access_profile_enabled = ?");
2570
+ values.push(params.codexHostAccessProfileEnabled ? 1 : 0);
2571
+ }
2572
+
2573
+ if (normalizedTerminalFeatures?.wsPtyPasteEnabled !== undefined) {
2574
+ updates.push("terminal_ws_pty_paste_enabled = ?");
2575
+ values.push(normalizedTerminalFeatures.wsPtyPasteEnabled ? 1 : 0);
2576
+ }
2577
+
2578
+ if (normalizedTerminalFeatures?.latencyProbesEnabled !== undefined) {
2579
+ updates.push("terminal_latency_probes_enabled = ?");
2580
+ values.push(normalizedTerminalFeatures.latencyProbesEnabled ? 1 : 0);
2581
+ }
2582
+
2583
+ if (normalizedTerminalFeatures?.diagnosticsPanelEnabled !== undefined) {
2584
+ updates.push("terminal_diagnostics_panel_enabled = ?");
2585
+ values.push(normalizedTerminalFeatures.diagnosticsPanelEnabled ? 1 : 0);
2586
+ }
2587
+
2588
+ if (normalizedTerminalFeatures?.codexAppServerSpikeEnabled !== undefined) {
2589
+ updates.push("terminal_codex_app_server_spike_enabled = ?");
2590
+ values.push(normalizedTerminalFeatures.codexAppServerSpikeEnabled ? 1 : 0);
2591
+ }
2592
+
2593
+ if (normalizedTerminalFeatures?.wsPtyPasteCanaryPercent !== undefined) {
2594
+ updates.push("terminal_ws_pty_paste_canary_percent = ?");
2595
+ values.push(normalizedTerminalFeatures.wsPtyPasteCanaryPercent);
2596
+ }
2597
+
2598
+ if (normalizedTerminalFeatures?.latencyProbesCanaryPercent !== undefined) {
2599
+ updates.push("terminal_latency_probes_canary_percent = ?");
2600
+ values.push(normalizedTerminalFeatures.latencyProbesCanaryPercent);
2601
+ }
2602
+
2603
+ if (normalizedTerminalFeatures?.diagnosticsPanelCanaryPercent !== undefined) {
2604
+ updates.push("terminal_diagnostics_panel_canary_percent = ?");
2605
+ values.push(normalizedTerminalFeatures.diagnosticsPanelCanaryPercent);
2606
+ }
2607
+
2608
+ if (params.notificationsEnabled !== undefined) {
2609
+ updates.push("notifications_enabled = ?");
2610
+ values.push(params.notificationsEnabled ? 1 : 0);
2611
+ }
2612
+
2613
+ if (params.systemNotificationsEnabled !== undefined) {
2614
+ updates.push("system_notifications_enabled = ?");
2615
+ values.push(params.systemNotificationsEnabled ? 1 : 0);
2616
+ }
2617
+
2618
+ if (params.notificationSoundsEnabled !== undefined) {
2619
+ updates.push("notification_sounds_enabled = ?");
2620
+ values.push(params.notificationSoundsEnabled ? 1 : 0);
2621
+ }
2622
+
2623
+ if (params.notificationEventDefaults !== undefined) {
2624
+ updates.push("notification_event_defaults_json = ?");
2625
+ values.push(this.toJsonRecord(params.notificationEventDefaults, "boolean"));
2626
+ }
2627
+
2628
+ if (params.notificationSoundMap !== undefined) {
2629
+ updates.push("notification_sound_map_json = ?");
2630
+ values.push(this.toJsonRecord(params.notificationSoundMap, "string"));
2631
+ }
2632
+
2633
+ updates.push("updated_at = ?");
2634
+ values.push(now);
2635
+
2636
+ this.db
2637
+ .prepare(`UPDATE daemon_settings SET ${updates.join(", ")} WHERE id = 1`)
2638
+ .run(...values);
2639
+ }
2640
+ }
2641
+
2642
+ private normalizeCanaryPercent(value: unknown, fallback: number): number {
2643
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2644
+ return fallback;
2645
+ }
2646
+ const rounded = Math.round(value);
2647
+ if (rounded < 0) {
2648
+ return 0;
2649
+ }
2650
+ if (rounded > 100) {
2651
+ return 100;
2652
+ }
2653
+ return rounded;
2654
+ }
2655
+
2656
+ private parseBooleanRecord(value: unknown): Record<string, boolean> {
2657
+ if (typeof value !== "string" || value.trim() === "") {
2658
+ return {};
2659
+ }
2660
+ try {
2661
+ const parsed = JSON.parse(value) as unknown;
2662
+ if (!(parsed && typeof parsed === "object" && !Array.isArray(parsed))) {
2663
+ return {};
2664
+ }
2665
+ const result: Record<string, boolean> = {};
2666
+ for (const [key, entry] of Object.entries(parsed)) {
2667
+ if (typeof entry === "boolean") {
2668
+ result[key] = entry;
2669
+ }
2670
+ }
2671
+ return result;
2672
+ } catch {
2673
+ return {};
2674
+ }
2675
+ }
2676
+
2677
+ private parseStringRecord(value: unknown): Record<string, string> {
2678
+ if (typeof value !== "string" || value.trim() === "") {
2679
+ return {};
2680
+ }
2681
+ try {
2682
+ const parsed = JSON.parse(value) as unknown;
2683
+ if (!(parsed && typeof parsed === "object" && !Array.isArray(parsed))) {
2684
+ return {};
2685
+ }
2686
+ const result: Record<string, string> = {};
2687
+ for (const [key, entry] of Object.entries(parsed)) {
2688
+ if (typeof entry === "string") {
2689
+ result[key] = entry;
2690
+ }
2691
+ }
2692
+ return result;
2693
+ } catch {
2694
+ return {};
2695
+ }
2696
+ }
2697
+
2698
+ private toJsonRecord(
2699
+ value: Record<string, unknown> | undefined,
2700
+ itemType: "boolean" | "string"
2701
+ ): string {
2702
+ if (!(value && typeof value === "object" && !Array.isArray(value))) {
2703
+ return "{}";
2704
+ }
2705
+
2706
+ const normalized: Record<string, unknown> = {};
2707
+ for (const [key, entry] of Object.entries(value)) {
2708
+ if (itemType === "boolean" && typeof entry === "boolean") {
2709
+ normalized[key] = entry;
2710
+ } else if (itemType === "string" && typeof entry === "string") {
2711
+ normalized[key] = entry;
2712
+ }
2713
+ }
2714
+
2715
+ return JSON.stringify(normalized);
2716
+ }
2717
+
2718
+ private mapRowToEnvSetRecord(row: any): EnvSetRecord {
2719
+ const { decryptVars, maskValue } = require("../crypto/encryption");
2720
+ const key = this.getEncryptionKey();
2721
+
2722
+ let maskedVars: Record<string, string> = {};
2723
+ let varCount = 0;
2724
+ try {
2725
+ const plainVars = decryptVars(row.encrypted_vars_json, key);
2726
+ varCount = Object.keys(plainVars).length;
2727
+ for (const [k, v] of Object.entries(plainVars)) {
2728
+ maskedVars[k] = maskValue(v);
2729
+ }
2730
+ } catch {
2731
+ // If decryption fails, return empty masked vars
2732
+ maskedVars = {};
2733
+ }
2734
+
2735
+ return {
2736
+ id: row.id,
2737
+ name: row.name,
2738
+ description: row.description ?? undefined,
2739
+ maskedVars,
2740
+ varCount,
2741
+ createdAt: new Date(row.created_at),
2742
+ updatedAt: new Date(row.updated_at),
2743
+ };
2744
+ }
2745
+ }