@wangyaoshen/remux 0.3.8-dev.29e114b

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 (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
package/src/store.ts ADDED
@@ -0,0 +1,1422 @@
1
+ /**
2
+ * SQLite persistence store for Remux.
3
+ * Uses better-sqlite3 with WAL mode at ~/.remux/remux.db.
4
+ * Manages sessions, tabs (scrollback as BLOB), and device trust.
5
+ *
6
+ * Adapted from better-sqlite3 best practices:
7
+ * https://github.com/WiseLibs/better-sqlite3/blob/master/docs/performance.md
8
+ */
9
+
10
+ import Database from "better-sqlite3";
11
+ import path from "path";
12
+ import { homedir } from "os";
13
+ import fs from "fs";
14
+ import crypto from "crypto";
15
+
16
+ // ── Database path ───────────────────────────────────────────────
17
+
18
+ const REMUX_DIR = path.join(homedir(), ".remux");
19
+ const PORT = process.env.PORT || 8767;
20
+ const PERSIST_ID = process.env.REMUX_INSTANCE_ID || `port-${PORT}`;
21
+
22
+ export function getDbPath(): string {
23
+ return path.join(REMUX_DIR, `remux-${PERSIST_ID}.db`);
24
+ }
25
+
26
+ // ── Database singleton ──────────────────────────────────────────
27
+
28
+ let _db: Database.Database | null = null;
29
+
30
+ export function getDb(): Database.Database {
31
+ if (_db) return _db;
32
+
33
+ if (!fs.existsSync(REMUX_DIR)) {
34
+ fs.mkdirSync(REMUX_DIR, { recursive: true });
35
+ }
36
+
37
+ _db = new Database(getDbPath());
38
+ _db.pragma("journal_mode = WAL");
39
+ _db.pragma("foreign_keys = ON");
40
+
41
+ // Create tables
42
+ _db.exec(`
43
+ CREATE TABLE IF NOT EXISTS sessions (
44
+ name TEXT PRIMARY KEY,
45
+ created_at INTEGER NOT NULL
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS tabs (
49
+ id INTEGER PRIMARY KEY,
50
+ session_name TEXT NOT NULL,
51
+ title TEXT NOT NULL DEFAULT 'Tab',
52
+ scrollback BLOB,
53
+ ended INTEGER NOT NULL DEFAULT 0,
54
+ FOREIGN KEY (session_name) REFERENCES sessions(name) ON DELETE CASCADE
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS devices (
58
+ id TEXT PRIMARY KEY,
59
+ name TEXT NOT NULL,
60
+ fingerprint TEXT NOT NULL,
61
+ trust TEXT NOT NULL DEFAULT 'untrusted',
62
+ created_at INTEGER NOT NULL,
63
+ last_seen INTEGER NOT NULL
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS pair_codes (
67
+ code TEXT PRIMARY KEY,
68
+ created_by TEXT NOT NULL,
69
+ expires_at INTEGER NOT NULL,
70
+ FOREIGN KEY (created_by) REFERENCES devices(id) ON DELETE CASCADE
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS settings (
74
+ key TEXT PRIMARY KEY,
75
+ value TEXT
76
+ );
77
+
78
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
79
+ device_id TEXT PRIMARY KEY,
80
+ endpoint TEXT NOT NULL,
81
+ p256dh TEXT NOT NULL,
82
+ auth TEXT NOT NULL,
83
+ created_at INTEGER NOT NULL
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS topics (
87
+ id TEXT PRIMARY KEY,
88
+ session_name TEXT NOT NULL,
89
+ title TEXT NOT NULL,
90
+ created_at INTEGER NOT NULL,
91
+ updated_at INTEGER NOT NULL
92
+ );
93
+
94
+ CREATE TABLE IF NOT EXISTS runs (
95
+ id TEXT PRIMARY KEY,
96
+ topic_id TEXT REFERENCES topics(id),
97
+ session_name TEXT NOT NULL,
98
+ tab_id INTEGER,
99
+ command TEXT,
100
+ exit_code INTEGER,
101
+ started_at INTEGER NOT NULL,
102
+ ended_at INTEGER,
103
+ status TEXT DEFAULT 'running'
104
+ );
105
+
106
+ CREATE TABLE IF NOT EXISTS artifacts (
107
+ id TEXT PRIMARY KEY,
108
+ run_id TEXT REFERENCES runs(id),
109
+ topic_id TEXT REFERENCES topics(id),
110
+ session_name TEXT,
111
+ type TEXT NOT NULL,
112
+ title TEXT,
113
+ content TEXT,
114
+ created_at INTEGER NOT NULL
115
+ );
116
+
117
+ CREATE TABLE IF NOT EXISTS approvals (
118
+ id TEXT PRIMARY KEY,
119
+ run_id TEXT REFERENCES runs(id),
120
+ topic_id TEXT REFERENCES topics(id),
121
+ title TEXT NOT NULL,
122
+ description TEXT,
123
+ status TEXT DEFAULT 'pending',
124
+ created_at INTEGER NOT NULL,
125
+ resolved_at INTEGER
126
+ );
127
+
128
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_index USING fts5(
129
+ entity_type, entity_id, title, content,
130
+ tokenize='porter unicode61'
131
+ );
132
+
133
+ CREATE TABLE IF NOT EXISTS memory_notes (
134
+ id TEXT PRIMARY KEY,
135
+ content TEXT NOT NULL,
136
+ pinned INTEGER DEFAULT 0,
137
+ created_at INTEGER NOT NULL,
138
+ updated_at INTEGER NOT NULL
139
+ );
140
+
141
+ CREATE TABLE IF NOT EXISTS commands (
142
+ id TEXT PRIMARY KEY,
143
+ session_name TEXT NOT NULL,
144
+ tab_id INTEGER NOT NULL,
145
+ command TEXT,
146
+ exit_code INTEGER,
147
+ cwd TEXT,
148
+ started_at INTEGER NOT NULL,
149
+ ended_at INTEGER
150
+ );
151
+
152
+ -- Step 2: workspace_head (multi-device shared focus state)
153
+ CREATE TABLE IF NOT EXISTS workspace_head (
154
+ id TEXT PRIMARY KEY DEFAULT 'global',
155
+ session_name TEXT NOT NULL,
156
+ tab_id INTEGER NOT NULL,
157
+ topic_id TEXT,
158
+ view TEXT NOT NULL DEFAULT 'live',
159
+ revision INTEGER NOT NULL DEFAULT 0,
160
+ updated_by_device TEXT,
161
+ updated_at INTEGER NOT NULL
162
+ );
163
+
164
+ -- Step 4: durable stream tables
165
+ CREATE TABLE IF NOT EXISTS tab_stream_chunks (
166
+ tab_id INTEGER NOT NULL,
167
+ seq_from INTEGER NOT NULL,
168
+ seq_to INTEGER NOT NULL,
169
+ created_at INTEGER NOT NULL,
170
+ data BLOB NOT NULL,
171
+ PRIMARY KEY (tab_id, seq_from)
172
+ );
173
+
174
+ CREATE TABLE IF NOT EXISTS tab_snapshots (
175
+ tab_id INTEGER NOT NULL,
176
+ seq INTEGER NOT NULL,
177
+ cols INTEGER NOT NULL,
178
+ rows INTEGER NOT NULL,
179
+ snapshot BLOB NOT NULL,
180
+ created_at INTEGER NOT NULL,
181
+ PRIMARY KEY (tab_id, seq)
182
+ );
183
+
184
+ CREATE TABLE IF NOT EXISTS device_tab_cursors (
185
+ device_id TEXT NOT NULL,
186
+ tab_id INTEGER NOT NULL,
187
+ last_acked_seq INTEGER NOT NULL,
188
+ updated_at INTEGER NOT NULL,
189
+ PRIMARY KEY (device_id, tab_id)
190
+ );
191
+ `);
192
+
193
+ // ── Migrations for existing databases ──
194
+ // Add session_name to artifacts if missing (introduced in v0.3.6)
195
+ const artifactCols = _db.prepare("PRAGMA table_info(artifacts)").all() as any[];
196
+ if (!artifactCols.find((c: any) => c.name === "session_name")) {
197
+ _db.exec("ALTER TABLE artifacts ADD COLUMN session_name TEXT");
198
+ }
199
+
200
+ // Step 3: Add session_name to memory_notes if missing
201
+ const noteCols = _db.prepare("PRAGMA table_info(memory_notes)").all() as any[];
202
+ if (!noteCols.find((c: any) => c.name === "session_name")) {
203
+ _db.exec("ALTER TABLE memory_notes ADD COLUMN session_name TEXT");
204
+ }
205
+
206
+ return _db;
207
+ }
208
+
209
+ /**
210
+ * Close the database connection (for clean shutdown / testing).
211
+ */
212
+ export function closeDb(): void {
213
+ if (_db) {
214
+ _db.close();
215
+ _db = null;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Reset singleton for testing -- allows re-init with a different path.
221
+ */
222
+ export function _resetDbForTest(testDb: Database.Database): void {
223
+ _db = testDb;
224
+ }
225
+
226
+ // ── Device types ────────────────────────────────────────────────
227
+
228
+ export type TrustLevel = "trusted" | "untrusted" | "blocked";
229
+
230
+ export interface Device {
231
+ id: string;
232
+ name: string;
233
+ fingerprint: string;
234
+ trust: TrustLevel;
235
+ createdAt: number;
236
+ lastSeen: number;
237
+ }
238
+
239
+ // ── Session / Tab persistence ───────────────────────────────────
240
+
241
+ export interface PersistedSession {
242
+ name: string;
243
+ createdAt: number;
244
+ }
245
+
246
+ export interface PersistedTab {
247
+ id: number;
248
+ sessionName: string;
249
+ title: string;
250
+ scrollback: Buffer | null;
251
+ ended: boolean;
252
+ }
253
+
254
+ /**
255
+ * Upsert a session record.
256
+ */
257
+ export function upsertSession(name: string, createdAt: number): void {
258
+ const db = getDb();
259
+ db.prepare(
260
+ `INSERT INTO sessions (name, created_at) VALUES (?, ?)
261
+ ON CONFLICT(name) DO UPDATE SET created_at = excluded.created_at`,
262
+ ).run(name, createdAt);
263
+ }
264
+
265
+ /**
266
+ * Upsert a tab record (scrollback as BLOB).
267
+ */
268
+ export function upsertTab(tab: PersistedTab): void {
269
+ const db = getDb();
270
+ db.prepare(
271
+ `INSERT INTO tabs (id, session_name, title, scrollback, ended) VALUES (?, ?, ?, ?, ?)
272
+ ON CONFLICT(id) DO UPDATE SET
273
+ session_name = excluded.session_name,
274
+ title = excluded.title,
275
+ scrollback = excluded.scrollback,
276
+ ended = excluded.ended`,
277
+ ).run(
278
+ tab.id,
279
+ tab.sessionName,
280
+ tab.title,
281
+ tab.scrollback,
282
+ tab.ended ? 1 : 0,
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Load all persisted sessions with their tabs.
288
+ */
289
+ export function loadSessions(): Array<{
290
+ name: string;
291
+ createdAt: number;
292
+ tabs: Array<{
293
+ id: number;
294
+ title: string;
295
+ scrollback: Buffer | null;
296
+ ended: boolean;
297
+ }>;
298
+ }> {
299
+ const db = getDb();
300
+ const sessions = db
301
+ .prepare("SELECT name, created_at FROM sessions ORDER BY created_at")
302
+ .all() as Array<{ name: string; created_at: number }>;
303
+
304
+ return sessions.map((s) => {
305
+ const tabs = db
306
+ .prepare(
307
+ "SELECT id, title, scrollback, ended FROM tabs WHERE session_name = ? ORDER BY id",
308
+ )
309
+ .all(s.name) as Array<{
310
+ id: number;
311
+ title: string;
312
+ scrollback: Buffer | null;
313
+ ended: number;
314
+ }>;
315
+
316
+ return {
317
+ name: s.name,
318
+ createdAt: s.created_at,
319
+ tabs: tabs.map((t) => ({
320
+ id: t.id,
321
+ title: t.title,
322
+ scrollback: t.scrollback,
323
+ ended: t.ended === 1,
324
+ })),
325
+ };
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Remove tabs that no longer exist in the live session map.
331
+ */
332
+ export function removeStaleTab(tabId: number): void {
333
+ const db = getDb();
334
+ db.prepare("DELETE FROM tabs WHERE id = ?").run(tabId);
335
+ }
336
+
337
+ /**
338
+ * Remove a session and its tabs from the store.
339
+ */
340
+ export function removeSession(name: string): void {
341
+ const db = getDb();
342
+ db.prepare("DELETE FROM sessions WHERE name = ?").run(name);
343
+ }
344
+
345
+ // ── Device CRUD ─────────────────────────────────────────────────
346
+
347
+ /**
348
+ * Generate a random 16-char hex device ID.
349
+ */
350
+ export function generateDeviceId(): string {
351
+ return crypto.randomBytes(8).toString("hex");
352
+ }
353
+
354
+ /**
355
+ * Compute device fingerprint from request headers.
356
+ * SHA-256 of user-agent + accept-language, truncated to 16 chars.
357
+ */
358
+ export function computeFingerprint(
359
+ userAgent: string,
360
+ acceptLanguage: string,
361
+ ): string {
362
+ const raw = `${userAgent}|${acceptLanguage}`;
363
+ return crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16);
364
+ }
365
+
366
+ /**
367
+ * Check if any devices exist (for bootstrap trust logic).
368
+ */
369
+ export function hasAnyDevice(): boolean {
370
+ const db = getDb();
371
+ const row = db
372
+ .prepare("SELECT COUNT(*) as cnt FROM devices")
373
+ .get() as { cnt: number };
374
+ return row.cnt > 0;
375
+ }
376
+
377
+ /**
378
+ * Create a new device. Returns the created device.
379
+ */
380
+ export function createDevice(
381
+ fingerprint: string,
382
+ trust: TrustLevel = "untrusted",
383
+ name?: string,
384
+ explicitId?: string,
385
+ ): Device {
386
+ const db = getDb();
387
+ const id = explicitId || generateDeviceId();
388
+ const now = Date.now();
389
+ const deviceName = name || `Device-${id.slice(0, 8).toUpperCase()}`;
390
+
391
+ db.prepare(
392
+ `INSERT INTO devices (id, name, fingerprint, trust, created_at, last_seen)
393
+ VALUES (?, ?, ?, ?, ?, ?)`,
394
+ ).run(id, deviceName, fingerprint, trust, now, now);
395
+
396
+ return {
397
+ id,
398
+ name: deviceName,
399
+ fingerprint,
400
+ trust,
401
+ createdAt: now,
402
+ lastSeen: now,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Find a device by fingerprint.
408
+ */
409
+ export function findDeviceByFingerprint(
410
+ fingerprint: string,
411
+ ): Device | null {
412
+ const db = getDb();
413
+ const row = db
414
+ .prepare("SELECT * FROM devices WHERE fingerprint = ?")
415
+ .get(fingerprint) as any;
416
+ if (!row) return null;
417
+ return {
418
+ id: row.id,
419
+ name: row.name,
420
+ fingerprint: row.fingerprint,
421
+ trust: row.trust as TrustLevel,
422
+ createdAt: row.created_at,
423
+ lastSeen: row.last_seen,
424
+ };
425
+ }
426
+
427
+ /**
428
+ * Find a device by ID.
429
+ */
430
+ export function findDeviceById(id: string): Device | null {
431
+ const db = getDb();
432
+ const row = db
433
+ .prepare("SELECT * FROM devices WHERE id = ?")
434
+ .get(id) as any;
435
+ if (!row) return null;
436
+ return {
437
+ id: row.id,
438
+ name: row.name,
439
+ fingerprint: row.fingerprint,
440
+ trust: row.trust as TrustLevel,
441
+ createdAt: row.created_at,
442
+ lastSeen: row.last_seen,
443
+ };
444
+ }
445
+
446
+ /**
447
+ * List all devices.
448
+ */
449
+ export function listDevices(): Device[] {
450
+ const db = getDb();
451
+ const rows = db
452
+ .prepare("SELECT * FROM devices ORDER BY last_seen DESC")
453
+ .all() as any[];
454
+ return rows.map((r) => ({
455
+ id: r.id,
456
+ name: r.name,
457
+ fingerprint: r.fingerprint,
458
+ trust: r.trust as TrustLevel,
459
+ createdAt: r.created_at,
460
+ lastSeen: r.last_seen,
461
+ }));
462
+ }
463
+
464
+ /**
465
+ * Update device trust level.
466
+ */
467
+ export function updateDeviceTrust(id: string, trust: TrustLevel): boolean {
468
+ const db = getDb();
469
+ const result = db
470
+ .prepare("UPDATE devices SET trust = ? WHERE id = ?")
471
+ .run(trust, id);
472
+ return result.changes > 0;
473
+ }
474
+
475
+ /**
476
+ * Rename a device.
477
+ */
478
+ export function renameDevice(id: string, name: string): boolean {
479
+ const db = getDb();
480
+ const result = db
481
+ .prepare("UPDATE devices SET name = ? WHERE id = ?")
482
+ .run(name, id);
483
+ return result.changes > 0;
484
+ }
485
+
486
+ /**
487
+ * Update device last_seen timestamp.
488
+ */
489
+ export function touchDevice(id: string): void {
490
+ const db = getDb();
491
+ db.prepare("UPDATE devices SET last_seen = ? WHERE id = ?").run(
492
+ Date.now(),
493
+ id,
494
+ );
495
+ }
496
+
497
+ /**
498
+ * Delete a device record.
499
+ */
500
+ export function deleteDevice(id: string): boolean {
501
+ const db = getDb();
502
+ const result = db.prepare("DELETE FROM devices WHERE id = ?").run(id);
503
+ return result.changes > 0;
504
+ }
505
+
506
+ // ── Pair codes ──────────────────────────────────────────────────
507
+
508
+ export interface PairCode {
509
+ code: string;
510
+ createdBy: string;
511
+ expiresAt: number;
512
+ }
513
+
514
+ /**
515
+ * Generate a random 6-digit pairing code (valid for 5 minutes).
516
+ */
517
+ export function createPairCode(createdBy: string): PairCode {
518
+ const db = getDb();
519
+ // Random 6-digit code
520
+ const code = String(crypto.randomInt(100000, 999999));
521
+ const expiresAt = Date.now() + 5 * 60 * 1000;
522
+
523
+ // Clean expired codes first
524
+ db.prepare("DELETE FROM pair_codes WHERE expires_at < ?").run(Date.now());
525
+
526
+ db.prepare(
527
+ "INSERT INTO pair_codes (code, created_by, expires_at) VALUES (?, ?, ?)",
528
+ ).run(code, createdBy, expiresAt);
529
+
530
+ return { code, createdBy, expiresAt };
531
+ }
532
+
533
+ /**
534
+ * Validate and consume a pairing code (one-time use).
535
+ * Returns the device ID that created it, or null if invalid/expired.
536
+ */
537
+ export function consumePairCode(code: string): string | null {
538
+ const db = getDb();
539
+ const row = db
540
+ .prepare(
541
+ "SELECT created_by, expires_at FROM pair_codes WHERE code = ?",
542
+ )
543
+ .get(code) as { created_by: string; expires_at: number } | undefined;
544
+
545
+ if (!row || row.expires_at < Date.now()) {
546
+ // Clean up expired
547
+ db.prepare("DELETE FROM pair_codes WHERE code = ?").run(code);
548
+ return null;
549
+ }
550
+
551
+ // Consume (delete) the code
552
+ db.prepare("DELETE FROM pair_codes WHERE code = ?").run(code);
553
+ return row.created_by;
554
+ }
555
+
556
+ // ── Settings KV ────────────────────────────────────────────────
557
+
558
+ /**
559
+ * Get a setting value by key.
560
+ */
561
+ export function getSetting(key: string): string | null {
562
+ const db = getDb();
563
+ const row = db
564
+ .prepare("SELECT value FROM settings WHERE key = ?")
565
+ .get(key) as { value: string } | undefined;
566
+ return row?.value ?? null;
567
+ }
568
+
569
+ /**
570
+ * Set a setting value (upsert).
571
+ */
572
+ export function setSetting(key: string, value: string): void {
573
+ const db = getDb();
574
+ db.prepare(
575
+ `INSERT INTO settings (key, value) VALUES (?, ?)
576
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
577
+ ).run(key, value);
578
+ }
579
+
580
+ // ── Push Subscriptions ─────────────────────────────────────────
581
+
582
+ export interface PushSubscriptionRecord {
583
+ deviceId: string;
584
+ endpoint: string;
585
+ p256dh: string;
586
+ auth: string;
587
+ createdAt: number;
588
+ }
589
+
590
+ /**
591
+ * Save a push subscription for a device (upsert).
592
+ */
593
+ export function savePushSubscription(
594
+ deviceId: string,
595
+ endpoint: string,
596
+ p256dh: string,
597
+ auth: string,
598
+ ): void {
599
+ const db = getDb();
600
+ db.prepare(
601
+ `INSERT INTO push_subscriptions (device_id, endpoint, p256dh, auth, created_at)
602
+ VALUES (?, ?, ?, ?, ?)
603
+ ON CONFLICT(device_id) DO UPDATE SET
604
+ endpoint = excluded.endpoint,
605
+ p256dh = excluded.p256dh,
606
+ auth = excluded.auth,
607
+ created_at = excluded.created_at`,
608
+ ).run(deviceId, endpoint, p256dh, auth, Date.now());
609
+ }
610
+
611
+ /**
612
+ * Remove a push subscription for a device.
613
+ */
614
+ export function removePushSubscription(deviceId: string): boolean {
615
+ const db = getDb();
616
+ const result = db
617
+ .prepare("DELETE FROM push_subscriptions WHERE device_id = ?")
618
+ .run(deviceId);
619
+ return result.changes > 0;
620
+ }
621
+
622
+ /**
623
+ * Get a push subscription for a specific device.
624
+ */
625
+ export function getPushSubscription(
626
+ deviceId: string,
627
+ ): PushSubscriptionRecord | null {
628
+ const db = getDb();
629
+ const row = db
630
+ .prepare("SELECT * FROM push_subscriptions WHERE device_id = ?")
631
+ .get(deviceId) as any;
632
+ if (!row) return null;
633
+ return {
634
+ deviceId: row.device_id,
635
+ endpoint: row.endpoint,
636
+ p256dh: row.p256dh,
637
+ auth: row.auth,
638
+ createdAt: row.created_at,
639
+ };
640
+ }
641
+
642
+ /**
643
+ * List all push subscriptions.
644
+ */
645
+ export function listPushSubscriptions(): PushSubscriptionRecord[] {
646
+ const db = getDb();
647
+ const rows = db
648
+ .prepare("SELECT * FROM push_subscriptions ORDER BY created_at DESC")
649
+ .all() as any[];
650
+ return rows.map((r) => ({
651
+ deviceId: r.device_id,
652
+ endpoint: r.endpoint,
653
+ p256dh: r.p256dh,
654
+ auth: r.auth,
655
+ createdAt: r.created_at,
656
+ }));
657
+ }
658
+
659
+ // ── Workspace: Topics ─────────────────────────────────────────────
660
+
661
+ export interface Topic {
662
+ id: string;
663
+ sessionName: string;
664
+ title: string;
665
+ createdAt: number;
666
+ updatedAt: number;
667
+ }
668
+
669
+ /**
670
+ * Create a new topic (conversation thread) within a session.
671
+ */
672
+ export function createTopic(sessionName: string, title: string): Topic {
673
+ const db = getDb();
674
+ const id = crypto.randomUUID();
675
+ const now = Date.now();
676
+ db.prepare(
677
+ "INSERT INTO topics (id, session_name, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
678
+ ).run(id, sessionName, title, now, now);
679
+ // Index into FTS
680
+ indexEntity("topic", id, title, title);
681
+ return { id, sessionName, title, createdAt: now, updatedAt: now };
682
+ }
683
+
684
+ /**
685
+ * Update a topic's title.
686
+ */
687
+ export function updateTopic(id: string, title: string): boolean {
688
+ const db = getDb();
689
+ const now = Date.now();
690
+ const result = db
691
+ .prepare("UPDATE topics SET title = ?, updated_at = ? WHERE id = ?")
692
+ .run(title, now, id);
693
+ return result.changes > 0;
694
+ }
695
+
696
+ /**
697
+ * List topics, optionally filtered by session name.
698
+ */
699
+ export function listTopics(sessionName?: string): Topic[] {
700
+ const db = getDb();
701
+ let rows: any[];
702
+ if (sessionName) {
703
+ rows = db
704
+ .prepare(
705
+ "SELECT * FROM topics WHERE session_name = ? ORDER BY created_at",
706
+ )
707
+ .all(sessionName) as any[];
708
+ } else {
709
+ rows = db
710
+ .prepare("SELECT * FROM topics ORDER BY created_at")
711
+ .all() as any[];
712
+ }
713
+ return rows.map((r) => ({
714
+ id: r.id,
715
+ sessionName: r.session_name,
716
+ title: r.title,
717
+ createdAt: r.created_at,
718
+ updatedAt: r.updated_at,
719
+ }));
720
+ }
721
+
722
+ /**
723
+ * Delete a topic by ID.
724
+ */
725
+ export function deleteTopic(id: string): boolean {
726
+ const db = getDb();
727
+ const txn = db.transaction(() => {
728
+ const result = db.prepare("DELETE FROM topics WHERE id = ?").run(id);
729
+ if (result.changes > 0) removeFromIndex(id);
730
+ return result.changes > 0;
731
+ });
732
+ return txn();
733
+ }
734
+
735
+ // ── Workspace: Runs ───────────────────────────────────────────────
736
+
737
+ export interface Run {
738
+ id: string;
739
+ topicId: string | null;
740
+ sessionName: string;
741
+ tabId: number | null;
742
+ command: string | null;
743
+ exitCode: number | null;
744
+ startedAt: number;
745
+ endedAt: number | null;
746
+ status: "running" | "completed" | "failed";
747
+ }
748
+
749
+ /**
750
+ * Create a new run (command execution tracked within a topic).
751
+ */
752
+ export function createRun(params: {
753
+ topicId?: string;
754
+ sessionName: string;
755
+ tabId?: number;
756
+ command?: string;
757
+ }): Run {
758
+ const db = getDb();
759
+ const id = crypto.randomUUID();
760
+ const now = Date.now();
761
+ db.prepare(
762
+ `INSERT INTO runs (id, topic_id, session_name, tab_id, command, started_at, status)
763
+ VALUES (?, ?, ?, ?, ?, ?, 'running')`,
764
+ ).run(
765
+ id,
766
+ params.topicId ?? null,
767
+ params.sessionName,
768
+ params.tabId ?? null,
769
+ params.command ?? null,
770
+ now,
771
+ );
772
+ // Index into FTS
773
+ if (params.command) {
774
+ indexEntity("run", id, params.command, params.command);
775
+ }
776
+ return {
777
+ id,
778
+ topicId: params.topicId ?? null,
779
+ sessionName: params.sessionName,
780
+ tabId: params.tabId ?? null,
781
+ command: params.command ?? null,
782
+ exitCode: null,
783
+ startedAt: now,
784
+ endedAt: null,
785
+ status: "running",
786
+ };
787
+ }
788
+
789
+ /**
790
+ * Update a run's exit code, status, and/or end time.
791
+ */
792
+ export function updateRun(
793
+ id: string,
794
+ params: { exitCode?: number; status?: string },
795
+ ): boolean {
796
+ const db = getDb();
797
+ const now = Date.now();
798
+ const sets: string[] = [];
799
+ const values: any[] = [];
800
+
801
+ if (params.exitCode !== undefined) {
802
+ sets.push("exit_code = ?");
803
+ values.push(params.exitCode);
804
+ }
805
+ if (params.status !== undefined) {
806
+ sets.push("status = ?");
807
+ values.push(params.status);
808
+ }
809
+ if (
810
+ params.status === "completed" ||
811
+ params.status === "failed" ||
812
+ params.exitCode !== undefined
813
+ ) {
814
+ sets.push("ended_at = ?");
815
+ values.push(now);
816
+ }
817
+
818
+ if (sets.length === 0) return false;
819
+ values.push(id);
820
+
821
+ const result = db
822
+ .prepare(`UPDATE runs SET ${sets.join(", ")} WHERE id = ?`)
823
+ .run(...values);
824
+ return result.changes > 0;
825
+ }
826
+
827
+ /**
828
+ * List runs, optionally filtered by topic ID and/or session name.
829
+ */
830
+ export function listRuns(topicId?: string, sessionName?: string): Run[] {
831
+ const db = getDb();
832
+ let rows: any[];
833
+ if (topicId && sessionName) {
834
+ rows = db
835
+ .prepare("SELECT * FROM runs WHERE topic_id = ? AND session_name = ? ORDER BY started_at")
836
+ .all(topicId, sessionName) as any[];
837
+ } else if (topicId) {
838
+ rows = db
839
+ .prepare("SELECT * FROM runs WHERE topic_id = ? ORDER BY started_at")
840
+ .all(topicId) as any[];
841
+ } else if (sessionName) {
842
+ rows = db
843
+ .prepare("SELECT * FROM runs WHERE session_name = ? ORDER BY started_at")
844
+ .all(sessionName) as any[];
845
+ } else {
846
+ rows = db
847
+ .prepare("SELECT * FROM runs ORDER BY started_at")
848
+ .all() as any[];
849
+ }
850
+ return rows.map((r) => ({
851
+ id: r.id,
852
+ topicId: r.topic_id,
853
+ sessionName: r.session_name,
854
+ tabId: r.tab_id,
855
+ command: r.command,
856
+ exitCode: r.exit_code,
857
+ startedAt: r.started_at,
858
+ endedAt: r.ended_at,
859
+ status: r.status,
860
+ }));
861
+ }
862
+
863
+ // ── Workspace: Artifacts ──────────────────────────────────────────
864
+
865
+ export interface Artifact {
866
+ id: string;
867
+ runId: string | null;
868
+ topicId: string | null;
869
+ type: "snapshot" | "command-card" | "note";
870
+ title: string | null;
871
+ content: string | null;
872
+ createdAt: number;
873
+ }
874
+
875
+ /**
876
+ * Create an artifact (snapshot, command card, or note).
877
+ */
878
+ export function createArtifact(params: {
879
+ runId?: string;
880
+ topicId?: string;
881
+ sessionName?: string;
882
+ type: string;
883
+ title?: string;
884
+ content?: string;
885
+ }): Artifact {
886
+ const db = getDb();
887
+ const id = crypto.randomUUID();
888
+ const now = Date.now();
889
+ db.prepare(
890
+ `INSERT INTO artifacts (id, run_id, topic_id, session_name, type, title, content, created_at)
891
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
892
+ ).run(
893
+ id,
894
+ params.runId ?? null,
895
+ params.topicId ?? null,
896
+ params.sessionName ?? null,
897
+ params.type,
898
+ params.title ?? null,
899
+ params.content ?? null,
900
+ now,
901
+ );
902
+ // Index into FTS
903
+ const ftsTitle = params.title || params.type;
904
+ const ftsContent = [params.title, params.content].filter(Boolean).join(" ");
905
+ if (ftsContent) {
906
+ indexEntity("artifact", id, ftsTitle, ftsContent);
907
+ }
908
+ return {
909
+ id,
910
+ runId: params.runId ?? null,
911
+ topicId: params.topicId ?? null,
912
+ type: params.type as Artifact["type"],
913
+ title: params.title ?? null,
914
+ content: params.content ?? null,
915
+ createdAt: now,
916
+ };
917
+ }
918
+
919
+ /**
920
+ * List artifacts, optionally filtered by topic, run, or session.
921
+ */
922
+ export function listArtifacts(params: {
923
+ topicId?: string;
924
+ runId?: string;
925
+ sessionName?: string;
926
+ }): Artifact[] {
927
+ const db = getDb();
928
+ let rows: any[];
929
+ if (params.topicId) {
930
+ rows = db
931
+ .prepare(
932
+ "SELECT * FROM artifacts WHERE topic_id = ? ORDER BY created_at",
933
+ )
934
+ .all(params.topicId) as any[];
935
+ } else if (params.runId) {
936
+ rows = db
937
+ .prepare("SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at")
938
+ .all(params.runId) as any[];
939
+ } else if (params.sessionName) {
940
+ rows = db
941
+ .prepare("SELECT * FROM artifacts WHERE session_name = ? ORDER BY created_at")
942
+ .all(params.sessionName) as any[];
943
+ } else {
944
+ rows = db
945
+ .prepare("SELECT * FROM artifacts ORDER BY created_at")
946
+ .all() as any[];
947
+ }
948
+ return rows.map((r) => ({
949
+ id: r.id,
950
+ runId: r.run_id,
951
+ topicId: r.topic_id,
952
+ type: r.type,
953
+ title: r.title,
954
+ content: r.content,
955
+ createdAt: r.created_at,
956
+ }));
957
+ }
958
+
959
+ // ── Workspace: Approvals ──────────────────────────────────────────
960
+
961
+ export interface Approval {
962
+ id: string;
963
+ runId: string | null;
964
+ topicId: string | null;
965
+ title: string;
966
+ description: string | null;
967
+ status: "pending" | "approved" | "rejected";
968
+ createdAt: number;
969
+ resolvedAt: number | null;
970
+ }
971
+
972
+ /**
973
+ * Create an approval request (pending human review).
974
+ */
975
+ export function createApproval(params: {
976
+ runId?: string;
977
+ topicId?: string;
978
+ title: string;
979
+ description?: string;
980
+ }): Approval {
981
+ const db = getDb();
982
+ const id = crypto.randomUUID();
983
+ const now = Date.now();
984
+ db.prepare(
985
+ `INSERT INTO approvals (id, run_id, topic_id, title, description, status, created_at)
986
+ VALUES (?, ?, ?, ?, ?, 'pending', ?)`,
987
+ ).run(
988
+ id,
989
+ params.runId ?? null,
990
+ params.topicId ?? null,
991
+ params.title,
992
+ params.description ?? null,
993
+ now,
994
+ );
995
+ return {
996
+ id,
997
+ runId: params.runId ?? null,
998
+ topicId: params.topicId ?? null,
999
+ title: params.title,
1000
+ description: params.description ?? null,
1001
+ status: "pending",
1002
+ createdAt: now,
1003
+ resolvedAt: null,
1004
+ };
1005
+ }
1006
+
1007
+ /**
1008
+ * List approvals, optionally filtered by status.
1009
+ */
1010
+ export function listApprovals(
1011
+ status?: "pending" | "approved" | "rejected",
1012
+ ): Approval[] {
1013
+ const db = getDb();
1014
+ let rows: any[];
1015
+ if (status) {
1016
+ rows = db
1017
+ .prepare(
1018
+ "SELECT * FROM approvals WHERE status = ? ORDER BY created_at DESC",
1019
+ )
1020
+ .all(status) as any[];
1021
+ } else {
1022
+ rows = db
1023
+ .prepare("SELECT * FROM approvals ORDER BY created_at DESC")
1024
+ .all() as any[];
1025
+ }
1026
+ return rows.map((r) => ({
1027
+ id: r.id,
1028
+ runId: r.run_id,
1029
+ topicId: r.topic_id,
1030
+ title: r.title,
1031
+ description: r.description,
1032
+ status: r.status,
1033
+ createdAt: r.created_at,
1034
+ resolvedAt: r.resolved_at,
1035
+ }));
1036
+ }
1037
+
1038
+ /**
1039
+ * Resolve an approval (approve or reject).
1040
+ */
1041
+ export function resolveApproval(
1042
+ id: string,
1043
+ status: "approved" | "rejected",
1044
+ ): boolean {
1045
+ const db = getDb();
1046
+ const now = Date.now();
1047
+ const result = db
1048
+ .prepare(
1049
+ "UPDATE approvals SET status = ?, resolved_at = ? WHERE id = ?",
1050
+ )
1051
+ .run(status, now, id);
1052
+ return result.changes > 0;
1053
+ }
1054
+
1055
+ // ── FTS5 Full-Text Search ────────────────────────────────────────
1056
+
1057
+ export interface SearchResult {
1058
+ entityType: string;
1059
+ entityId: string;
1060
+ title: string;
1061
+ content: string;
1062
+ rank: number;
1063
+ }
1064
+
1065
+ /**
1066
+ * Index an entity for full-text search.
1067
+ * Replaces existing entry for the same entityId.
1068
+ */
1069
+ export function indexEntity(
1070
+ entityType: string,
1071
+ entityId: string,
1072
+ title: string,
1073
+ content: string,
1074
+ ): void {
1075
+ const db = getDb();
1076
+ // Remove existing entry first (FTS5 doesn't support ON CONFLICT)
1077
+ db.prepare("DELETE FROM fts_index WHERE entity_id = ?").run(entityId);
1078
+ db.prepare(
1079
+ "INSERT INTO fts_index (entity_type, entity_id, title, content) VALUES (?, ?, ?, ?)",
1080
+ ).run(entityType, entityId, title, content);
1081
+ }
1082
+
1083
+ /**
1084
+ * Search indexed entities using FTS5. Returns ranked results.
1085
+ */
1086
+ export function searchEntities(
1087
+ query: string,
1088
+ limit = 20,
1089
+ ): SearchResult[] {
1090
+ const db = getDb();
1091
+ if (!query.trim()) return [];
1092
+ // Sanitize: strip FTS5 operators, escape quotes for phrase search
1093
+ const safeQuery = query
1094
+ .replace(/"/g, '""')
1095
+ .replace(/[*^]/g, "");
1096
+ try {
1097
+ const rows = db
1098
+ .prepare(
1099
+ `SELECT entity_type, entity_id, title, content, rank
1100
+ FROM fts_index WHERE fts_index MATCH ?
1101
+ ORDER BY rank LIMIT ?`,
1102
+ )
1103
+ .all(`"${safeQuery}"`, limit) as any[];
1104
+ return rows.map((r) => ({
1105
+ entityType: r.entity_type,
1106
+ entityId: r.entity_id,
1107
+ title: r.title,
1108
+ content: r.content,
1109
+ rank: r.rank,
1110
+ }));
1111
+ } catch {
1112
+ // FTS query syntax error -- fall back to empty results
1113
+ return [];
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * Remove an entity from the FTS index.
1119
+ */
1120
+ export function removeFromIndex(entityId: string): void {
1121
+ const db = getDb();
1122
+ db.prepare("DELETE FROM fts_index WHERE entity_id = ?").run(entityId);
1123
+ }
1124
+
1125
+ // ── Memory Notes ─────────────────────────────────────────────────
1126
+
1127
+ export interface MemoryNote {
1128
+ id: string;
1129
+ content: string;
1130
+ pinned: boolean;
1131
+ createdAt: number;
1132
+ updatedAt: number;
1133
+ }
1134
+
1135
+ /**
1136
+ * Create a memory note, optionally scoped to a session.
1137
+ */
1138
+ export function createNote(content: string, sessionName?: string): MemoryNote {
1139
+ const db = getDb();
1140
+ const id = crypto.randomUUID();
1141
+ const now = Date.now();
1142
+ db.prepare(
1143
+ "INSERT INTO memory_notes (id, content, pinned, created_at, updated_at, session_name) VALUES (?, ?, 0, ?, ?, ?)",
1144
+ ).run(id, content, now, now, sessionName ?? null);
1145
+ return { id, content, pinned: false, createdAt: now, updatedAt: now };
1146
+ }
1147
+
1148
+ /**
1149
+ * List memory notes (pinned first, then by updated_at desc).
1150
+ * Optionally filtered by sessionName. If sessionName is provided,
1151
+ * returns notes scoped to that session plus global notes (session_name IS NULL).
1152
+ */
1153
+ export function listNotes(sessionName?: string): MemoryNote[] {
1154
+ const db = getDb();
1155
+ let rows: any[];
1156
+ if (sessionName) {
1157
+ rows = db
1158
+ .prepare(
1159
+ "SELECT * FROM memory_notes WHERE session_name = ? OR session_name IS NULL ORDER BY pinned DESC, updated_at DESC",
1160
+ )
1161
+ .all(sessionName) as any[];
1162
+ } else {
1163
+ rows = db
1164
+ .prepare(
1165
+ "SELECT * FROM memory_notes ORDER BY pinned DESC, updated_at DESC",
1166
+ )
1167
+ .all() as any[];
1168
+ }
1169
+ return rows.map((r) => ({
1170
+ id: r.id,
1171
+ content: r.content,
1172
+ pinned: r.pinned === 1,
1173
+ createdAt: r.created_at,
1174
+ updatedAt: r.updated_at,
1175
+ }));
1176
+ }
1177
+
1178
+ /**
1179
+ * Update a memory note's content.
1180
+ */
1181
+ export function updateNote(id: string, content: string): boolean {
1182
+ const db = getDb();
1183
+ const now = Date.now();
1184
+ const result = db
1185
+ .prepare("UPDATE memory_notes SET content = ?, updated_at = ? WHERE id = ?")
1186
+ .run(content, now, id);
1187
+ return result.changes > 0;
1188
+ }
1189
+
1190
+ /**
1191
+ * Delete a memory note.
1192
+ */
1193
+ export function deleteNote(id: string): boolean {
1194
+ const db = getDb();
1195
+ const txn = db.transaction(() => {
1196
+ const result = db.prepare("DELETE FROM memory_notes WHERE id = ?").run(id);
1197
+ if (result.changes > 0) removeFromIndex(id);
1198
+ return result.changes > 0;
1199
+ });
1200
+ return txn();
1201
+ }
1202
+
1203
+ /**
1204
+ * Toggle the pinned state of a memory note.
1205
+ */
1206
+ export function togglePinNote(id: string): boolean {
1207
+ const db = getDb();
1208
+ const result = db
1209
+ .prepare(
1210
+ "UPDATE memory_notes SET pinned = CASE WHEN pinned = 0 THEN 1 ELSE 0 END, updated_at = ? WHERE id = ?",
1211
+ )
1212
+ .run(Date.now(), id);
1213
+ return result.changes > 0;
1214
+ }
1215
+
1216
+ // ── Commands (Shell Integration) ─────────────────────────────────
1217
+
1218
+ export interface CommandRecord {
1219
+ id: string;
1220
+ sessionName: string;
1221
+ tabId: number;
1222
+ command: string | null;
1223
+ exitCode: number | null;
1224
+ cwd: string | null;
1225
+ startedAt: number;
1226
+ endedAt: number | null;
1227
+ }
1228
+
1229
+ /**
1230
+ * Create a command record (shell integration tracking).
1231
+ */
1232
+ export function createCommand(params: {
1233
+ sessionName: string;
1234
+ tabId: number;
1235
+ command?: string;
1236
+ cwd?: string;
1237
+ }): CommandRecord {
1238
+ const db = getDb();
1239
+ const id = crypto.randomUUID();
1240
+ const now = Date.now();
1241
+ db.prepare(
1242
+ `INSERT INTO commands (id, session_name, tab_id, command, cwd, started_at)
1243
+ VALUES (?, ?, ?, ?, ?, ?)`,
1244
+ ).run(id, params.sessionName, params.tabId, params.command ?? null, params.cwd ?? null, now);
1245
+ return {
1246
+ id,
1247
+ sessionName: params.sessionName,
1248
+ tabId: params.tabId,
1249
+ command: params.command ?? null,
1250
+ exitCode: null,
1251
+ cwd: params.cwd ?? null,
1252
+ startedAt: now,
1253
+ endedAt: null,
1254
+ };
1255
+ }
1256
+
1257
+ /**
1258
+ * Complete a command record with exit code.
1259
+ */
1260
+ export function completeCommand(
1261
+ id: string,
1262
+ exitCode: number,
1263
+ ): boolean {
1264
+ const db = getDb();
1265
+ const now = Date.now();
1266
+ const result = db
1267
+ .prepare("UPDATE commands SET exit_code = ?, ended_at = ? WHERE id = ?")
1268
+ .run(exitCode, now, id);
1269
+ return result.changes > 0;
1270
+ }
1271
+
1272
+ /**
1273
+ * List commands for a tab (most recent first).
1274
+ */
1275
+ export function listCommands(
1276
+ tabId: number,
1277
+ limit = 50,
1278
+ ): CommandRecord[] {
1279
+ const db = getDb();
1280
+ const rows = db
1281
+ .prepare(
1282
+ "SELECT * FROM commands WHERE tab_id = ? ORDER BY started_at DESC, rowid DESC LIMIT ?",
1283
+ )
1284
+ .all(tabId, limit) as any[];
1285
+ return rows.map((r) => ({
1286
+ id: r.id,
1287
+ sessionName: r.session_name,
1288
+ tabId: r.tab_id,
1289
+ command: r.command,
1290
+ exitCode: r.exit_code,
1291
+ cwd: r.cwd,
1292
+ startedAt: r.started_at,
1293
+ endedAt: r.ended_at,
1294
+ }));
1295
+ }
1296
+
1297
+ // ── Durable Stream (Step 4) ─────────────────────────────────────
1298
+
1299
+ export interface StreamChunk {
1300
+ tabId: number;
1301
+ seqFrom: number;
1302
+ seqTo: number;
1303
+ createdAt: number;
1304
+ data: Buffer;
1305
+ }
1306
+
1307
+ /**
1308
+ * Save a stream chunk (batch of PTY output data with sequence range).
1309
+ */
1310
+ export function saveStreamChunk(
1311
+ tabId: number,
1312
+ seqFrom: number,
1313
+ seqTo: number,
1314
+ data: Buffer,
1315
+ ): void {
1316
+ const db = getDb();
1317
+ db.prepare(
1318
+ `INSERT OR REPLACE INTO tab_stream_chunks (tab_id, seq_from, seq_to, created_at, data)
1319
+ VALUES (?, ?, ?, ?, ?)`,
1320
+ ).run(tabId, seqFrom, seqTo, Date.now(), data);
1321
+ }
1322
+
1323
+ /**
1324
+ * Get all stream chunks for a tab since a given sequence number.
1325
+ */
1326
+ export function getChunksSince(tabId: number, sinceSeq: number): StreamChunk[] {
1327
+ const db = getDb();
1328
+ const rows = db
1329
+ .prepare(
1330
+ "SELECT * FROM tab_stream_chunks WHERE tab_id = ? AND seq_to > ? ORDER BY seq_from",
1331
+ )
1332
+ .all(tabId, sinceSeq) as any[];
1333
+ return rows.map((r) => ({
1334
+ tabId: r.tab_id,
1335
+ seqFrom: r.seq_from,
1336
+ seqTo: r.seq_to,
1337
+ createdAt: r.created_at,
1338
+ data: r.data,
1339
+ }));
1340
+ }
1341
+
1342
+ /**
1343
+ * Save a VT snapshot at a given sequence point.
1344
+ */
1345
+ export function saveTabSnapshot(
1346
+ tabId: number,
1347
+ seq: number,
1348
+ cols: number,
1349
+ rows: number,
1350
+ snapshot: Buffer | string,
1351
+ ): void {
1352
+ const db = getDb();
1353
+ const data = typeof snapshot === "string" ? Buffer.from(snapshot, "utf8") : snapshot;
1354
+ db.prepare(
1355
+ `INSERT OR REPLACE INTO tab_snapshots (tab_id, seq, cols, rows, snapshot, created_at)
1356
+ VALUES (?, ?, ?, ?, ?, ?)`,
1357
+ ).run(tabId, seq, cols, rows, data, Date.now());
1358
+
1359
+ // Keep at most 100 snapshots per tab; prune oldest
1360
+ db.prepare(
1361
+ `DELETE FROM tab_snapshots WHERE tab_id = ? AND seq NOT IN (
1362
+ SELECT seq FROM tab_snapshots WHERE tab_id = ? ORDER BY seq DESC LIMIT 100
1363
+ )`,
1364
+ ).run(tabId, tabId);
1365
+ }
1366
+
1367
+ /**
1368
+ * Get the latest VT snapshot for a tab.
1369
+ */
1370
+ export function getLatestSnapshot(
1371
+ tabId: number,
1372
+ ): { seq: number; cols: number; rows: number; snapshot: Buffer; createdAt: number } | null {
1373
+ const db = getDb();
1374
+ const row = db
1375
+ .prepare(
1376
+ "SELECT * FROM tab_snapshots WHERE tab_id = ? ORDER BY seq DESC LIMIT 1",
1377
+ )
1378
+ .get(tabId) as any;
1379
+ if (!row) return null;
1380
+ return {
1381
+ seq: row.seq,
1382
+ cols: row.cols,
1383
+ rows: row.rows,
1384
+ snapshot: row.snapshot,
1385
+ createdAt: row.created_at,
1386
+ };
1387
+ }
1388
+
1389
+ /**
1390
+ * Update the device cursor (last acknowledged sequence) for a tab.
1391
+ */
1392
+ export function updateDeviceCursor(
1393
+ deviceId: string,
1394
+ tabId: number,
1395
+ lastAckedSeq: number,
1396
+ ): void {
1397
+ const db = getDb();
1398
+ db.prepare(
1399
+ `INSERT OR REPLACE INTO device_tab_cursors (device_id, tab_id, last_acked_seq, updated_at)
1400
+ VALUES (?, ?, ?, ?)`,
1401
+ ).run(deviceId, tabId, lastAckedSeq, Date.now());
1402
+ }
1403
+
1404
+ /**
1405
+ * Get the device cursor for a tab.
1406
+ */
1407
+ export function getDeviceCursor(
1408
+ deviceId: string,
1409
+ tabId: number,
1410
+ ): { lastAckedSeq: number; updatedAt: number } | null {
1411
+ const db = getDb();
1412
+ const row = db
1413
+ .prepare(
1414
+ "SELECT last_acked_seq, updated_at FROM device_tab_cursors WHERE device_id = ? AND tab_id = ?",
1415
+ )
1416
+ .get(deviceId, tabId) as any;
1417
+ if (!row) return null;
1418
+ return {
1419
+ lastAckedSeq: row.last_acked_seq,
1420
+ updatedAt: row.updated_at,
1421
+ };
1422
+ }