feishu-codex-console 1.0.0-beta.6

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 (113) hide show
  1. package/.env.example +101 -0
  2. package/.feishu-codex-policy.example.json +11 -0
  3. package/.feishu-codex-runbooks.example.json +36 -0
  4. package/CHANGELOG.md +129 -0
  5. package/CODE_OF_CONDUCT.md +7 -0
  6. package/CONTRIBUTING.md +52 -0
  7. package/LICENSE +21 -0
  8. package/README.en.md +81 -0
  9. package/README.md +398 -0
  10. package/ROADMAP.md +40 -0
  11. package/SECURITY.md +53 -0
  12. package/dist/account-quota-card.js +233 -0
  13. package/dist/account-quota.js +125 -0
  14. package/dist/app-server-client.js +281 -0
  15. package/dist/card-session.js +166 -0
  16. package/dist/codex-events.js +1 -0
  17. package/dist/codex-runner.js +875 -0
  18. package/dist/config.js +198 -0
  19. package/dist/confirmation-card.js +135 -0
  20. package/dist/control-card.js +345 -0
  21. package/dist/conversation-turn-session.js +209 -0
  22. package/dist/data-maintenance.js +71 -0
  23. package/dist/device-card.js +460 -0
  24. package/dist/device-health.js +94 -0
  25. package/dist/diagnostics.js +253 -0
  26. package/dist/doctor.js +250 -0
  27. package/dist/fallback-card-session.js +37 -0
  28. package/dist/health-file.js +75 -0
  29. package/dist/index.js +4330 -0
  30. package/dist/lark-cli.js +558 -0
  31. package/dist/lark-retry.js +34 -0
  32. package/dist/maintenance.js +140 -0
  33. package/dist/model-capabilities.js +31 -0
  34. package/dist/onboarding-card.js +312 -0
  35. package/dist/permission-lease.js +22 -0
  36. package/dist/policy.js +506 -0
  37. package/dist/progress.js +267 -0
  38. package/dist/project-card.js +303 -0
  39. package/dist/project-overview-card.js +182 -0
  40. package/dist/project-overview.js +278 -0
  41. package/dist/project-policy.js +160 -0
  42. package/dist/project-registry.js +259 -0
  43. package/dist/project-status.js +45 -0
  44. package/dist/project-workspace.js +55 -0
  45. package/dist/quota-card.js +94 -0
  46. package/dist/recovery-policy.js +26 -0
  47. package/dist/redaction.js +67 -0
  48. package/dist/remote-ready.js +112 -0
  49. package/dist/response-card.js +139 -0
  50. package/dist/result-card.js +166 -0
  51. package/dist/review-card.js +452 -0
  52. package/dist/runbook-card.js +272 -0
  53. package/dist/runbooks.js +191 -0
  54. package/dist/runtime-card.js +337 -0
  55. package/dist/session-card.js +128 -0
  56. package/dist/session-naming.js +14 -0
  57. package/dist/smoke.js +28 -0
  58. package/dist/state-backup.js +302 -0
  59. package/dist/state-store.js +874 -0
  60. package/dist/task-card.js +640 -0
  61. package/dist/task-center-card.js +176 -0
  62. package/dist/task-failure.js +43 -0
  63. package/dist/task-intent.js +76 -0
  64. package/dist/task-queue.js +187 -0
  65. package/dist/task-reconciliation.js +80 -0
  66. package/dist/task-review.js +497 -0
  67. package/dist/team-card.js +275 -0
  68. package/dist/team-directory.js +54 -0
  69. package/dist/team-policy.js +93 -0
  70. package/dist/types.js +1 -0
  71. package/dist/version.js +9 -0
  72. package/dist/workspace-session.js +64 -0
  73. package/docs/ARCHITECTURE.md +54 -0
  74. package/docs/COMPATIBILITY.md +55 -0
  75. package/docs/CONFIGURATION.md +88 -0
  76. package/docs/DEMO.md +45 -0
  77. package/docs/GOOD_FIRST_ISSUES.md +23 -0
  78. package/docs/INSTALLATION.md +207 -0
  79. package/docs/OPEN_SOURCE_PRODUCT_PLAN.md +113 -0
  80. package/docs/PRODUCT_REQUIREMENTS_MAP.md +591 -0
  81. package/docs/RELEASE_CHECKLIST.md +65 -0
  82. package/docs/TEAM_DEPLOYMENT.md +35 -0
  83. package/docs/TROUBLESHOOTING.md +130 -0
  84. package/docs/V4_WORKSPACE_SESSION_FLOW.md +232 -0
  85. package/docs/requirements/D10_MAINTENANCE_AND_ECOSYSTEM.md +103 -0
  86. package/docs/requirements/D1_INSTALLATION_AND_FIRST_CONNECTION.md +479 -0
  87. package/docs/requirements/D2_DEVICE_AND_CONNECTIVITY.md +54 -0
  88. package/docs/requirements/D3_PROJECTS_AND_SESSIONS.md +107 -0
  89. package/docs/requirements/D4_REMOTE_TASK_EXECUTION.md +102 -0
  90. package/docs/requirements/D5_CODEX_NATIVE_INTERACTIONS.md +99 -0
  91. package/docs/requirements/D6_RESULTS_AND_CODE_REVIEW.md +100 -0
  92. package/docs/requirements/D7_SECURITY_GOVERNANCE.md +106 -0
  93. package/docs/requirements/D8_RELIABILITY_AND_RECOVERY.md +182 -0
  94. package/docs/requirements/D9_TEAM_COLLABORATION.md +129 -0
  95. package/package.json +76 -0
  96. package/scripts/capability-probe.mjs +113 -0
  97. package/scripts/cli.mjs +919 -0
  98. package/scripts/config-file.mjs +137 -0
  99. package/scripts/discovery-lib.mjs +78 -0
  100. package/scripts/install-card.mjs +37 -0
  101. package/scripts/install-detection.mjs +126 -0
  102. package/scripts/install-state.mjs +107 -0
  103. package/scripts/launchd.mjs +161 -0
  104. package/scripts/migrate-legacy.mjs +97 -0
  105. package/scripts/package-smoke.mjs +163 -0
  106. package/scripts/release-dist-tag.mjs +7 -0
  107. package/scripts/runbook-template.mjs +36 -0
  108. package/scripts/service-health.mjs +110 -0
  109. package/scripts/service.mjs +24 -0
  110. package/scripts/setup-lib.mjs +118 -0
  111. package/scripts/systemd.mjs +96 -0
  112. package/scripts/upgrade-lib.mjs +99 -0
  113. package/scripts/verify-release.mjs +37 -0
@@ -0,0 +1,874 @@
1
+ import { chmod, mkdir, open, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { createRuntimeBackupFromDatabase, restoreRuntimeBackupFile, } from "./state-backup.js";
5
+ export const PERSISTED_STATE_VERSION = 6;
6
+ export const STATE_SCHEMA_VERSION = 1;
7
+ function freshState() {
8
+ return {
9
+ version: PERSISTED_STATE_VERSION,
10
+ threads: {},
11
+ projects: {},
12
+ chats: {},
13
+ seenEvents: [],
14
+ tasks: {},
15
+ projectCards: {},
16
+ confirmations: {},
17
+ preferences: {},
18
+ device: { remoteReadyEnabled: false, updatedAt: new Date(0).toISOString() },
19
+ };
20
+ }
21
+ function isPersistedState(value) {
22
+ if (!isRecord(value))
23
+ return false;
24
+ return (value.version === 6 &&
25
+ isRecord(value.threads) &&
26
+ isRecord(value.projects) &&
27
+ isRecord(value.chats) &&
28
+ Array.isArray(value.seenEvents) &&
29
+ isRecord(value.tasks) &&
30
+ isRecord(value.projectCards) &&
31
+ isRecord(value.confirmations) &&
32
+ isRecord(value.preferences) &&
33
+ isRecord(value.device) &&
34
+ typeof value.device.remoteReadyEnabled === "boolean");
35
+ }
36
+ function isLegacyV5State(value) {
37
+ if (!isRecord(value))
38
+ return false;
39
+ return (value.version === 5 &&
40
+ isRecord(value.threads) &&
41
+ isRecord(value.projects) &&
42
+ isRecord(value.chats) &&
43
+ Array.isArray(value.seenEvents) &&
44
+ isRecord(value.tasks) &&
45
+ isRecord(value.projectCards) &&
46
+ isRecord(value.confirmations) &&
47
+ isRecord(value.device));
48
+ }
49
+ function isLegacyV4State(value) {
50
+ if (!isRecord(value))
51
+ return false;
52
+ return (value.version === 4 &&
53
+ isRecord(value.threads) &&
54
+ isRecord(value.projects) &&
55
+ Array.isArray(value.seenEvents) &&
56
+ isRecord(value.tasks) &&
57
+ isRecord(value.projectCards));
58
+ }
59
+ function isLegacyV3State(value) {
60
+ if (!isRecord(value))
61
+ return false;
62
+ return (value.version === 3 &&
63
+ isRecord(value.threads) &&
64
+ isRecord(value.projects) &&
65
+ Array.isArray(value.seenEvents) &&
66
+ isRecord(value.tasks) &&
67
+ isRecord(value.projectCards));
68
+ }
69
+ function isLegacyV2State(value) {
70
+ if (!isRecord(value))
71
+ return false;
72
+ return (value.version === 2 &&
73
+ isRecord(value.threads) &&
74
+ isRecord(value.projects) &&
75
+ Array.isArray(value.seenEvents));
76
+ }
77
+ function isLegacyV1State(value) {
78
+ if (!isRecord(value))
79
+ return false;
80
+ return value.version === 1 && isRecord(value.threads) && Array.isArray(value.seenEvents);
81
+ }
82
+ export class StateStore {
83
+ databaseFile;
84
+ maxSeenEvents;
85
+ legacyStateFile;
86
+ state = freshState();
87
+ database = null;
88
+ constructor(databaseFile, maxSeenEvents, legacyStateFile) {
89
+ this.databaseFile = databaseFile;
90
+ this.maxSeenEvents = maxSeenEvents;
91
+ this.legacyStateFile = legacyStateFile;
92
+ }
93
+ async load() {
94
+ if (this.database)
95
+ return;
96
+ await ensurePrivateDatabasePath(this.databaseFile);
97
+ const database = new DatabaseSync(this.databaseFile);
98
+ this.database = database;
99
+ let migrationBackup;
100
+ try {
101
+ database.exec("PRAGMA journal_mode = WAL");
102
+ database.exec("PRAGMA synchronous = FULL");
103
+ database.exec("PRAGMA busy_timeout = 5000");
104
+ const sqliteUserVersion = databaseUserVersion(database);
105
+ if (sqliteUserVersion > STATE_SCHEMA_VERSION) {
106
+ throw new Error(`database schema ${sqliteUserVersion} is newer than supported ${STATE_SCHEMA_VERSION}`);
107
+ }
108
+ const existingSchema = databaseObjectCount(database) > 0;
109
+ const storedStateVersion = databaseStateVersion(database);
110
+ const migrationReasons = [
111
+ ...(sqliteUserVersion < STATE_SCHEMA_VERSION
112
+ ? [`schema-${sqliteUserVersion}-to-${STATE_SCHEMA_VERSION}`]
113
+ : []),
114
+ ...(storedStateVersion !== undefined && storedStateVersion !== PERSISTED_STATE_VERSION
115
+ ? [`state-${storedStateVersion}-to-${PERSISTED_STATE_VERSION}`]
116
+ : []),
117
+ ];
118
+ if (existingSchema && migrationReasons.length > 0) {
119
+ migrationBackup = await createRuntimeBackupFromDatabase(database, this.databaseFile, {
120
+ reason: migrationReasons.join("_"),
121
+ });
122
+ }
123
+ database.exec("BEGIN IMMEDIATE");
124
+ try {
125
+ database.exec(`
126
+ CREATE TABLE IF NOT EXISTS bridge_state (
127
+ id INTEGER PRIMARY KEY CHECK (id = 1),
128
+ version INTEGER NOT NULL,
129
+ payload TEXT NOT NULL,
130
+ updated_at TEXT NOT NULL
131
+ );
132
+ CREATE TABLE IF NOT EXISTS outbox (
133
+ id TEXT PRIMARY KEY,
134
+ kind TEXT NOT NULL,
135
+ payload TEXT NOT NULL,
136
+ attempts INTEGER NOT NULL DEFAULT 0,
137
+ created_at TEXT NOT NULL,
138
+ next_attempt_at TEXT NOT NULL,
139
+ last_error TEXT
140
+ );
141
+ CREATE INDEX IF NOT EXISTS outbox_due_idx ON outbox(next_attempt_at, created_at);
142
+ CREATE TABLE IF NOT EXISTS audit_log (
143
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ occurred_at TEXT NOT NULL,
145
+ actor_id TEXT NOT NULL,
146
+ action TEXT NOT NULL,
147
+ resource_type TEXT NOT NULL,
148
+ resource_id TEXT NOT NULL,
149
+ outcome TEXT NOT NULL,
150
+ detail TEXT
151
+ );
152
+ CREATE INDEX IF NOT EXISTS audit_log_time_idx ON audit_log(occurred_at DESC);
153
+ CREATE TABLE IF NOT EXISTS thread_access (
154
+ thread_id TEXT NOT NULL,
155
+ owner_id TEXT NOT NULL,
156
+ conversation_key TEXT NOT NULL,
157
+ project_path TEXT NOT NULL,
158
+ updated_at TEXT NOT NULL,
159
+ PRIMARY KEY (thread_id, owner_id)
160
+ );
161
+ CREATE INDEX IF NOT EXISTS thread_access_owner_project_idx
162
+ ON thread_access(owner_id, project_path, updated_at DESC);
163
+ CREATE TABLE IF NOT EXISTS onboarding_state (
164
+ conversation_key TEXT PRIMARY KEY,
165
+ owner_id TEXT NOT NULL,
166
+ status TEXT NOT NULL,
167
+ step INTEGER NOT NULL,
168
+ updated_at TEXT NOT NULL
169
+ );
170
+ CREATE TABLE IF NOT EXISTS permission_leases (
171
+ conversation_key TEXT PRIMARY KEY,
172
+ owner_id TEXT NOT NULL,
173
+ project_path TEXT NOT NULL,
174
+ scope TEXT NOT NULL,
175
+ thread_id TEXT,
176
+ remaining_uses INTEGER NOT NULL,
177
+ expires_at TEXT NOT NULL,
178
+ created_at TEXT NOT NULL,
179
+ updated_at TEXT NOT NULL
180
+ );
181
+ CREATE INDEX IF NOT EXISTS permission_leases_expiry_idx
182
+ ON permission_leases(expires_at);
183
+ CREATE TABLE IF NOT EXISTS device_cards (
184
+ message_id TEXT PRIMARY KEY,
185
+ card_id TEXT NOT NULL,
186
+ conversation_key TEXT NOT NULL,
187
+ owner_id TEXT NOT NULL,
188
+ sequence INTEGER NOT NULL,
189
+ created_at INTEGER NOT NULL
190
+ );
191
+ CREATE INDEX IF NOT EXISTS device_cards_created_idx
192
+ ON device_cards(created_at DESC);
193
+ CREATE TABLE IF NOT EXISTS device_recovery_notice (
194
+ conversation_key TEXT PRIMARY KEY,
195
+ notified_at TEXT NOT NULL
196
+ );
197
+ CREATE TABLE IF NOT EXISTS project_usage (
198
+ owner_id TEXT NOT NULL,
199
+ project_path TEXT NOT NULL,
200
+ favorite INTEGER NOT NULL DEFAULT 0,
201
+ last_used_at TEXT,
202
+ use_count INTEGER NOT NULL DEFAULT 0,
203
+ PRIMARY KEY (owner_id, project_path)
204
+ );
205
+ CREATE INDEX IF NOT EXISTS project_usage_recent_idx
206
+ ON project_usage(owner_id, favorite DESC, last_used_at DESC);
207
+ `);
208
+ const permissionLeaseColumns = database
209
+ .prepare("PRAGMA table_info(permission_leases)")
210
+ .all();
211
+ if (!permissionLeaseColumns.some((column) => column.name === "project_path")) {
212
+ // Leases are deliberately ephemeral. An old lease is retained only as an
213
+ // invalid row and will be revoked on its next lookup instead of guessing
214
+ // which project it belonged to.
215
+ database.exec("ALTER TABLE permission_leases ADD COLUMN project_path TEXT NOT NULL DEFAULT ''");
216
+ }
217
+ database.exec(`PRAGMA user_version = ${STATE_SCHEMA_VERSION}`);
218
+ database.exec("COMMIT");
219
+ }
220
+ catch (error) {
221
+ database.exec("ROLLBACK");
222
+ throw error;
223
+ }
224
+ const integrity = database.prepare("PRAGMA quick_check").get();
225
+ if (integrity?.quick_check !== "ok") {
226
+ throw new Error(`database integrity check failed: ${integrity?.quick_check ?? "unknown"}`);
227
+ }
228
+ await secureSqliteFiles(this.databaseFile);
229
+ const row = database
230
+ .prepare("SELECT payload FROM bridge_state WHERE id = 1")
231
+ .get();
232
+ if (row?.payload) {
233
+ this.state = migrateState(JSON.parse(row.payload));
234
+ await this.persist();
235
+ return;
236
+ }
237
+ if (this.legacyStateFile) {
238
+ try {
239
+ const raw = await readFile(this.legacyStateFile, "utf8");
240
+ this.state = migrateState(JSON.parse(raw));
241
+ await writeFile(this.legacyStateFile, `${JSON.stringify(this.state, null, 2)}\n`, {
242
+ encoding: "utf8",
243
+ mode: 0o600,
244
+ });
245
+ await chmod(this.legacyStateFile, 0o600);
246
+ console.info(`[state] migrated legacy JSON state into SQLite: ${this.legacyStateFile}`);
247
+ }
248
+ catch (error) {
249
+ if (error.code !== "ENOENT") {
250
+ throw new Error(`Unable to migrate legacy bridge state: ${error.message}`);
251
+ }
252
+ }
253
+ }
254
+ await this.persist();
255
+ }
256
+ catch (error) {
257
+ database.close();
258
+ this.database = null;
259
+ if (migrationBackup) {
260
+ try {
261
+ await restoreRuntimeBackupFile(migrationBackup.databaseFile, this.databaseFile);
262
+ }
263
+ catch (rollbackError) {
264
+ throw new Error(`Unable to load bridge state: ${error.message}; automatic rollback failed: ${rollbackError.message}`);
265
+ }
266
+ }
267
+ throw new Error(`Unable to load bridge state: ${error.message}`);
268
+ }
269
+ }
270
+ getThread(conversationKey) {
271
+ return this.state.threads[conversationKey]?.threadId;
272
+ }
273
+ get threadCount() {
274
+ return Object.keys(this.state.threads).length;
275
+ }
276
+ getProject(conversationKey) {
277
+ return this.state.projects[conversationKey]?.path;
278
+ }
279
+ getChatType(conversationKey) {
280
+ return this.state.chats[conversationKey]?.type;
281
+ }
282
+ getRemoteReady() {
283
+ return this.state.device.remoteReadyEnabled;
284
+ }
285
+ async setRemoteReady(enabled) {
286
+ if (this.state.device.remoteReadyEnabled === enabled)
287
+ return;
288
+ this.state.device = { remoteReadyEnabled: enabled, updatedAt: new Date().toISOString() };
289
+ await this.persist();
290
+ }
291
+ async setChatType(conversationKey, type) {
292
+ if (this.state.chats[conversationKey]?.type === type)
293
+ return;
294
+ this.state.chats[conversationKey] = { type, updatedAt: new Date().toISOString() };
295
+ await this.persist();
296
+ }
297
+ async setProject(conversationKey, projectPath) {
298
+ const previous = this.state.projects[conversationKey]?.path;
299
+ if (previous === projectPath)
300
+ return { changed: false, threadReset: false };
301
+ const threadReset = conversationKey in this.state.threads;
302
+ this.state.projects[conversationKey] = {
303
+ path: projectPath,
304
+ updatedAt: new Date().toISOString(),
305
+ };
306
+ delete this.state.threads[conversationKey];
307
+ this.revokePermissionLease(conversationKey);
308
+ await this.persist();
309
+ return { changed: true, threadReset };
310
+ }
311
+ async setThread(conversationKey, threadId) {
312
+ const previous = this.state.threads[conversationKey]?.threadId;
313
+ if (previous && previous !== threadId)
314
+ this.revokeSessionPermissionLease(conversationKey);
315
+ this.state.threads[conversationKey] = {
316
+ threadId,
317
+ updatedAt: new Date().toISOString(),
318
+ };
319
+ await this.persist();
320
+ }
321
+ async registerThreadAccess(threadId, conversationKey, ownerId, projectPath) {
322
+ this.requireDatabase()
323
+ .prepare(`INSERT INTO thread_access
324
+ (thread_id, owner_id, conversation_key, project_path, updated_at)
325
+ VALUES (?, ?, ?, ?, ?)
326
+ ON CONFLICT(thread_id, owner_id) DO UPDATE SET
327
+ conversation_key = excluded.conversation_key,
328
+ project_path = excluded.project_path,
329
+ updated_at = excluded.updated_at`)
330
+ .run(threadId, ownerId, conversationKey, projectPath, new Date().toISOString());
331
+ }
332
+ listOwnedThreadIds(ownerId, projectPath) {
333
+ const rows = this.requireDatabase()
334
+ .prepare(`SELECT thread_id FROM thread_access
335
+ WHERE owner_id = ? AND project_path = ?
336
+ ORDER BY updated_at DESC`)
337
+ .all(ownerId, projectPath);
338
+ return new Set(rows.map((row) => row.thread_id));
339
+ }
340
+ getOnboarding(conversationKey) {
341
+ const row = this.requireDatabase()
342
+ .prepare(`SELECT owner_id, status, step, updated_at
343
+ FROM onboarding_state WHERE conversation_key = ?`)
344
+ .get(conversationKey);
345
+ if (!row || !["active", "completed", "dismissed"].includes(row.status))
346
+ return undefined;
347
+ const step = row.step === 2 ? 2 : row.step === 3 ? 3 : row.step === 4 ? 4 : 1;
348
+ return {
349
+ ownerId: row.owner_id,
350
+ status: row.status,
351
+ step,
352
+ updatedAt: row.updated_at,
353
+ };
354
+ }
355
+ async setOnboarding(conversationKey, ownerId, status, step) {
356
+ const updatedAt = new Date().toISOString();
357
+ this.requireDatabase()
358
+ .prepare(`INSERT INTO onboarding_state
359
+ (conversation_key, owner_id, status, step, updated_at)
360
+ VALUES (?, ?, ?, ?, ?)
361
+ ON CONFLICT(conversation_key) DO UPDATE SET
362
+ owner_id = excluded.owner_id,
363
+ status = excluded.status,
364
+ step = excluded.step,
365
+ updated_at = excluded.updated_at`)
366
+ .run(conversationKey, ownerId, status, step, updatedAt);
367
+ return { ownerId, status, step, updatedAt };
368
+ }
369
+ async resetThread(conversationKey) {
370
+ const existed = conversationKey in this.state.threads;
371
+ delete this.state.threads[conversationKey];
372
+ this.revokeSessionPermissionLease(conversationKey);
373
+ if (existed)
374
+ await this.persist();
375
+ return existed;
376
+ }
377
+ getPreferences(conversationKey) {
378
+ const preferences = this.state.preferences[conversationKey];
379
+ return preferences ? structuredClone(preferences) : undefined;
380
+ }
381
+ async setPreferences(conversationKey, preferences) {
382
+ const saved = {
383
+ ...preferences,
384
+ updatedAt: new Date().toISOString(),
385
+ };
386
+ this.state.preferences[conversationKey] = saved;
387
+ await this.persist();
388
+ return structuredClone(saved);
389
+ }
390
+ getPermissionLease(conversationKey, ownerId, now = Date.now()) {
391
+ const row = this.requireDatabase()
392
+ .prepare(`SELECT conversation_key, owner_id, project_path, scope, thread_id, remaining_uses,
393
+ expires_at, created_at, updated_at
394
+ FROM permission_leases WHERE conversation_key = ?`)
395
+ .get(conversationKey);
396
+ if (!row)
397
+ return undefined;
398
+ if (row.owner_id !== ownerId ||
399
+ !isPermissionLeaseScope(row.scope) ||
400
+ row.remaining_uses <= 0 ||
401
+ Date.parse(row.expires_at) <= now) {
402
+ this.requireDatabase()
403
+ .prepare("DELETE FROM permission_leases WHERE conversation_key = ?")
404
+ .run(conversationKey);
405
+ return undefined;
406
+ }
407
+ return permissionLeaseFromRow(row);
408
+ }
409
+ async setPermissionLease(lease) {
410
+ const now = new Date().toISOString();
411
+ const saved = {
412
+ ...lease,
413
+ createdAt: now,
414
+ updatedAt: now,
415
+ };
416
+ this.requireDatabase()
417
+ .prepare(`INSERT INTO permission_leases
418
+ (conversation_key, owner_id, project_path, scope, thread_id, remaining_uses,
419
+ expires_at, created_at, updated_at)
420
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
421
+ ON CONFLICT(conversation_key) DO UPDATE SET
422
+ owner_id = excluded.owner_id,
423
+ project_path = excluded.project_path,
424
+ scope = excluded.scope,
425
+ thread_id = excluded.thread_id,
426
+ remaining_uses = excluded.remaining_uses,
427
+ expires_at = excluded.expires_at,
428
+ created_at = excluded.created_at,
429
+ updated_at = excluded.updated_at`)
430
+ .run(saved.conversationKey, saved.ownerId, saved.projectPath, saved.scope, saved.threadId ?? null, saved.remainingUses, saved.expiresAt, saved.createdAt, saved.updatedAt);
431
+ return saved;
432
+ }
433
+ consumePermissionLease(conversationKey, ownerId, projectPath, threadId, now = Date.now()) {
434
+ const lease = this.getPermissionLease(conversationKey, ownerId, now);
435
+ if (!lease)
436
+ return undefined;
437
+ if (lease.projectPath !== projectPath) {
438
+ this.revokePermissionLease(conversationKey);
439
+ return undefined;
440
+ }
441
+ if (lease.scope === "session" && (!threadId || lease.threadId !== threadId)) {
442
+ this.revokePermissionLease(conversationKey);
443
+ return undefined;
444
+ }
445
+ if (lease.scope === "next-task") {
446
+ this.revokePermissionLease(conversationKey);
447
+ }
448
+ return lease;
449
+ }
450
+ revokePermissionLease(conversationKey) {
451
+ const result = this.requireDatabase()
452
+ .prepare("DELETE FROM permission_leases WHERE conversation_key = ?")
453
+ .run(conversationKey);
454
+ return Number(result.changes) > 0;
455
+ }
456
+ revokeSessionPermissionLease(conversationKey) {
457
+ this.requireDatabase()
458
+ .prepare("DELETE FROM permission_leases WHERE conversation_key = ? AND scope = 'session'")
459
+ .run(conversationKey);
460
+ }
461
+ async markEventIfNew(eventId) {
462
+ if (this.state.seenEvents.some((event) => event.id === eventId))
463
+ return false;
464
+ this.state.seenEvents.push({ id: eventId, seenAt: new Date().toISOString() });
465
+ if (this.state.seenEvents.length > this.maxSeenEvents) {
466
+ this.state.seenEvents.splice(0, this.state.seenEvents.length - this.maxSeenEvents);
467
+ }
468
+ await this.persist();
469
+ return true;
470
+ }
471
+ hasSeenEvent(eventId) {
472
+ return this.state.seenEvents.some((event) => event.id === eventId);
473
+ }
474
+ listTasks() {
475
+ return Object.values(this.state.tasks).map((task) => structuredClone(task));
476
+ }
477
+ async upsertTask(task) {
478
+ this.state.tasks[task.id] = structuredClone(task);
479
+ const completed = Object.values(this.state.tasks)
480
+ .filter((candidate) => candidate.status !== "queued" && candidate.status !== "running")
481
+ .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
482
+ while (Object.keys(this.state.tasks).length > 100 && completed.length > 0) {
483
+ const oldest = completed.shift();
484
+ if (oldest)
485
+ delete this.state.tasks[oldest.id];
486
+ }
487
+ await this.persist();
488
+ }
489
+ async removeTask(taskId) {
490
+ if (!(taskId in this.state.tasks))
491
+ return;
492
+ delete this.state.tasks[taskId];
493
+ await this.persist();
494
+ }
495
+ listProjectCards() {
496
+ return Object.values(this.state.projectCards).map((card) => structuredClone(card));
497
+ }
498
+ async upsertProjectCard(card) {
499
+ this.state.projectCards[card.messageId] = structuredClone(card);
500
+ const cards = Object.values(this.state.projectCards).sort((left, right) => left.createdAt - right.createdAt);
501
+ while (cards.length > 50) {
502
+ const oldest = cards.shift();
503
+ if (oldest)
504
+ delete this.state.projectCards[oldest.messageId];
505
+ }
506
+ await this.persist();
507
+ }
508
+ async removeProjectCard(messageId) {
509
+ if (!(messageId in this.state.projectCards))
510
+ return;
511
+ delete this.state.projectCards[messageId];
512
+ await this.persist();
513
+ }
514
+ listDeviceCards() {
515
+ const rows = this.requireDatabase()
516
+ .prepare(`SELECT message_id, card_id, conversation_key, owner_id, sequence, created_at
517
+ FROM device_cards ORDER BY created_at DESC`)
518
+ .all();
519
+ return rows.map((row) => ({
520
+ messageId: String(row.message_id),
521
+ cardId: String(row.card_id),
522
+ conversationKey: String(row.conversation_key),
523
+ ownerId: String(row.owner_id),
524
+ sequence: Number(row.sequence),
525
+ createdAt: Number(row.created_at),
526
+ }));
527
+ }
528
+ async upsertDeviceCard(card) {
529
+ this.requireDatabase()
530
+ .prepare(`INSERT INTO device_cards
531
+ (message_id, card_id, conversation_key, owner_id, sequence, created_at)
532
+ VALUES (?, ?, ?, ?, ?, ?)
533
+ ON CONFLICT(message_id) DO UPDATE SET
534
+ card_id = excluded.card_id,
535
+ conversation_key = excluded.conversation_key,
536
+ owner_id = excluded.owner_id,
537
+ sequence = excluded.sequence,
538
+ created_at = excluded.created_at`)
539
+ .run(card.messageId, card.cardId, card.conversationKey, card.ownerId, card.sequence, card.createdAt);
540
+ const overflow = this.listDeviceCards().slice(30);
541
+ for (const stale of overflow)
542
+ await this.removeDeviceCard(stale.messageId);
543
+ }
544
+ async removeDeviceCard(messageId) {
545
+ this.requireDatabase().prepare("DELETE FROM device_cards WHERE message_id = ?").run(messageId);
546
+ }
547
+ getLastDeviceRecoveryNotice(conversationKey) {
548
+ const row = this.requireDatabase()
549
+ .prepare("SELECT notified_at FROM device_recovery_notice WHERE conversation_key = ?")
550
+ .get(conversationKey);
551
+ return row?.notified_at;
552
+ }
553
+ async setLastDeviceRecoveryNotice(conversationKey, notifiedAt) {
554
+ this.requireDatabase()
555
+ .prepare(`INSERT INTO device_recovery_notice (conversation_key, notified_at)
556
+ VALUES (?, ?)
557
+ ON CONFLICT(conversation_key) DO UPDATE SET notified_at = excluded.notified_at`)
558
+ .run(conversationKey, notifiedAt);
559
+ }
560
+ listProjectUsage(ownerId) {
561
+ const rows = this.requireDatabase()
562
+ .prepare(`SELECT owner_id, project_path, favorite, last_used_at, use_count
563
+ FROM project_usage WHERE owner_id = ?
564
+ ORDER BY favorite DESC, last_used_at DESC`)
565
+ .all(ownerId);
566
+ return rows.map((row) => ({
567
+ ownerId: String(row.owner_id),
568
+ projectPath: String(row.project_path),
569
+ favorite: Number(row.favorite) === 1,
570
+ ...(typeof row.last_used_at === "string" ? { lastUsedAt: row.last_used_at } : {}),
571
+ useCount: Number(row.use_count),
572
+ }));
573
+ }
574
+ async recordProjectUse(ownerId, projectPath, usedAt = new Date().toISOString()) {
575
+ this.requireDatabase()
576
+ .prepare(`INSERT INTO project_usage
577
+ (owner_id, project_path, favorite, last_used_at, use_count)
578
+ VALUES (?, ?, 0, ?, 1)
579
+ ON CONFLICT(owner_id, project_path) DO UPDATE SET
580
+ last_used_at = excluded.last_used_at,
581
+ use_count = project_usage.use_count + 1`)
582
+ .run(ownerId, projectPath, usedAt);
583
+ }
584
+ async toggleProjectFavorite(ownerId, projectPath) {
585
+ this.requireDatabase()
586
+ .prepare(`INSERT INTO project_usage
587
+ (owner_id, project_path, favorite, last_used_at, use_count)
588
+ VALUES (?, ?, 1, NULL, 0)
589
+ ON CONFLICT(owner_id, project_path) DO UPDATE SET
590
+ favorite = CASE project_usage.favorite WHEN 1 THEN 0 ELSE 1 END`)
591
+ .run(ownerId, projectPath);
592
+ const row = this.requireDatabase()
593
+ .prepare("SELECT favorite FROM project_usage WHERE owner_id = ? AND project_path = ?")
594
+ .get(ownerId, projectPath);
595
+ return row?.favorite === 1;
596
+ }
597
+ listConfirmations() {
598
+ return Object.values(this.state.confirmations).map((confirmation) => structuredClone(confirmation));
599
+ }
600
+ async upsertConfirmation(confirmation) {
601
+ this.state.confirmations[confirmation.id] = structuredClone(confirmation);
602
+ await this.persist();
603
+ }
604
+ async removeConfirmation(confirmationId) {
605
+ if (!(confirmationId in this.state.confirmations))
606
+ return;
607
+ delete this.state.confirmations[confirmationId];
608
+ await this.persist();
609
+ }
610
+ async enqueueOutbox(item) {
611
+ this.requireDatabase()
612
+ .prepare(`INSERT OR IGNORE INTO outbox
613
+ (id, kind, payload, attempts, created_at, next_attempt_at, last_error)
614
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
615
+ .run(item.id, item.kind, JSON.stringify(item.payload), item.attempts, item.createdAt, item.nextAttemptAt, item.lastError ?? null);
616
+ }
617
+ listDueOutbox(now = new Date().toISOString(), limit = 50) {
618
+ const rows = this.requireDatabase()
619
+ .prepare(`SELECT id, kind, payload, attempts, created_at, next_attempt_at, last_error
620
+ FROM outbox WHERE next_attempt_at <= ? ORDER BY created_at LIMIT ?`)
621
+ .all(now, limit);
622
+ return rows.flatMap((row) => {
623
+ if (row.kind !== "reply_text" || typeof row.payload !== "string")
624
+ return [];
625
+ try {
626
+ const payload = JSON.parse(row.payload);
627
+ return [
628
+ {
629
+ id: String(row.id),
630
+ kind: "reply_text",
631
+ payload,
632
+ attempts: Number(row.attempts),
633
+ createdAt: String(row.created_at),
634
+ nextAttemptAt: String(row.next_attempt_at),
635
+ ...(typeof row.last_error === "string" ? { lastError: row.last_error } : {}),
636
+ },
637
+ ];
638
+ }
639
+ catch {
640
+ return [];
641
+ }
642
+ });
643
+ }
644
+ async markOutboxSent(id) {
645
+ this.requireDatabase().prepare("DELETE FROM outbox WHERE id = ?").run(id);
646
+ }
647
+ async markOutboxFailed(id, error) {
648
+ const row = this.requireDatabase()
649
+ .prepare("SELECT attempts FROM outbox WHERE id = ?")
650
+ .get(id);
651
+ if (!row)
652
+ return;
653
+ const attempts = (row.attempts ?? 0) + 1;
654
+ const delayMs = Math.min(60_000, 1_000 * 2 ** Math.min(attempts, 6));
655
+ this.requireDatabase()
656
+ .prepare("UPDATE outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ?")
657
+ .run(attempts, new Date(Date.now() + delayMs).toISOString(), error.slice(0, 1_000), id);
658
+ }
659
+ async appendAudit(event) {
660
+ this.requireDatabase()
661
+ .prepare(`INSERT INTO audit_log
662
+ (occurred_at, actor_id, action, resource_type, resource_id, outcome, detail)
663
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
664
+ .run(event.occurredAt, event.actorId, event.action.slice(0, 120), event.resourceType, event.resourceId.slice(0, 500), event.outcome, event.detail?.slice(0, 2_000) ?? null);
665
+ this.requireDatabase().exec("DELETE FROM audit_log WHERE id NOT IN (SELECT id FROM audit_log ORDER BY id DESC LIMIT 5000)");
666
+ }
667
+ listAudit(limit = 20, actorId) {
668
+ const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit)));
669
+ const rows = actorId
670
+ ? this.requireDatabase()
671
+ .prepare(`SELECT id, occurred_at, actor_id, action, resource_type, resource_id, outcome, detail
672
+ FROM audit_log WHERE actor_id = ? ORDER BY id DESC LIMIT ?`)
673
+ .all(actorId, safeLimit)
674
+ : this.requireDatabase()
675
+ .prepare(`SELECT id, occurred_at, actor_id, action, resource_type, resource_id, outcome, detail
676
+ FROM audit_log ORDER BY id DESC LIMIT ?`)
677
+ .all(safeLimit);
678
+ return rows.map((row) => ({
679
+ id: Number(row.id),
680
+ occurredAt: String(row.occurred_at),
681
+ actorId: String(row.actor_id),
682
+ action: String(row.action),
683
+ resourceType: String(row.resource_type),
684
+ resourceId: String(row.resource_id),
685
+ outcome: String(row.outcome),
686
+ ...(typeof row.detail === "string" ? { detail: row.detail } : {}),
687
+ }));
688
+ }
689
+ async flush() {
690
+ if (!this.database)
691
+ return;
692
+ this.database.exec("PRAGMA wal_checkpoint(PASSIVE)");
693
+ }
694
+ close() {
695
+ if (!this.database)
696
+ return;
697
+ this.database.close();
698
+ this.database = null;
699
+ }
700
+ async persist() {
701
+ const database = this.requireDatabase();
702
+ database.exec("BEGIN IMMEDIATE");
703
+ try {
704
+ database
705
+ .prepare(`INSERT INTO bridge_state (id, version, payload, updated_at)
706
+ VALUES (1, ?, ?, ?)
707
+ ON CONFLICT(id) DO UPDATE SET
708
+ version = excluded.version,
709
+ payload = excluded.payload,
710
+ updated_at = excluded.updated_at`)
711
+ .run(this.state.version, JSON.stringify(this.state), new Date().toISOString());
712
+ database.exec("COMMIT");
713
+ }
714
+ catch (error) {
715
+ database.exec("ROLLBACK");
716
+ throw error;
717
+ }
718
+ }
719
+ requireDatabase() {
720
+ if (!this.database)
721
+ throw new Error("StateStore.load() must be called first");
722
+ return this.database;
723
+ }
724
+ }
725
+ async function ensurePrivateDatabasePath(databaseFile) {
726
+ if (databaseFile === ":memory:")
727
+ return;
728
+ const directory = path.dirname(databaseFile);
729
+ await mkdir(directory, { recursive: true, mode: 0o700 });
730
+ const handle = await open(databaseFile, "a", 0o600);
731
+ await handle.close();
732
+ await chmod(databaseFile, 0o600);
733
+ }
734
+ async function secureSqliteFiles(databaseFile) {
735
+ if (databaseFile === ":memory:")
736
+ return;
737
+ await Promise.all([databaseFile, `${databaseFile}-wal`, `${databaseFile}-shm`].map(async (file) => {
738
+ try {
739
+ await chmod(file, 0o600);
740
+ }
741
+ catch (error) {
742
+ if (error.code !== "ENOENT")
743
+ throw error;
744
+ }
745
+ }));
746
+ }
747
+ function databaseUserVersion(database) {
748
+ const row = database.prepare("PRAGMA user_version").get();
749
+ return Number(row?.user_version ?? 0);
750
+ }
751
+ function databaseObjectCount(database) {
752
+ const row = database
753
+ .prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'")
754
+ .get();
755
+ return Number(row?.count ?? 0);
756
+ }
757
+ function databaseStateVersion(database) {
758
+ try {
759
+ const row = database.prepare("SELECT version FROM bridge_state WHERE id = 1").get();
760
+ return typeof row?.version === "number" ? row.version : undefined;
761
+ }
762
+ catch {
763
+ return undefined;
764
+ }
765
+ }
766
+ function migrateState(value) {
767
+ if (isPersistedState(value)) {
768
+ return {
769
+ ...value,
770
+ tasks: sanitizeTasks(value.tasks),
771
+ projectCards: sanitizeProjectCards(value.projectCards),
772
+ confirmations: sanitizeConfirmations(value.confirmations),
773
+ };
774
+ }
775
+ if (isLegacyV5State(value)) {
776
+ return {
777
+ version: 6,
778
+ threads: value.threads,
779
+ projects: value.projects,
780
+ chats: value.chats,
781
+ seenEvents: value.seenEvents,
782
+ tasks: sanitizeTasks(value.tasks),
783
+ projectCards: sanitizeProjectCards(value.projectCards),
784
+ confirmations: sanitizeConfirmations(value.confirmations),
785
+ preferences: {},
786
+ device: value.device,
787
+ };
788
+ }
789
+ if (isLegacyV4State(value)) {
790
+ return {
791
+ version: 6,
792
+ threads: value.threads,
793
+ projects: value.projects,
794
+ chats: value.chats ?? {},
795
+ seenEvents: value.seenEvents,
796
+ tasks: sanitizeTasks(value.tasks),
797
+ projectCards: sanitizeProjectCards(value.projectCards),
798
+ confirmations: sanitizeConfirmations(value.confirmations ?? {}),
799
+ preferences: {},
800
+ device: { remoteReadyEnabled: false, updatedAt: new Date().toISOString() },
801
+ };
802
+ }
803
+ if (isLegacyV3State(value)) {
804
+ return {
805
+ version: 6,
806
+ threads: value.threads,
807
+ projects: value.projects,
808
+ chats: value.chats ?? {},
809
+ seenEvents: value.seenEvents,
810
+ tasks: sanitizeTasks(value.tasks),
811
+ projectCards: sanitizeProjectCards(value.projectCards),
812
+ confirmations: {},
813
+ preferences: {},
814
+ device: { remoteReadyEnabled: false, updatedAt: new Date().toISOString() },
815
+ };
816
+ }
817
+ if (isLegacyV2State(value)) {
818
+ return {
819
+ ...freshState(),
820
+ threads: value.threads,
821
+ projects: value.projects,
822
+ seenEvents: value.seenEvents,
823
+ };
824
+ }
825
+ if (isLegacyV1State(value)) {
826
+ return { ...freshState(), threads: value.threads, seenEvents: value.seenEvents };
827
+ }
828
+ throw new Error("unsupported state shape");
829
+ }
830
+ function sanitizeTasks(tasks) {
831
+ return Object.fromEntries(Object.entries(tasks).map(([id, task]) => [
832
+ id,
833
+ {
834
+ ...task,
835
+ ownerId: task.ownerId || "legacy",
836
+ controllerId: task.controllerId || task.ownerId || "legacy",
837
+ settings: task.settings ?? { sandboxMode: "workspace-write" },
838
+ attachments: task.attachments.map((attachment) => ({
839
+ kind: attachment.kind,
840
+ path: attachment.path,
841
+ name: attachment.name,
842
+ sizeBytes: attachment.sizeBytes,
843
+ })),
844
+ },
845
+ ]));
846
+ }
847
+ function sanitizeProjectCards(cards) {
848
+ return Object.fromEntries(Object.entries(cards).map(([id, card]) => [id, { ...card, ownerId: card.ownerId || "legacy" }]));
849
+ }
850
+ function sanitizeConfirmations(confirmations) {
851
+ return Object.fromEntries(Object.entries(confirmations).map(([id, confirmation]) => [
852
+ id,
853
+ { ...confirmation, ownerId: confirmation.ownerId || "legacy" },
854
+ ]));
855
+ }
856
+ function isPermissionLeaseScope(value) {
857
+ return value === "next-task" || value === "timed" || value === "session";
858
+ }
859
+ function permissionLeaseFromRow(row) {
860
+ return {
861
+ conversationKey: row.conversation_key,
862
+ ownerId: row.owner_id,
863
+ projectPath: row.project_path,
864
+ scope: row.scope,
865
+ remainingUses: row.remaining_uses,
866
+ expiresAt: row.expires_at,
867
+ createdAt: row.created_at,
868
+ updatedAt: row.updated_at,
869
+ ...(row.thread_id ? { threadId: row.thread_id } : {}),
870
+ };
871
+ }
872
+ function isRecord(value) {
873
+ return typeof value === "object" && value !== null && !Array.isArray(value);
874
+ }