bertrand 0.17.0 → 0.18.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.
package/dist/bertrand.js CHANGED
@@ -25,12 +25,173 @@ var init_paths = __esm(() => {
25
25
  root: join(homedir(), BERTRAND_DIR),
26
26
  db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
27
27
  hooks: join(homedir(), BERTRAND_DIR, "hooks"),
28
- sessions: join(homedir(), BERTRAND_DIR, "sessions")
28
+ sessions: join(homedir(), BERTRAND_DIR, "sessions"),
29
+ syncEnv: join(homedir(), BERTRAND_DIR, "sync.env")
29
30
  };
30
31
  });
31
32
 
33
+ // src/sync/config.ts
34
+ import {
35
+ readFileSync,
36
+ statSync,
37
+ writeFileSync,
38
+ chmodSync,
39
+ existsSync
40
+ } from "fs";
41
+ function parseEnv(contents) {
42
+ const out = {};
43
+ for (const rawLine of contents.split(/\r?\n/)) {
44
+ const line = rawLine.trim();
45
+ if (!line || line.startsWith("#"))
46
+ continue;
47
+ const eq = line.indexOf("=");
48
+ if (eq === -1)
49
+ continue;
50
+ const key = line.slice(0, eq).trim();
51
+ if (!KEYS.includes(key))
52
+ continue;
53
+ let value = line.slice(eq + 1).trim();
54
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
55
+ value = value.slice(1, -1);
56
+ }
57
+ out[key] = value;
58
+ }
59
+ return out;
60
+ }
61
+ function hasSyncConfig() {
62
+ return existsSync(paths.syncEnv);
63
+ }
64
+ function loadSyncConfig() {
65
+ if (!existsSync(paths.syncEnv))
66
+ return null;
67
+ const mode = statSync(paths.syncEnv).mode & 511;
68
+ if (mode & 63) {
69
+ console.warn(`warning: ${paths.syncEnv} was mode 0${mode.toString(8)} (should be 0600). Tightening to 0600.`);
70
+ try {
71
+ chmodSync(paths.syncEnv, 384);
72
+ } catch (e) {
73
+ console.warn(`failed to chmod 0600: ${e instanceof Error ? e.message : String(e)}. Fix manually.`);
74
+ }
75
+ }
76
+ const env = parseEnv(readFileSync(paths.syncEnv, "utf8"));
77
+ if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_KEY || !env.BERTRAND_SYNC_BUCKET || !env.BERTRAND_ENCRYPTION_KEY) {
78
+ return null;
79
+ }
80
+ return {
81
+ supabaseUrl: env.SUPABASE_URL,
82
+ supabaseServiceKey: env.SUPABASE_SERVICE_KEY,
83
+ bucket: env.BERTRAND_SYNC_BUCKET,
84
+ objectKey: env.BERTRAND_SYNC_OBJECT || "bertrand.db.enc",
85
+ encryptionKey: env.BERTRAND_ENCRYPTION_KEY,
86
+ clientName: env.BERTRAND_CLIENT_NAME || `bertrand-${process.platform}-${process.pid}`
87
+ };
88
+ }
89
+ function saveSyncConfig(cfg) {
90
+ const lines = [
91
+ "# bertrand sync configuration",
92
+ "# Created by `bertrand sync onboard`. chmod 600.",
93
+ "#",
94
+ "# SUPABASE_SERVICE_KEY has read+write access to your storage bucket.",
95
+ "# Treat it like an SSH private key. Do not commit. Do not share.",
96
+ "#",
97
+ "# BERTRAND_ENCRYPTION_KEY is the AES-256-GCM key used to encrypt the DB",
98
+ "# locally before upload. Without it the uploaded blob can't be decrypted.",
99
+ "# Use the SAME key on every machine that should be able to pull this DB.",
100
+ `SUPABASE_URL=${cfg.supabaseUrl}`,
101
+ `SUPABASE_SERVICE_KEY=${cfg.supabaseServiceKey}`,
102
+ `BERTRAND_SYNC_BUCKET=${cfg.bucket}`,
103
+ `BERTRAND_SYNC_OBJECT=${cfg.objectKey}`,
104
+ `BERTRAND_ENCRYPTION_KEY=${cfg.encryptionKey}`,
105
+ `BERTRAND_CLIENT_NAME=${cfg.clientName}`,
106
+ ""
107
+ ];
108
+ writeFileSync(paths.syncEnv, lines.join(`
109
+ `), { mode: 384 });
110
+ chmodSync(paths.syncEnv, 384);
111
+ }
112
+ var KEYS;
113
+ var init_config = __esm(() => {
114
+ init_paths();
115
+ KEYS = [
116
+ "SUPABASE_URL",
117
+ "SUPABASE_SERVICE_KEY",
118
+ "BERTRAND_SYNC_BUCKET",
119
+ "BERTRAND_SYNC_OBJECT",
120
+ "BERTRAND_ENCRYPTION_KEY",
121
+ "BERTRAND_CLIENT_NAME"
122
+ ];
123
+ });
124
+
125
+ // src/lib/config.ts
126
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
127
+ import { join as join2 } from "path";
128
+ function readConfig() {
129
+ try {
130
+ return JSON.parse(readFileSync2(CONFIG_PATH, "utf-8"));
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+ function writeConfig(config) {
136
+ writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2) + `
137
+ `);
138
+ }
139
+ function patchConfig(patch) {
140
+ const current = readConfig() ?? {};
141
+ const next = deepMerge(current, patch);
142
+ writeConfig(next);
143
+ return next;
144
+ }
145
+ function deepMerge(base, patch) {
146
+ const out = { ...base };
147
+ for (const [k, v] of Object.entries(patch)) {
148
+ const existing = out[k];
149
+ if (isPlainObject(existing) && isPlainObject(v)) {
150
+ out[k] = deepMerge(existing, v);
151
+ } else if (v !== undefined) {
152
+ out[k] = v;
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+ function isPlainObject(v) {
158
+ return typeof v === "object" && v !== null && !Array.isArray(v);
159
+ }
160
+ function isSyncEnabled() {
161
+ return readConfig()?.sync?.enabled === true;
162
+ }
163
+ var CONFIG_PATH;
164
+ var init_config2 = __esm(() => {
165
+ init_paths();
166
+ CONFIG_PATH = join2(paths.root, "config.json");
167
+ });
168
+
169
+ // src/sync/trigger.ts
170
+ import { spawn } from "child_process";
171
+ function spawnSync(op) {
172
+ if (!isSyncEnabled() || !hasSyncConfig())
173
+ return;
174
+ const bin = process.argv[1] && process.argv[1].endsWith(".ts") ? process.execPath : "bertrand";
175
+ const args = process.argv[1] && process.argv[1].endsWith(".ts") ? ["run", process.argv[1], "sync", op] : ["sync", op];
176
+ const child = spawn(bin, args, {
177
+ detached: true,
178
+ stdio: "ignore"
179
+ });
180
+ child.unref();
181
+ }
182
+ function triggerBackgroundPull() {
183
+ spawnSync("pull");
184
+ }
185
+ function triggerBackgroundPush() {
186
+ spawnSync("push");
187
+ }
188
+ var init_trigger = __esm(() => {
189
+ init_config();
190
+ init_config2();
191
+ });
192
+
32
193
  // src/cli/router.ts
33
- import { existsSync } from "fs";
194
+ import { existsSync as existsSync2 } from "fs";
34
195
  function register(name, handler) {
35
196
  commands.set(name, handler);
36
197
  }
@@ -38,7 +199,7 @@ function alias(from, to) {
38
199
  aliases.set(from, to);
39
200
  }
40
201
  async function autoInitIfFirstRun() {
41
- if (existsSync(paths.db))
202
+ if (existsSync2(paths.db))
42
203
  return;
43
204
  const init = commands.get("init");
44
205
  if (!init)
@@ -57,6 +218,7 @@ async function route(argv) {
57
218
  const command = args[0];
58
219
  if (!command) {
59
220
  await autoInitIfFirstRun();
221
+ triggerBackgroundPull();
60
222
  const handler2 = commands.get("launch");
61
223
  if (!handler2)
62
224
  throw new Error("No launch command registered");
@@ -92,11 +254,13 @@ Usage:
92
254
  bertrand archive <name> Archive/unarchive a session
93
255
  bertrand update Hook-facing state writer (internal)
94
256
  bertrand serve Start dashboard HTTP server
257
+ bertrand sync <op> push|pull|status|onboard (see: bertrand sync --help)
95
258
  `.trim());
96
259
  }
97
260
  var commands, aliases;
98
261
  var init_router = __esm(() => {
99
262
  init_paths();
263
+ init_trigger();
100
264
  commands = new Map;
101
265
  aliases = new Map;
102
266
  });
@@ -411,6 +575,7 @@ var init_update = __esm(() => {
411
575
  init_sessions();
412
576
  init_events();
413
577
  init_conversations();
578
+ init_trigger();
414
579
  EVENT_STATUS_MAP = {
415
580
  "session.waiting": "waiting",
416
581
  "session.answered": "active",
@@ -480,11 +645,14 @@ var init_update = __esm(() => {
480
645
  if (event === "session.waiting" && conversationId && meta?.question) {
481
646
  updateLastQuestion(conversationId, meta.question);
482
647
  }
648
+ if (event === "session.end") {
649
+ triggerBackgroundPush();
650
+ }
483
651
  });
484
652
  });
485
653
 
486
654
  // src/lib/transcript.ts
487
- import { existsSync as existsSync2, readFileSync } from "fs";
655
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
488
656
  function getContextWindowSize(model) {
489
657
  for (const [prefix, size] of Object.entries(CONTEXT_WINDOW_SIZES)) {
490
658
  if (model.startsWith(prefix))
@@ -493,9 +661,9 @@ function getContextWindowSize(model) {
493
661
  return 200000;
494
662
  }
495
663
  function getLatestAssistantTurn(filePath) {
496
- if (!existsSync2(filePath))
664
+ if (!existsSync3(filePath))
497
665
  return null;
498
- const text2 = readFileSync(filePath, "utf-8");
666
+ const text2 = readFileSync3(filePath, "utf-8");
499
667
  const lines = text2.split(`
500
668
  `);
501
669
  const assistantEntries = [];
@@ -553,9 +721,9 @@ function getLatestAssistantTurn(filePath) {
553
721
  };
554
722
  }
555
723
  function getContextSnapshot(filePath) {
556
- if (!existsSync2(filePath))
724
+ if (!existsSync3(filePath))
557
725
  return null;
558
- const text2 = readFileSync(filePath, "utf-8");
726
+ const text2 = readFileSync3(filePath, "utf-8");
559
727
  const lines = text2.split(`
560
728
  `);
561
729
  for (let i = lines.length - 1;i >= 0; i--) {
@@ -822,8 +990,8 @@ class NoopAdapter {
822
990
  }
823
991
 
824
992
  // src/terminal/index.ts
825
- import { readFileSync as readFileSync2 } from "fs";
826
- import { join as join2 } from "path";
993
+ import { readFileSync as readFileSync4 } from "fs";
994
+ import { join as join3 } from "path";
827
995
  function getTerminalAdapter() {
828
996
  if (cachedAdapter)
829
997
  return cachedAdapter;
@@ -842,7 +1010,7 @@ function getTerminalAdapter() {
842
1010
  }
843
1011
  function readConfigTerminal() {
844
1012
  try {
845
- const config = JSON.parse(readFileSync2(join2(paths.root, "config.json"), "utf-8"));
1013
+ const config = JSON.parse(readFileSync4(join3(paths.root, "config.json"), "utf-8"));
846
1014
  return config.terminal ?? null;
847
1015
  } catch {
848
1016
  return null;
@@ -1193,8 +1361,8 @@ var init_session_archive = __esm(() => {
1193
1361
 
1194
1362
  // src/server/index.ts
1195
1363
  import { execFile } from "child_process";
1196
- import { existsSync as existsSync3 } from "fs";
1197
- import { join as join3 } from "path";
1364
+ import { existsSync as existsSync4 } from "fs";
1365
+ import { join as join4 } from "path";
1198
1366
  function liveStats(sessionId) {
1199
1367
  return {
1200
1368
  sessionId,
@@ -1247,11 +1415,11 @@ function match(pathname, url) {
1247
1415
  }
1248
1416
  function findDashboardDir() {
1249
1417
  const candidates = [
1250
- join3(import.meta.dir, "dashboard"),
1251
- join3(import.meta.dir, "..", "dashboard")
1418
+ join4(import.meta.dir, "dashboard"),
1419
+ join4(import.meta.dir, "..", "dashboard")
1252
1420
  ];
1253
1421
  for (const dir of candidates) {
1254
- if (existsSync3(join3(dir, "index.html")))
1422
+ if (existsSync4(join4(dir, "index.html")))
1255
1423
  return dir;
1256
1424
  }
1257
1425
  return null;
@@ -1260,13 +1428,13 @@ async function serveDashboard(pathname) {
1260
1428
  if (!DASHBOARD_DIR)
1261
1429
  return null;
1262
1430
  const requested = pathname === "/" ? "/index.html" : pathname;
1263
- const filePath = join3(DASHBOARD_DIR, requested);
1431
+ const filePath = join4(DASHBOARD_DIR, requested);
1264
1432
  if (!filePath.startsWith(DASHBOARD_DIR))
1265
1433
  return null;
1266
1434
  const file = Bun.file(filePath);
1267
1435
  if (await file.exists())
1268
1436
  return new Response(file);
1269
- return new Response(Bun.file(join3(DASHBOARD_DIR, "index.html")));
1437
+ return new Response(Bun.file(join4(DASHBOARD_DIR, "index.html")));
1270
1438
  }
1271
1439
  function startServer(port = PORT) {
1272
1440
  const server = Bun.serve({
@@ -1387,6 +1555,652 @@ var init_serve = __esm(() => {
1387
1555
  });
1388
1556
  });
1389
1557
 
1558
+ // src/sync/snapshot.ts
1559
+ import { Database as Database2 } from "bun:sqlite";
1560
+ import { existsSync as existsSync5, unlinkSync } from "fs";
1561
+ function takeSnapshot() {
1562
+ cleanupSnapshot();
1563
+ const src = new Database2(paths.db, { readonly: true });
1564
+ try {
1565
+ src.exec(`VACUUM INTO '${SNAPSHOT_PATH.replace(/'/g, "''")}'`);
1566
+ } finally {
1567
+ src.close();
1568
+ }
1569
+ return SNAPSHOT_PATH;
1570
+ }
1571
+ function cleanupSnapshot() {
1572
+ for (const suffix of SIDECAR_SUFFIXES) {
1573
+ const p = SNAPSHOT_PATH + suffix;
1574
+ if (existsSync5(p)) {
1575
+ try {
1576
+ unlinkSync(p);
1577
+ } catch {}
1578
+ }
1579
+ }
1580
+ }
1581
+ var SIDECAR_SUFFIXES, SNAPSHOT_PATH;
1582
+ var init_snapshot2 = __esm(() => {
1583
+ init_paths();
1584
+ SIDECAR_SUFFIXES = ["", "-wal", "-shm"];
1585
+ SNAPSHOT_PATH = `${paths.db}.sync-snapshot`;
1586
+ });
1587
+
1588
+ // src/sync/crypto.ts
1589
+ import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
1590
+ function parseKey(base64Key) {
1591
+ const buf = Buffer.from(base64Key, "base64");
1592
+ if (buf.length !== 32) {
1593
+ throw new Error(`BERTRAND_ENCRYPTION_KEY must decode to 32 bytes (got ${buf.length}). ` + `Generate one with: openssl rand -base64 32`);
1594
+ }
1595
+ return buf;
1596
+ }
1597
+ function generateKeyBase64() {
1598
+ return randomBytes(32).toString("base64");
1599
+ }
1600
+ function encrypt(plaintext, base64Key) {
1601
+ const key = parseKey(base64Key);
1602
+ const iv = randomBytes(IV_LEN);
1603
+ const cipher = createCipheriv(ALGO, key, iv);
1604
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
1605
+ const tag = cipher.getAuthTag();
1606
+ return Buffer.concat([MAGIC, iv, ciphertext, tag]);
1607
+ }
1608
+ function decrypt(blob, base64Key) {
1609
+ if (blob.length < MAGIC.length + IV_LEN + TAG_LEN) {
1610
+ throw new Error("encrypted blob too small to be valid");
1611
+ }
1612
+ if (!blob.subarray(0, MAGIC.length).equals(MAGIC)) {
1613
+ throw new Error("not a bertrand encrypted blob (magic prefix mismatch). " + "Did you upload a plaintext .db file by mistake?");
1614
+ }
1615
+ const key = parseKey(base64Key);
1616
+ const iv = blob.subarray(MAGIC.length, MAGIC.length + IV_LEN);
1617
+ const tag = blob.subarray(blob.length - TAG_LEN);
1618
+ const ciphertext = blob.subarray(MAGIC.length + IV_LEN, blob.length - TAG_LEN);
1619
+ const decipher = createDecipheriv(ALGO, key, iv);
1620
+ decipher.setAuthTag(tag);
1621
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1622
+ }
1623
+ var ALGO = "aes-256-gcm", IV_LEN = 12, TAG_LEN = 16, MAGIC;
1624
+ var init_crypto = __esm(() => {
1625
+ MAGIC = Buffer.from("BTRD1", "ascii");
1626
+ });
1627
+
1628
+ // src/sync/engine.ts
1629
+ import { createClient } from "@supabase/supabase-js";
1630
+ import { readFileSync as readFileSync5, renameSync, statSync as statSync2, openSync, writeSync, fsyncSync, closeSync } from "fs";
1631
+ import { dirname as dirname2 } from "path";
1632
+ import { execFileSync } from "child_process";
1633
+ function client(cfg) {
1634
+ if (!cfg)
1635
+ return null;
1636
+ return createClient(cfg.supabaseUrl, cfg.supabaseServiceKey, {
1637
+ auth: { persistSession: false, autoRefreshToken: false }
1638
+ });
1639
+ }
1640
+ async function push() {
1641
+ if (!hasSyncConfig()) {
1642
+ return { ok: false, operation: "push", error: "no sync config \u2014 run `bertrand sync onboard`" };
1643
+ }
1644
+ const cfg = loadSyncConfig();
1645
+ const supabase = client(cfg);
1646
+ if (!cfg || !supabase) {
1647
+ return { ok: false, operation: "push", error: "sync config incomplete" };
1648
+ }
1649
+ const started = performance.now();
1650
+ try {
1651
+ let snapshotPath;
1652
+ try {
1653
+ snapshotPath = takeSnapshot();
1654
+ } catch (e) {
1655
+ return {
1656
+ ok: false,
1657
+ operation: "push",
1658
+ error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
1659
+ };
1660
+ }
1661
+ const plaintext = readFileSync5(snapshotPath);
1662
+ const ciphertext = encrypt(plaintext, cfg.encryptionKey);
1663
+ const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
1664
+ contentType: "application/octet-stream",
1665
+ upsert: true
1666
+ });
1667
+ if (error) {
1668
+ return { ok: false, operation: "push", error: `upload failed: ${error.message}` };
1669
+ }
1670
+ return {
1671
+ ok: true,
1672
+ operation: "push",
1673
+ bytes: ciphertext.length,
1674
+ durationMs: Math.round(performance.now() - started)
1675
+ };
1676
+ } catch (e) {
1677
+ return { ok: false, operation: "push", error: e instanceof Error ? e.message : String(e) };
1678
+ } finally {
1679
+ cleanupSnapshot();
1680
+ }
1681
+ }
1682
+ async function pull(opts = {}) {
1683
+ if (!hasSyncConfig()) {
1684
+ return { ok: false, operation: "pull", error: "no sync config \u2014 run `bertrand sync onboard`" };
1685
+ }
1686
+ const cfg = loadSyncConfig();
1687
+ const supabase = client(cfg);
1688
+ if (!cfg || !supabase) {
1689
+ return { ok: false, operation: "pull", error: "sync config incomplete" };
1690
+ }
1691
+ const holders = findHolders(paths.db);
1692
+ if (holders.length > 0) {
1693
+ const procs = holders.map((h) => `${h.command}(${h.pid})`).join(", ");
1694
+ if (!opts.force) {
1695
+ return {
1696
+ ok: false,
1697
+ operation: "pull",
1698
+ error: `${paths.db} is held by ${procs} \u2014 close active bertrand sessions before pulling, ` + `or pass --force to overwrite anyway (risks corrupting the running session).`
1699
+ };
1700
+ }
1701
+ console.warn(`warning: --force pulling while ${procs} hold ${paths.db}. The running process may crash on next file access.`);
1702
+ }
1703
+ const started = performance.now();
1704
+ try {
1705
+ const { data, error } = await supabase.storage.from(cfg.bucket).download(cfg.objectKey);
1706
+ if (error || !data) {
1707
+ if (error?.message?.toLowerCase().includes("not found")) {
1708
+ return {
1709
+ ok: true,
1710
+ operation: "pull",
1711
+ pulled: false,
1712
+ bytes: 0,
1713
+ durationMs: Math.round(performance.now() - started)
1714
+ };
1715
+ }
1716
+ return { ok: false, operation: "pull", error: `download failed: ${error?.message ?? "no data"}` };
1717
+ }
1718
+ const ciphertext = Buffer.from(await data.arrayBuffer());
1719
+ const plaintext = decrypt(ciphertext, cfg.encryptionKey);
1720
+ const tmp = `${paths.db}.pull-${process.pid}`;
1721
+ const fd = openSync(tmp, "w");
1722
+ try {
1723
+ writeSync(fd, plaintext);
1724
+ fsyncSync(fd);
1725
+ } finally {
1726
+ closeSync(fd);
1727
+ }
1728
+ renameSync(tmp, paths.db);
1729
+ const dirFd = openSync(dirname2(paths.db), "r");
1730
+ try {
1731
+ fsyncSync(dirFd);
1732
+ } finally {
1733
+ closeSync(dirFd);
1734
+ }
1735
+ return {
1736
+ ok: true,
1737
+ operation: "pull",
1738
+ pulled: true,
1739
+ bytes: plaintext.length,
1740
+ durationMs: Math.round(performance.now() - started)
1741
+ };
1742
+ } catch (e) {
1743
+ return { ok: false, operation: "pull", error: e instanceof Error ? e.message : String(e) };
1744
+ }
1745
+ }
1746
+ async function status() {
1747
+ const cfg = loadSyncConfig();
1748
+ if (!cfg)
1749
+ return { configured: false, local: null, remote: null };
1750
+ let local = null;
1751
+ try {
1752
+ const s = statSync2(paths.db);
1753
+ local = { size: s.size, modifiedAt: new Date(s.mtimeMs) };
1754
+ } catch {
1755
+ local = null;
1756
+ }
1757
+ const supabase = client(cfg);
1758
+ let remote = null;
1759
+ if (supabase) {
1760
+ try {
1761
+ const parentPath = cfg.objectKey.includes("/") ? cfg.objectKey.slice(0, cfg.objectKey.lastIndexOf("/")) : "";
1762
+ const fileName = cfg.objectKey.includes("/") ? cfg.objectKey.slice(cfg.objectKey.lastIndexOf("/") + 1) : cfg.objectKey;
1763
+ const { data, error } = await supabase.storage.from(cfg.bucket).list(parentPath, { search: fileName });
1764
+ if (!error && data) {
1765
+ const match2 = data.find((f) => f.name === fileName);
1766
+ if (match2) {
1767
+ remote = {
1768
+ size: match2.metadata?.size ?? 0,
1769
+ modifiedAt: new Date(match2.updated_at ?? match2.created_at ?? Date.now())
1770
+ };
1771
+ }
1772
+ }
1773
+ } catch {}
1774
+ }
1775
+ return { configured: true, local, remote };
1776
+ }
1777
+ function findHolders(path) {
1778
+ try {
1779
+ const out = execFileSync("lsof", ["-F", "pcn", path], {
1780
+ encoding: "utf8",
1781
+ stdio: ["ignore", "pipe", "ignore"]
1782
+ });
1783
+ const holders = [];
1784
+ let pid = 0;
1785
+ let command = "";
1786
+ for (const line of out.split(`
1787
+ `)) {
1788
+ if (line.startsWith("p"))
1789
+ pid = Number(line.slice(1));
1790
+ else if (line.startsWith("c"))
1791
+ command = line.slice(1);
1792
+ else if (line.startsWith("n")) {
1793
+ if (pid && pid !== process.pid && command) {
1794
+ holders.push({ pid, command });
1795
+ }
1796
+ }
1797
+ }
1798
+ return holders;
1799
+ } catch {
1800
+ return [];
1801
+ }
1802
+ }
1803
+ var init_engine = __esm(() => {
1804
+ init_paths();
1805
+ init_config();
1806
+ init_snapshot2();
1807
+ init_crypto();
1808
+ });
1809
+
1810
+ // src/sync/invite.ts
1811
+ function encodeInvite(cfg) {
1812
+ const bundle = {
1813
+ v: VERSION,
1814
+ url: cfg.supabaseUrl,
1815
+ key: cfg.supabaseServiceKey,
1816
+ bucket: cfg.bucket,
1817
+ obj: cfg.objectKey,
1818
+ ek: cfg.encryptionKey
1819
+ };
1820
+ return SCHEME + Buffer.from(JSON.stringify(bundle), "utf8").toString("base64url");
1821
+ }
1822
+ function isInvite(value) {
1823
+ return typeof value === "string" && value.startsWith(SCHEME);
1824
+ }
1825
+ function decodeInvite(invite) {
1826
+ if (!invite.startsWith(SCHEME)) {
1827
+ throw new Error(`invite must start with ${SCHEME}`);
1828
+ }
1829
+ const payload = invite.slice(SCHEME.length).trim();
1830
+ let parsed;
1831
+ try {
1832
+ const json = Buffer.from(payload, "base64url").toString("utf8");
1833
+ parsed = JSON.parse(json);
1834
+ } catch {
1835
+ throw new Error("invite is malformed \u2014 could not decode base64/JSON payload");
1836
+ }
1837
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1838
+ throw new Error("invite payload is not a JSON object");
1839
+ }
1840
+ const bundle = parsed;
1841
+ if (bundle.v !== VERSION) {
1842
+ throw new Error(`invite version ${String(bundle.v)} is not supported by this bertrand (expected v${VERSION}). ` + `Upgrade or downgrade so both machines run the same version.`);
1843
+ }
1844
+ for (const field of ["url", "key", "bucket", "obj", "ek"]) {
1845
+ if (!bundle[field] || typeof bundle[field] !== "string") {
1846
+ throw new Error(`invite is missing required field: ${field}`);
1847
+ }
1848
+ }
1849
+ return {
1850
+ supabaseUrl: bundle.url,
1851
+ supabaseServiceKey: bundle.key,
1852
+ bucket: bundle.bucket,
1853
+ objectKey: bundle.obj,
1854
+ encryptionKey: bundle.ek
1855
+ };
1856
+ }
1857
+ var SCHEME = "bertrand-sync://", VERSION = 1;
1858
+
1859
+ // src/lib/format.ts
1860
+ function formatDuration(ms) {
1861
+ if (ms < MINUTE)
1862
+ return `${Math.round(ms / SECOND)}s`;
1863
+ const days = Math.floor(ms / DAY);
1864
+ const hours = Math.floor(ms % DAY / HOUR);
1865
+ const minutes = Math.floor(ms % HOUR / MINUTE);
1866
+ if (days > 0)
1867
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
1868
+ if (hours > 0)
1869
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1870
+ return `${minutes}m`;
1871
+ }
1872
+ function formatAgo(isoOrDate) {
1873
+ const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
1874
+ const ms = Date.now() - date.getTime();
1875
+ if (ms < MINUTE)
1876
+ return "just now";
1877
+ if (ms < HOUR)
1878
+ return `${Math.floor(ms / MINUTE)}m ago`;
1879
+ if (ms < DAY)
1880
+ return `${Math.floor(ms / HOUR)}h ago`;
1881
+ if (ms < 2 * DAY)
1882
+ return "yesterday";
1883
+ if (ms < 7 * DAY)
1884
+ return `${Math.floor(ms / DAY)}d ago`;
1885
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1886
+ }
1887
+ function truncate(text2, maxLen) {
1888
+ if (text2.length <= maxLen)
1889
+ return text2;
1890
+ return text2.slice(0, maxLen - 1) + "\u2026";
1891
+ }
1892
+ function formatTime(iso, includeDate = false) {
1893
+ const date = new Date(iso);
1894
+ const time = date.toLocaleTimeString("en-US", {
1895
+ hour: "numeric",
1896
+ minute: "2-digit"
1897
+ });
1898
+ if (!includeDate)
1899
+ return time;
1900
+ const day = date.toLocaleDateString("en-US", {
1901
+ month: "short",
1902
+ day: "numeric"
1903
+ });
1904
+ return `${day} ${time}`;
1905
+ }
1906
+ var SECOND = 1000, MINUTE, HOUR, DAY;
1907
+ var init_format = __esm(() => {
1908
+ MINUTE = 60 * SECOND;
1909
+ HOUR = 60 * MINUTE;
1910
+ DAY = 24 * HOUR;
1911
+ });
1912
+
1913
+ // src/cli/commands/sync.ts
1914
+ var exports_sync = {};
1915
+ import { hostname } from "os";
1916
+ function printUsage2() {
1917
+ console.log(`
1918
+ bertrand sync \u2014 replicate your local DB to Supabase Storage (opt-in)
1919
+
1920
+ Usage:
1921
+ bertrand sync onboard Interactive: configure Supabase + bucket + encryption key
1922
+ bertrand sync invite Print a paste-able bundle for another machine
1923
+ bertrand sync <bundle> One-liner: decode bundle, save config, pull
1924
+ bertrand sync push VACUUM \u2192 encrypt \u2192 upload
1925
+ bertrand sync pull [--force] Download \u2192 decrypt \u2192 atomic-replace (refuses if active)
1926
+ bertrand sync status Local + remote object size and timestamps
1927
+ bertrand sync enable Turn auto-triggers on (assumes onboard already ran)
1928
+ bertrand sync disable Turn auto-triggers off without deleting credentials
1929
+
1930
+ Cross-machine setup:
1931
+ Machine A: bertrand sync invite
1932
+ \u2192 bertrand-sync://eyJ\u2026 (transmit via Signal/iMessage/AirDrop \u2014 sensitive)
1933
+ Machine B: bertrand sync bertrand-sync://eyJ\u2026
1934
+ \u2192 onboards, pulls, ready
1935
+ `.trim());
1936
+ }
1937
+ function abort(reason) {
1938
+ console.error(`Aborted: ${reason}`);
1939
+ process.exit(1);
1940
+ }
1941
+ function prompt(label, opts = {}) {
1942
+ const suffix = opts.default ? ` [${opts.default}]` : "";
1943
+ process.stdout.write(`${label}${suffix}: `);
1944
+ return new Promise((resolve) => {
1945
+ let buf = "";
1946
+ const onData = (chunk) => {
1947
+ const s = chunk.toString("utf8");
1948
+ for (const ch of s) {
1949
+ if (ch === `
1950
+ ` || ch === "\r") {
1951
+ process.stdin.off("data", onData);
1952
+ process.stdin.pause();
1953
+ process.stdout.write(`
1954
+ `);
1955
+ return resolve(buf.trim() || opts.default || "");
1956
+ }
1957
+ buf += ch;
1958
+ }
1959
+ };
1960
+ process.stdin.resume();
1961
+ process.stdin.on("data", onData);
1962
+ });
1963
+ }
1964
+ async function runOnboard() {
1965
+ const existing = loadSyncConfig();
1966
+ if (existing) {
1967
+ console.log(`Existing config at ${paths.syncEnv}:`);
1968
+ console.log(` SUPABASE_URL: ${existing.supabaseUrl}`);
1969
+ console.log(` SUPABASE_SERVICE_KEY: ${existing.supabaseServiceKey.slice(0, 12)}\u2026`);
1970
+ console.log(` BERTRAND_SYNC_BUCKET: ${existing.bucket}`);
1971
+ console.log(` BERTRAND_SYNC_OBJECT: ${existing.objectKey}`);
1972
+ console.log(` BERTRAND_ENCRYPTION_KEY: (set, redacted)`);
1973
+ console.log(` BERTRAND_CLIENT_NAME: ${existing.clientName}`);
1974
+ console.log(`
1975
+ Delete ${paths.syncEnv} and re-run to start over.`);
1976
+ return;
1977
+ }
1978
+ console.log(`Bertrand sync setup \u2014 Supabase Storage
1979
+ `);
1980
+ console.log(`In the Supabase dashboard (app.supabase.com):`);
1981
+ console.log(` 1. Create or pick a project`);
1982
+ console.log(` 2. Settings \u2192 General: copy the Project ID (the 20-char lowercase string`);
1983
+ console.log(` shown under "Reference ID" \u2014 looks like "xxxxxxxxxxxxxxxxxxxx")`);
1984
+ console.log(` 3. Settings \u2192 API: copy the service_role key (NOT the anon key \u2014 service_role`);
1985
+ console.log(` is below it and warns "keep secret")`);
1986
+ console.log(` 4. Storage \u2192 New bucket: name it "bertrand", PRIVATE (uncheck "Public bucket")
1987
+ `);
1988
+ const projectId = await prompt("Project ID");
1989
+ if (!projectId)
1990
+ return abort("no project ID provided");
1991
+ if (!/^[a-z0-9]{15,30}$/i.test(projectId)) {
1992
+ return abort(`"${projectId}" doesn't look like a Supabase project ID. ` + `Expected the 20-ish char string from Settings \u2192 General, not the project name.`);
1993
+ }
1994
+ const supabaseUrl = `https://${projectId}.supabase.co`;
1995
+ console.log(` \u2192 Project URL: ${supabaseUrl}`);
1996
+ const supabaseServiceKey = await prompt("SUPABASE_SERVICE_KEY (eyJ\u2026)");
1997
+ if (!supabaseServiceKey)
1998
+ return abort("no service key provided");
1999
+ if (!supabaseServiceKey.startsWith("eyJ")) {
2000
+ return abort("service key must be a JWT starting with eyJ \u2014 did you paste the URL by mistake?");
2001
+ }
2002
+ const bucket = await prompt("Storage bucket name", { default: "bertrand" });
2003
+ const objectKey = await prompt("Object key", { default: "bertrand.db.enc" });
2004
+ const defaultName = `bertrand-${hostname()}`;
2005
+ const clientName = await prompt("Client name", { default: defaultName });
2006
+ console.log(`
2007
+ Encryption key`);
2008
+ console.log(` First machine? Press ENTER to generate a fresh AES-256-GCM key.`);
2009
+ console.log(` Additional machine? Paste the key from your other machine.
2010
+ `);
2011
+ const pasted = await prompt("BERTRAND_ENCRYPTION_KEY");
2012
+ let encryptionKey;
2013
+ if (pasted) {
2014
+ const decoded = Buffer.from(pasted, "base64");
2015
+ if (decoded.length !== 32) {
2016
+ return abort(`pasted key is ${decoded.length} bytes when decoded; expected 32. ` + `Copy the full key from your other machine without trimming.`);
2017
+ }
2018
+ encryptionKey = pasted;
2019
+ console.log(` \u2713 Using pasted key \u2014 this machine can decrypt blobs from the other one.
2020
+ `);
2021
+ } else {
2022
+ encryptionKey = generateKeyBase64();
2023
+ console.log(`
2024
+ Generated a new key. Save this somewhere safe \u2014 you'll need it on every`);
2025
+ console.log(`other machine that should be able to pull this DB:
2026
+ `);
2027
+ console.log(` ${encryptionKey}
2028
+ `);
2029
+ console.log(`Hit ENTER to confirm you've saved it.`);
2030
+ await prompt("");
2031
+ }
2032
+ saveSyncConfig({ supabaseUrl, supabaseServiceKey, bucket, objectKey, encryptionKey, clientName });
2033
+ patchConfig({ sync: { enabled: true } });
2034
+ console.log(`Wrote ${paths.syncEnv} (mode 0600). Sync auto-triggers enabled.`);
2035
+ console.log(`
2036
+ Next:`);
2037
+ console.log(` bertrand sync push Send your current local DB to Supabase`);
2038
+ console.log(` bertrand sync status See sizes and timestamps`);
2039
+ console.log(`
2040
+ On another machine: paste the same SUPABASE_URL, a fresh SERVICE_KEY (you can reuse),`);
2041
+ console.log(`the same bucket and object key, and the SAME encryption key. Then \`bertrand sync pull\`.`);
2042
+ }
2043
+ async function runPush() {
2044
+ if (!hasSyncConfig())
2045
+ return abort("no sync config \u2014 run `bertrand sync onboard`");
2046
+ process.stdout.write("Snapshot \u2192 encrypt \u2192 upload\u2026 ");
2047
+ const result = await push();
2048
+ if (result.ok) {
2049
+ console.log(`done (${formatBytes(result.bytes)} in ${result.durationMs}ms).`);
2050
+ } else {
2051
+ console.log("failed.");
2052
+ console.error(result.error);
2053
+ process.exit(1);
2054
+ }
2055
+ }
2056
+ async function runPull(args) {
2057
+ if (!hasSyncConfig())
2058
+ return abort("no sync config \u2014 run `bertrand sync onboard`");
2059
+ const force = args.includes("--force");
2060
+ process.stdout.write("Download \u2192 decrypt \u2192 replace\u2026 ");
2061
+ const result = await pull({ force });
2062
+ if (result.ok) {
2063
+ if (result.pulled) {
2064
+ console.log(`done (${formatBytes(result.bytes)} in ${result.durationMs}ms).`);
2065
+ } else {
2066
+ console.log("no remote object yet \u2014 nothing to pull.");
2067
+ }
2068
+ } else {
2069
+ console.log("failed.");
2070
+ console.error(result.error);
2071
+ process.exit(1);
2072
+ }
2073
+ }
2074
+ async function runStatus() {
2075
+ if (!hasSyncConfig()) {
2076
+ console.log("Sync is not configured. Run `bertrand sync onboard` to set up.");
2077
+ return;
2078
+ }
2079
+ console.log(`Sync status (${paths.syncEnv}):`);
2080
+ console.log(` Auto-triggers: ${isSyncEnabled() ? "enabled" : "disabled"}`);
2081
+ const s = await status();
2082
+ if (s.local) {
2083
+ console.log(` Local: ${formatBytes(s.local.size)}, modified ${formatAgo(s.local.modifiedAt)}`);
2084
+ } else {
2085
+ console.log(` Local: (no DB file yet)`);
2086
+ }
2087
+ if (s.remote) {
2088
+ console.log(` Remote: ${formatBytes(s.remote.size)}, modified ${formatAgo(s.remote.modifiedAt)}`);
2089
+ } else {
2090
+ console.log(` Remote: (no object \u2014 push to create)`);
2091
+ }
2092
+ }
2093
+ function runEnable() {
2094
+ if (!hasSyncConfig()) {
2095
+ return abort("no sync config \u2014 run `bertrand sync onboard` first");
2096
+ }
2097
+ patchConfig({ sync: { enabled: true } });
2098
+ console.log("Sync auto-triggers enabled. Pull-on-launch and push-on-session-end will fire.");
2099
+ }
2100
+ function runInvite() {
2101
+ const cfg = loadSyncConfig();
2102
+ if (!cfg) {
2103
+ return abort("no sync config \u2014 run `bertrand sync onboard` first");
2104
+ }
2105
+ const invite = encodeInvite(cfg);
2106
+ console.log("Sensitive: this bundle contains a Supabase service_role token AND your DB encryption key.");
2107
+ console.log(`Transmit only over a secure channel (Signal, iMessage, AirDrop). Treat like an SSH private key.
2108
+ `);
2109
+ console.log(invite);
2110
+ console.log(`
2111
+ On the other machine:`);
2112
+ console.log(` bertrand sync ${invite.slice(0, 32)}\u2026`);
2113
+ }
2114
+ async function runBootstrap(invite) {
2115
+ if (hasSyncConfig()) {
2116
+ return abort(`sync config already exists at ${paths.syncEnv}. ` + `Delete it first if you really want to overwrite: \`rm ${paths.syncEnv}\``);
2117
+ }
2118
+ let decoded;
2119
+ try {
2120
+ decoded = decodeInvite(invite);
2121
+ } catch (e) {
2122
+ return abort(e instanceof Error ? e.message : String(e));
2123
+ }
2124
+ const clientName = `bertrand-${hostname()}`;
2125
+ saveSyncConfig({ ...decoded, clientName });
2126
+ patchConfig({ sync: { enabled: true } });
2127
+ console.log(`Wrote ${paths.syncEnv} (mode 0600). Auto-triggers enabled.`);
2128
+ console.log(`Pulling\u2026`);
2129
+ const result = await pull();
2130
+ if (result.ok) {
2131
+ if (result.pulled) {
2132
+ console.log(`\u2713 Done (${formatBytes(result.bytes)} in ${result.durationMs}ms). Run \`bertrand\` to start.`);
2133
+ } else {
2134
+ console.log(`\u2713 Config saved, but no remote object yet \u2014 nothing to pull.`);
2135
+ console.log(` Run \`bertrand sync push\` on the source machine first.`);
2136
+ }
2137
+ } else {
2138
+ console.error(`Pull failed: ${result.error}`);
2139
+ console.error(`(Config is saved, so you can fix and re-run \`bertrand sync pull\`.)`);
2140
+ process.exit(1);
2141
+ }
2142
+ }
2143
+ function runDisable() {
2144
+ patchConfig({ sync: { enabled: false } });
2145
+ console.log("Sync auto-triggers disabled. Credentials still in place \u2014 re-enable any time with `bertrand sync enable`.");
2146
+ console.log("Manual `bertrand sync push` / `pull` still work while disabled.");
2147
+ }
2148
+ function formatBytes(n) {
2149
+ if (n < 1024)
2150
+ return `${n}B`;
2151
+ if (n < 1024 * 1024)
2152
+ return `${(n / 1024).toFixed(1)}KB`;
2153
+ return `${(n / (1024 * 1024)).toFixed(2)}MB`;
2154
+ }
2155
+ var init_sync = __esm(() => {
2156
+ init_router();
2157
+ init_config();
2158
+ init_engine();
2159
+ init_crypto();
2160
+ init_paths();
2161
+ init_format();
2162
+ init_config2();
2163
+ register("sync", async (args) => {
2164
+ const sub = args[0];
2165
+ if (sub && isInvite(sub)) {
2166
+ await runBootstrap(sub);
2167
+ return;
2168
+ }
2169
+ switch (sub) {
2170
+ case "push":
2171
+ await runPush();
2172
+ return;
2173
+ case "pull":
2174
+ await runPull(args.slice(1));
2175
+ return;
2176
+ case "status":
2177
+ await runStatus();
2178
+ return;
2179
+ case "onboard":
2180
+ await runOnboard();
2181
+ return;
2182
+ case "invite":
2183
+ runInvite();
2184
+ return;
2185
+ case "enable":
2186
+ runEnable();
2187
+ return;
2188
+ case "disable":
2189
+ runDisable();
2190
+ return;
2191
+ case undefined:
2192
+ case "--help":
2193
+ case "-h":
2194
+ printUsage2();
2195
+ return;
2196
+ default:
2197
+ console.error(`Unknown subcommand: ${sub}`);
2198
+ printUsage2();
2199
+ process.exit(1);
2200
+ }
2201
+ });
2202
+ });
2203
+
1390
2204
  // src/db/queries/groups.ts
1391
2205
  import { eq as eq6, like, or, isNull } from "drizzle-orm";
1392
2206
  function createGroup(opts) {
@@ -1484,60 +2298,6 @@ var init_template2 = __esm(() => {
1484
2298
  init_template();
1485
2299
  });
1486
2300
 
1487
- // src/lib/format.ts
1488
- function formatDuration(ms) {
1489
- if (ms < MINUTE)
1490
- return `${Math.round(ms / SECOND)}s`;
1491
- const days = Math.floor(ms / DAY);
1492
- const hours = Math.floor(ms % DAY / HOUR);
1493
- const minutes = Math.floor(ms % HOUR / MINUTE);
1494
- if (days > 0)
1495
- return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
1496
- if (hours > 0)
1497
- return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1498
- return `${minutes}m`;
1499
- }
1500
- function formatAgo(isoOrDate) {
1501
- const date = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
1502
- const ms = Date.now() - date.getTime();
1503
- if (ms < MINUTE)
1504
- return "just now";
1505
- if (ms < HOUR)
1506
- return `${Math.floor(ms / MINUTE)}m ago`;
1507
- if (ms < DAY)
1508
- return `${Math.floor(ms / HOUR)}h ago`;
1509
- if (ms < 2 * DAY)
1510
- return "yesterday";
1511
- if (ms < 7 * DAY)
1512
- return `${Math.floor(ms / DAY)}d ago`;
1513
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1514
- }
1515
- function truncate(text2, maxLen) {
1516
- if (text2.length <= maxLen)
1517
- return text2;
1518
- return text2.slice(0, maxLen - 1) + "\u2026";
1519
- }
1520
- function formatTime(iso, includeDate = false) {
1521
- const date = new Date(iso);
1522
- const time = date.toLocaleTimeString("en-US", {
1523
- hour: "numeric",
1524
- minute: "2-digit"
1525
- });
1526
- if (!includeDate)
1527
- return time;
1528
- const day = date.toLocaleDateString("en-US", {
1529
- month: "short",
1530
- day: "numeric"
1531
- });
1532
- return `${day} ${time}`;
1533
- }
1534
- var SECOND = 1000, MINUTE, HOUR, DAY;
1535
- var init_format = __esm(() => {
1536
- MINUTE = 60 * SECOND;
1537
- HOUR = 60 * MINUTE;
1538
- DAY = 24 * HOUR;
1539
- });
1540
-
1541
2301
  // src/contract/context.ts
1542
2302
  function buildSiblingContext(groupId, groupPath, currentSessionId) {
1543
2303
  const siblings = getSessionsByGroup(groupId).filter((s) => s.id !== currentSessionId);
@@ -1567,7 +2327,7 @@ var init_context = __esm(() => {
1567
2327
  });
1568
2328
 
1569
2329
  // src/engine/process.ts
1570
- import { spawn } from "child_process";
2330
+ import { spawn as spawn2 } from "child_process";
1571
2331
  function launchClaude(opts) {
1572
2332
  const args = [];
1573
2333
  if (opts.resume) {
@@ -1585,7 +2345,7 @@ function launchClaude(opts) {
1585
2345
  BERTRAND_SESSION_SLUG: opts.sessionSlug
1586
2346
  };
1587
2347
  return new Promise((resolve, reject) => {
1588
- const child = spawn("claude", args, {
2348
+ const child = spawn2("claude", args, {
1589
2349
  env,
1590
2350
  stdio: "inherit",
1591
2351
  shell: false
@@ -1683,12 +2443,12 @@ var init_spawn_context = __esm(() => {
1683
2443
  });
1684
2444
 
1685
2445
  // src/lib/server-lifecycle.ts
1686
- import { spawn as spawn2 } from "child_process";
1687
- import { readFileSync as readFileSync3, writeFileSync, unlinkSync } from "fs";
1688
- import { join as join4 } from "path";
2446
+ import { spawn as spawn3 } from "child_process";
2447
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2 } from "fs";
2448
+ import { join as join5 } from "path";
1689
2449
  function readPidFile() {
1690
2450
  try {
1691
- const pid = Number(readFileSync3(deps.pidFile, "utf-8").trim());
2451
+ const pid = Number(readFileSync6(deps.pidFile, "utf-8").trim());
1692
2452
  return Number.isFinite(pid) && pid > 0 ? pid : null;
1693
2453
  } catch {
1694
2454
  return null;
@@ -1714,7 +2474,7 @@ async function isPortListening(port) {
1714
2474
  }
1715
2475
  function removePidFile() {
1716
2476
  try {
1717
- unlinkSync(deps.pidFile);
2477
+ unlinkSync2(deps.pidFile);
1718
2478
  } catch {}
1719
2479
  }
1720
2480
  async function ensureServerStarted() {
@@ -1728,14 +2488,14 @@ async function ensureServerStarted() {
1728
2488
  const bin = deps.resolveBin();
1729
2489
  if (!bin)
1730
2490
  return;
1731
- const child = spawn2(bin, ["serve"], {
2491
+ const child = spawn3(bin, ["serve"], {
1732
2492
  detached: true,
1733
2493
  stdio: "ignore",
1734
2494
  env: { ...process.env, BERTRAND_PORT: String(deps.port) }
1735
2495
  });
1736
2496
  child.unref();
1737
2497
  if (child.pid)
1738
- writeFileSync(deps.pidFile, String(child.pid));
2498
+ writeFileSync3(deps.pidFile, String(child.pid));
1739
2499
  }
1740
2500
  function stopServerIfIdle() {
1741
2501
  if (deps.getActiveCount() > 0)
@@ -1753,11 +2513,11 @@ var init_server_lifecycle = __esm(() => {
1753
2513
  init_paths();
1754
2514
  init_sessions();
1755
2515
  defaultDeps = {
1756
- pidFile: join4(paths.root, "server.pid"),
2516
+ pidFile: join5(paths.root, "server.pid"),
1757
2517
  port: Number(process.env.BERTRAND_PORT ?? 5200),
1758
2518
  resolveBin() {
1759
2519
  try {
1760
- const config = JSON.parse(readFileSync3(join4(paths.root, "config.json"), "utf-8"));
2520
+ const config = JSON.parse(readFileSync6(join5(paths.root, "config.json"), "utf-8"));
1761
2521
  return typeof config?.bin === "string" ? config.bin : null;
1762
2522
  } catch {
1763
2523
  return null;
@@ -1902,13 +2662,13 @@ var init_session = __esm(() => {
1902
2662
  });
1903
2663
 
1904
2664
  // src/tui/app.tsx
1905
- import { spawn as spawn3 } from "child_process";
1906
- import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
1907
- import { join as join5 } from "path";
2665
+ import { spawn as spawn4 } from "child_process";
2666
+ import { existsSync as existsSync6, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
2667
+ import { join as join6 } from "path";
1908
2668
  import { randomUUID as randomUUID2 } from "crypto";
1909
2669
  async function runScreen(screen, ...args) {
1910
2670
  const tmpFile = `/tmp/bertrand-tui-${process.pid}-${Date.now()}.json`;
1911
- const child = spawn3("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
2671
+ const child = spawn4("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
1912
2672
  stdio: "inherit"
1913
2673
  });
1914
2674
  const exitCode = await new Promise((resolve) => {
@@ -1917,8 +2677,8 @@ async function runScreen(screen, ...args) {
1917
2677
  if (exitCode !== 0) {
1918
2678
  throw new Error(`TUI screen "${screen}" exited with code ${exitCode}`);
1919
2679
  }
1920
- const result = JSON.parse(readFileSync4(tmpFile, "utf-8"));
1921
- unlinkSync2(tmpFile);
2680
+ const result = JSON.parse(readFileSync7(tmpFile, "utf-8"));
2681
+ unlinkSync3(tmpFile);
1922
2682
  return result;
1923
2683
  }
1924
2684
  async function startLaunchTui() {
@@ -2002,8 +2762,8 @@ var init_app = __esm(() => {
2002
2762
  init_session_archive();
2003
2763
  init_session();
2004
2764
  SCREEN_ENTRY = (() => {
2005
- const built = join5(import.meta.dir, "run-screen.js");
2006
- return existsSync4(built) ? built : join5(import.meta.dir, "run-screen.tsx");
2765
+ const built = join6(import.meta.dir, "run-screen.js");
2766
+ return existsSync6(built) ? built : join6(import.meta.dir, "run-screen.tsx");
2007
2767
  })();
2008
2768
  });
2009
2769
 
@@ -2082,14 +2842,14 @@ var init_launch = __esm(() => {
2082
2842
  });
2083
2843
 
2084
2844
  // src/db/migrate.ts
2085
- import { Database as Database2 } from "bun:sqlite";
2845
+ import { Database as Database3 } from "bun:sqlite";
2086
2846
  import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
2087
2847
  import { migrate } from "drizzle-orm/bun-sqlite/migrator";
2088
2848
  import { mkdirSync as mkdirSync2 } from "fs";
2089
- import { dirname as dirname2 } from "path";
2849
+ import { dirname as dirname3 } from "path";
2090
2850
  function runMigrations() {
2091
- mkdirSync2(dirname2(paths.db), { recursive: true });
2092
- const sqlite = new Database2(paths.db);
2851
+ mkdirSync2(dirname3(paths.db), { recursive: true });
2852
+ const sqlite = new Database3(paths.db);
2093
2853
  sqlite.exec("PRAGMA journal_mode = WAL");
2094
2854
  sqlite.exec("PRAGMA foreign_keys = ON");
2095
2855
  const db = drizzle2(sqlite);
@@ -2376,14 +3136,14 @@ var init_scripts = __esm(() => {
2376
3136
  });
2377
3137
 
2378
3138
  // src/hooks/install.ts
2379
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, chmodSync } from "fs";
2380
- import { join as join6 } from "path";
3139
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4, chmodSync as chmodSync2 } from "fs";
3140
+ import { join as join7 } from "path";
2381
3141
  function installHookScripts(bin) {
2382
3142
  mkdirSync3(paths.hooks, { recursive: true });
2383
3143
  for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
2384
- const filePath = join6(paths.hooks, filename);
2385
- writeFileSync2(filePath, scriptFn(bin));
2386
- chmodSync(filePath, 493);
3144
+ const filePath = join7(paths.hooks, filename);
3145
+ writeFileSync4(filePath, scriptFn(bin));
3146
+ chmodSync2(filePath, 493);
2387
3147
  }
2388
3148
  console.log(`Installed ${Object.keys(HOOK_SCRIPTS).length} hook scripts to ${paths.hooks}`);
2389
3149
  }
@@ -2393,8 +3153,8 @@ var init_install = __esm(() => {
2393
3153
  });
2394
3154
 
2395
3155
  // src/hooks/settings.ts
2396
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
2397
- import { join as join7, dirname as dirname3 } from "path";
3156
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4 } from "fs";
3157
+ import { join as join8, dirname as dirname4 } from "path";
2398
3158
  import { homedir as homedir2 } from "os";
2399
3159
  function isBertrandGroup(group) {
2400
3160
  return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
@@ -2402,7 +3162,7 @@ function isBertrandGroup(group) {
2402
3162
  function installHookSettings() {
2403
3163
  let settings = {};
2404
3164
  try {
2405
- settings = JSON.parse(readFileSync5(SETTINGS_PATH, "utf-8"));
3165
+ settings = JSON.parse(readFileSync8(SETTINGS_PATH, "utf-8"));
2406
3166
  } catch {}
2407
3167
  const existingHooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
2408
3168
  const merged = { ...existingHooks };
@@ -2411,15 +3171,15 @@ function installHookSettings() {
2411
3171
  merged[eventType] = [...existing, ...bertrandGroups];
2412
3172
  }
2413
3173
  settings.hooks = merged;
2414
- mkdirSync4(dirname3(SETTINGS_PATH), { recursive: true });
2415
- writeFileSync3(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
3174
+ mkdirSync4(dirname4(SETTINGS_PATH), { recursive: true });
3175
+ writeFileSync5(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
2416
3176
  `);
2417
3177
  console.log(`Updated ${SETTINGS_PATH} with bertrand hooks`);
2418
3178
  }
2419
3179
  var SETTINGS_PATH, BERTRAND_HOOKS;
2420
3180
  var init_settings = __esm(() => {
2421
3181
  init_paths();
2422
- SETTINGS_PATH = join7(homedir2(), ".claude", "settings.json");
3182
+ SETTINGS_PATH = join8(homedir2(), ".claude", "settings.json");
2423
3183
  BERTRAND_HOOKS = {
2424
3184
  PreToolUse: [
2425
3185
  {
@@ -2463,8 +3223,8 @@ var init_settings = __esm(() => {
2463
3223
  });
2464
3224
 
2465
3225
  // src/lib/completions.ts
2466
- import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync5 } from "fs";
2467
- import { join as join8 } from "path";
3226
+ import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5 } from "fs";
3227
+ import { join as join9 } from "path";
2468
3228
  function bashCompletion() {
2469
3229
  return `# bertrand bash completion
2470
3230
  _bertrand() {
@@ -2494,11 +3254,11 @@ function fishCompletion() {
2494
3254
  `;
2495
3255
  }
2496
3256
  function generateCompletions() {
2497
- const dir = join8(paths.root, "completions");
3257
+ const dir = join9(paths.root, "completions");
2498
3258
  mkdirSync5(dir, { recursive: true });
2499
- writeFileSync4(join8(dir, "bertrand.bash"), bashCompletion());
2500
- writeFileSync4(join8(dir, "_bertrand"), zshCompletion());
2501
- writeFileSync4(join8(dir, "bertrand.fish"), fishCompletion());
3259
+ writeFileSync6(join9(dir, "bertrand.bash"), bashCompletion());
3260
+ writeFileSync6(join9(dir, "_bertrand"), zshCompletion());
3261
+ writeFileSync6(join9(dir, "bertrand.fish"), fishCompletion());
2502
3262
  console.log(`Shell completions written to ${dir}`);
2503
3263
  console.log(" Add to your shell config:");
2504
3264
  console.log(` bash: source ${dir}/bertrand.bash`);
@@ -2525,9 +3285,9 @@ var init_completions = __esm(() => {
2525
3285
 
2526
3286
  // src/cli/commands/init.ts
2527
3287
  var exports_init = {};
2528
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
3288
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync7, chmodSync as chmodSync3 } from "fs";
2529
3289
  import { execSync as execSync2 } from "child_process";
2530
- import { join as join9 } from "path";
3290
+ import { join as join10 } from "path";
2531
3291
  function detectTerminal() {
2532
3292
  try {
2533
3293
  execSync2("which wsh", { stdio: "ignore" });
@@ -2536,35 +3296,24 @@ function detectTerminal() {
2536
3296
  return "other";
2537
3297
  }
2538
3298
  }
2539
- function writeConfig(config) {
2540
- writeFileSync5(CONFIG_PATH, JSON.stringify(config, null, 2) + `
2541
- `);
2542
- }
2543
- function readConfig() {
2544
- try {
2545
- return JSON.parse(readFileSync6(CONFIG_PATH, "utf-8"));
2546
- } catch {
2547
- return null;
2548
- }
2549
- }
2550
3299
  function resolveBin() {
2551
3300
  const onPath = Bun.which("bertrand");
2552
3301
  if (onPath)
2553
3302
  return onPath;
2554
3303
  const entry = process.argv[1];
2555
3304
  if (entry && SOURCE_ENTRY.test(entry)) {
2556
- const launcherDir = join9(paths.root, "bin");
2557
- const launcher = join9(launcherDir, "bertrand-dev");
3305
+ const launcherDir = join10(paths.root, "bin");
3306
+ const launcher = join10(launcherDir, "bertrand-dev");
2558
3307
  mkdirSync6(launcherDir, { recursive: true });
2559
- writeFileSync5(launcher, `#!/usr/bin/env bash
3308
+ writeFileSync7(launcher, `#!/usr/bin/env bash
2560
3309
  exec ${process.execPath} ${JSON.stringify(entry)} "$@"
2561
3310
  `);
2562
- chmodSync2(launcher, 493);
3311
+ chmodSync3(launcher, 493);
2563
3312
  return launcher;
2564
3313
  }
2565
3314
  return null;
2566
3315
  }
2567
- var CONFIG_PATH, SOURCE_ENTRY;
3316
+ var SOURCE_ENTRY;
2568
3317
  var init_init = __esm(() => {
2569
3318
  init_router();
2570
3319
  init_migrate();
@@ -2572,7 +3321,7 @@ var init_init = __esm(() => {
2572
3321
  init_settings();
2573
3322
  init_paths();
2574
3323
  init_completions();
2575
- CONFIG_PATH = join9(paths.root, "config.json");
3324
+ init_config2();
2576
3325
  SOURCE_ENTRY = /\/src\/index\.tsx?$/;
2577
3326
  register("init", async () => {
2578
3327
  console.log(`bertrand init
@@ -3122,10 +3871,10 @@ var init_log = __esm(() => {
3122
3871
 
3123
3872
  // src/cli/commands/stats.ts
3124
3873
  var exports_stats = {};
3125
- function getMetrics(sessionId, name, status) {
3874
+ function getMetrics(sessionId, name, status2) {
3126
3875
  const stats = getSessionStats(sessionId);
3127
3876
  if (stats) {
3128
- return { name, status, ...stats };
3877
+ return { name, status: status2, ...stats };
3129
3878
  }
3130
3879
  const timing = computeTimingsLive(sessionId);
3131
3880
  const allEvents = getEventsBySession(sessionId);
@@ -3142,7 +3891,7 @@ function getMetrics(sessionId, name, status) {
3142
3891
  }
3143
3892
  return {
3144
3893
  name,
3145
- status,
3894
+ status: status2,
3146
3895
  eventCount: allEvents.length,
3147
3896
  conversationCount: conversations2.size,
3148
3897
  interactionCount,
@@ -3390,7 +4139,8 @@ var hotPath = {
3390
4139
  "recap-thinking": () => Promise.resolve().then(() => (init_recap_thinking(), exports_recap_thinking)),
3391
4140
  badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
3392
4141
  notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
3393
- serve: () => Promise.resolve().then(() => (init_serve(), exports_serve))
4142
+ serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
4143
+ sync: () => Promise.resolve().then(() => (init_sync(), exports_sync))
3394
4144
  };
3395
4145
  if (command && command in hotPath) {
3396
4146
  await hotPath[command]();
@@ -3409,7 +4159,8 @@ if (command && command in hotPath) {
3409
4159
  Promise.resolve().then(() => (init_recap_thinking(), exports_recap_thinking)),
3410
4160
  Promise.resolve().then(() => (init_serve(), exports_serve)),
3411
4161
  Promise.resolve().then(() => (init_badge(), exports_badge)),
3412
- Promise.resolve().then(() => (init_notify(), exports_notify))
4162
+ Promise.resolve().then(() => (init_notify(), exports_notify)),
4163
+ Promise.resolve().then(() => (init_sync(), exports_sync))
3413
4164
  ]);
3414
4165
  }
3415
4166
  await route(process.argv);
@@ -577,7 +577,8 @@ var paths = {
577
577
  root: join(homedir(), BERTRAND_DIR),
578
578
  db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
579
579
  hooks: join(homedir(), BERTRAND_DIR, "hooks"),
580
- sessions: join(homedir(), BERTRAND_DIR, "sessions")
580
+ sessions: join(homedir(), BERTRAND_DIR, "sessions"),
581
+ syncEnv: join(homedir(), BERTRAND_DIR, "sync.env")
581
582
  };
582
583
 
583
584
  // src/db/schema.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bertrand",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@orchetron/storm": "^0.2.0",
50
+ "@supabase/supabase-js": "^2.108.1",
50
51
  "drizzle-orm": "^0.44.0",
51
52
  "nanoid": "^5.1.5",
52
53
  "react": "^19.2.5"