bertrand 0.18.0 → 0.20.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
@@ -23,20 +23,325 @@ var BERTRAND_DIR = ".bertrand", paths;
23
23
  var init_paths = __esm(() => {
24
24
  paths = {
25
25
  root: join(homedir(), BERTRAND_DIR),
26
- db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
27
26
  hooks: join(homedir(), BERTRAND_DIR, "hooks"),
28
27
  sessions: join(homedir(), BERTRAND_DIR, "sessions"),
28
+ db: join(homedir(), BERTRAND_DIR, "bertrand.db"),
29
29
  syncEnv: join(homedir(), BERTRAND_DIR, "sync.env")
30
30
  };
31
31
  });
32
32
 
33
- // src/sync/config.ts
33
+ // src/lib/projects/registry.ts
34
34
  import {
35
+ existsSync,
36
+ mkdirSync,
35
37
  readFileSync,
38
+ readdirSync,
39
+ renameSync,
36
40
  statSync,
37
- writeFileSync,
41
+ writeFileSync
42
+ } from "fs";
43
+ import { randomBytes } from "crypto";
44
+ import { join as join2 } from "path";
45
+ function _getRegistryDir() {
46
+ return _registryDir;
47
+ }
48
+ function registryPath() {
49
+ return join2(_registryDir, "projects.json");
50
+ }
51
+ function projectsDir() {
52
+ return join2(_registryDir, "projects");
53
+ }
54
+ function readRegistry() {
55
+ const path = registryPath();
56
+ if (!existsSync(path))
57
+ return null;
58
+ try {
59
+ const raw = readFileSync(path, "utf8");
60
+ const parsed = JSON.parse(raw);
61
+ if (!isRegistry(parsed))
62
+ return null;
63
+ return parsed;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+ function writeRegistry(registry) {
69
+ const path = registryPath();
70
+ mkdirSync(_registryDir, { recursive: true });
71
+ const tmp = `${path}.tmp-${process.pid}-${process.hrtime.bigint()}-${randomBytes(4).toString("hex")}`;
72
+ writeFileSync(tmp, JSON.stringify(registry, null, 2) + `
73
+ `);
74
+ renameSync(tmp, path);
75
+ }
76
+ function recoverFromDisk() {
77
+ const dir = projectsDir();
78
+ if (!existsSync(dir))
79
+ return null;
80
+ let entries;
81
+ try {
82
+ entries = readdirSync(dir);
83
+ } catch {
84
+ return null;
85
+ }
86
+ const projects = [];
87
+ for (const slug of entries) {
88
+ const subdir = join2(dir, slug);
89
+ let mtime;
90
+ try {
91
+ const s = statSync(subdir);
92
+ if (!s.isDirectory())
93
+ continue;
94
+ mtime = new Date(s.mtimeMs);
95
+ } catch {
96
+ continue;
97
+ }
98
+ if (!existsSync(join2(subdir, "bertrand.db")))
99
+ continue;
100
+ projects.push({
101
+ slug,
102
+ name: slug,
103
+ createdAt: mtime.toISOString(),
104
+ lastUsedAt: mtime.toISOString()
105
+ });
106
+ }
107
+ if (projects.length === 0)
108
+ return null;
109
+ projects.sort((a, b) => a.slug.localeCompare(b.slug));
110
+ const envSlug = process.env.BERTRAND_PROJECT;
111
+ const hasEnv = !!envSlug && projects.some((p) => p.slug === envSlug);
112
+ const hasDefault = projects.some((p) => p.slug === DEFAULT_PROJECT_SLUG);
113
+ const activeProjectSlug = hasEnv ? envSlug : hasDefault ? DEFAULT_PROJECT_SLUG : projects[0].slug;
114
+ return { activeProjectSlug, projects };
115
+ }
116
+ function loadRegistry() {
117
+ return readRegistry() ?? recoverFromDisk();
118
+ }
119
+ function listProjects() {
120
+ return loadRegistry()?.projects ?? [];
121
+ }
122
+ function getActiveProjectSlug() {
123
+ return loadRegistry()?.activeProjectSlug ?? DEFAULT_PROJECT_SLUG;
124
+ }
125
+ function projectExists(slug) {
126
+ return listProjects().some((p) => p.slug === slug);
127
+ }
128
+ function setActiveProjectSlug(slug) {
129
+ const registry = loadRegistry();
130
+ if (!registry) {
131
+ throw new Error(`No registry to update \u2014 create a project first (no ${registryPath()} and no ${projectsDir()}/* to recover from)`);
132
+ }
133
+ if (!registry.projects.some((p) => p.slug === slug)) {
134
+ throw new Error(`Unknown project slug "${slug}"`);
135
+ }
136
+ registry.activeProjectSlug = slug;
137
+ const idx = registry.projects.findIndex((p) => p.slug === slug);
138
+ if (idx !== -1) {
139
+ registry.projects[idx].lastUsedAt = new Date().toISOString();
140
+ }
141
+ writeRegistry(registry);
142
+ }
143
+ function registerProject(opts) {
144
+ const registry = loadRegistry() ?? {
145
+ activeProjectSlug: opts.slug,
146
+ projects: []
147
+ };
148
+ if (registry.projects.some((p) => p.slug === opts.slug)) {
149
+ throw new Error(`Project "${opts.slug}" already exists`);
150
+ }
151
+ const now = new Date().toISOString();
152
+ const entry = {
153
+ slug: opts.slug,
154
+ name: opts.name,
155
+ color: opts.color,
156
+ createdAt: now,
157
+ lastUsedAt: now
158
+ };
159
+ registry.projects.push(entry);
160
+ writeRegistry(registry);
161
+ return entry;
162
+ }
163
+ function renameProject(slug, newName) {
164
+ const registry = loadRegistry();
165
+ if (!registry) {
166
+ throw new Error(`No registry to update \u2014 create a project first`);
167
+ }
168
+ const entry = registry.projects.find((p) => p.slug === slug);
169
+ if (!entry) {
170
+ throw new Error(`Unknown project slug "${slug}"`);
171
+ }
172
+ entry.name = newName;
173
+ writeRegistry(registry);
174
+ }
175
+ function removeProject(slug) {
176
+ const registry = loadRegistry();
177
+ if (!registry)
178
+ return;
179
+ registry.projects = registry.projects.filter((p) => p.slug !== slug);
180
+ if (registry.activeProjectSlug === slug) {
181
+ const survivor = [...registry.projects].sort((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt))[0];
182
+ registry.activeProjectSlug = survivor?.slug ?? DEFAULT_PROJECT_SLUG;
183
+ }
184
+ writeRegistry(registry);
185
+ }
186
+ function isRegistry(value) {
187
+ if (typeof value !== "object" || value === null)
188
+ return false;
189
+ const r = value;
190
+ if (typeof r.activeProjectSlug !== "string")
191
+ return false;
192
+ if (!Array.isArray(r.projects))
193
+ return false;
194
+ return r.projects.every((p) => {
195
+ if (typeof p !== "object" || p === null)
196
+ return false;
197
+ const e = p;
198
+ return typeof e.slug === "string" && typeof e.name === "string" && typeof e.createdAt === "string" && typeof e.lastUsedAt === "string";
199
+ });
200
+ }
201
+ var DEFAULT_PROJECT_SLUG = "default", _registryDir;
202
+ var init_registry = __esm(() => {
203
+ init_paths();
204
+ _registryDir = paths.root;
205
+ });
206
+
207
+ // src/lib/projects/paths.ts
208
+ import { join as join3 } from "path";
209
+ function projectPaths(slug) {
210
+ const root = join3(_getRegistryDir(), "projects", slug);
211
+ return {
212
+ root,
213
+ db: join3(root, "bertrand.db"),
214
+ syncEnv: join3(root, "sync.env"),
215
+ snapshots: join3(root, "snapshots")
216
+ };
217
+ }
218
+ var init_paths2 = __esm(() => {
219
+ init_registry();
220
+ });
221
+
222
+ // src/lib/projects/resolve.ts
223
+ function resolveActiveProject() {
224
+ if (_cached)
225
+ return _cached;
226
+ const envSlug = process.env.BERTRAND_PROJECT;
227
+ let slug;
228
+ if (envSlug && envSlug.trim()) {
229
+ slug = envSlug.trim();
230
+ } else {
231
+ slug = getActiveProjectSlug();
232
+ }
233
+ const entry = listProjects().find((p) => p.slug === slug);
234
+ const name = entry?.name ?? slug;
235
+ _cached = { slug, name, ...projectPaths(slug) };
236
+ return _cached;
237
+ }
238
+ function _resetActiveProjectCache() {
239
+ _cached = null;
240
+ }
241
+ var _cached = null;
242
+ var init_resolve = __esm(() => {
243
+ init_registry();
244
+ init_paths2();
245
+ });
246
+
247
+ // src/lib/lsof.ts
248
+ import { execFileSync } from "child_process";
249
+ function findHolders(path) {
250
+ try {
251
+ const out = execFileSync("lsof", ["-F", "pcn", path], {
252
+ encoding: "utf8",
253
+ stdio: ["ignore", "pipe", "ignore"]
254
+ });
255
+ const holders = [];
256
+ let pid = 0;
257
+ let command = "";
258
+ for (const line of out.split(`
259
+ `)) {
260
+ if (line.startsWith("p"))
261
+ pid = Number(line.slice(1));
262
+ else if (line.startsWith("c"))
263
+ command = line.slice(1);
264
+ else if (line.startsWith("n")) {
265
+ if (pid && pid !== process.pid && command) {
266
+ holders.push({ pid, command });
267
+ }
268
+ }
269
+ }
270
+ return holders;
271
+ } catch {
272
+ return [];
273
+ }
274
+ }
275
+ var init_lsof = () => {};
276
+
277
+ // src/lib/projects/migrate-layout.ts
278
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync as renameSync2 } from "fs";
279
+ import { join as join4 } from "path";
280
+ function migrateLegacyLayout() {
281
+ const root = _getRegistryDir();
282
+ const registryFile = join4(root, "projects.json");
283
+ const projectsDir2 = join4(root, "projects");
284
+ if (existsSync2(registryFile)) {
285
+ return { migrated: false, reason: "already-migrated" };
286
+ }
287
+ if (existsSync2(projectsDir2)) {
288
+ return { migrated: false, reason: "already-migrated" };
289
+ }
290
+ const legacyDb = join4(root, "bertrand.db");
291
+ const legacySyncEnv = join4(root, "sync.env");
292
+ if (!existsSync2(legacyDb) && !existsSync2(legacySyncEnv)) {
293
+ return { migrated: false, reason: "fresh-install" };
294
+ }
295
+ if (existsSync2(legacyDb)) {
296
+ const holders = findHolders(legacyDb);
297
+ if (holders.length > 0) {
298
+ return { migrated: false, reason: "db-held", holders };
299
+ }
300
+ }
301
+ const destPaths = projectPaths(DEFAULT_PROJECT_SLUG);
302
+ mkdirSync2(destPaths.root, { recursive: true });
303
+ const moved = [];
304
+ for (const suffix of ["", "-wal", "-shm"]) {
305
+ const src = legacyDb + suffix;
306
+ if (!existsSync2(src))
307
+ continue;
308
+ const dst = destPaths.db + suffix;
309
+ renameSync2(src, dst);
310
+ moved.push(`bertrand.db${suffix}`);
311
+ }
312
+ if (existsSync2(legacySyncEnv)) {
313
+ renameSync2(legacySyncEnv, destPaths.syncEnv);
314
+ moved.push("sync.env");
315
+ }
316
+ const now = new Date().toISOString();
317
+ writeRegistry({
318
+ activeProjectSlug: DEFAULT_PROJECT_SLUG,
319
+ projects: [
320
+ {
321
+ slug: DEFAULT_PROJECT_SLUG,
322
+ name: "Default",
323
+ createdAt: now,
324
+ lastUsedAt: now
325
+ }
326
+ ]
327
+ });
328
+ _resetActiveProjectCache();
329
+ return { migrated: true, moved };
330
+ }
331
+ var init_migrate_layout = __esm(() => {
332
+ init_lsof();
333
+ init_registry();
334
+ init_paths2();
335
+ init_resolve();
336
+ });
337
+
338
+ // src/sync/config.ts
339
+ import {
340
+ readFileSync as readFileSync2,
341
+ statSync as statSync2,
342
+ writeFileSync as writeFileSync2,
38
343
  chmodSync,
39
- existsSync
344
+ existsSync as existsSync3
40
345
  } from "fs";
41
346
  function parseEnv(contents) {
42
347
  const out = {};
@@ -58,22 +363,26 @@ function parseEnv(contents) {
58
363
  }
59
364
  return out;
60
365
  }
61
- function hasSyncConfig() {
62
- return existsSync(paths.syncEnv);
366
+ function defaultPath() {
367
+ return resolveActiveProject().syncEnv;
63
368
  }
64
- function loadSyncConfig() {
65
- if (!existsSync(paths.syncEnv))
369
+ function hasSyncConfig(syncEnvPath) {
370
+ return existsSync3(syncEnvPath ?? defaultPath());
371
+ }
372
+ function loadSyncConfig(syncEnvPath) {
373
+ const path = syncEnvPath ?? defaultPath();
374
+ if (!existsSync3(path))
66
375
  return null;
67
- const mode = statSync(paths.syncEnv).mode & 511;
376
+ const mode = statSync2(path).mode & 511;
68
377
  if (mode & 63) {
69
- console.warn(`warning: ${paths.syncEnv} was mode 0${mode.toString(8)} (should be 0600). Tightening to 0600.`);
378
+ console.warn(`warning: ${path} was mode 0${mode.toString(8)} (should be 0600). Tightening to 0600.`);
70
379
  try {
71
- chmodSync(paths.syncEnv, 384);
380
+ chmodSync(path, 384);
72
381
  } catch (e) {
73
382
  console.warn(`failed to chmod 0600: ${e instanceof Error ? e.message : String(e)}. Fix manually.`);
74
383
  }
75
384
  }
76
- const env = parseEnv(readFileSync(paths.syncEnv, "utf8"));
385
+ const env = parseEnv(readFileSync2(path, "utf8"));
77
386
  if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_KEY || !env.BERTRAND_SYNC_BUCKET || !env.BERTRAND_ENCRYPTION_KEY) {
78
387
  return null;
79
388
  }
@@ -86,7 +395,8 @@ function loadSyncConfig() {
86
395
  clientName: env.BERTRAND_CLIENT_NAME || `bertrand-${process.platform}-${process.pid}`
87
396
  };
88
397
  }
89
- function saveSyncConfig(cfg) {
398
+ function saveSyncConfig(cfg, syncEnvPath) {
399
+ const path = syncEnvPath ?? defaultPath();
90
400
  const lines = [
91
401
  "# bertrand sync configuration",
92
402
  "# Created by `bertrand sync onboard`. chmod 600.",
@@ -105,13 +415,13 @@ function saveSyncConfig(cfg) {
105
415
  `BERTRAND_CLIENT_NAME=${cfg.clientName}`,
106
416
  ""
107
417
  ];
108
- writeFileSync(paths.syncEnv, lines.join(`
418
+ writeFileSync2(path, lines.join(`
109
419
  `), { mode: 384 });
110
- chmodSync(paths.syncEnv, 384);
420
+ chmodSync(path, 384);
111
421
  }
112
422
  var KEYS;
113
423
  var init_config = __esm(() => {
114
- init_paths();
424
+ init_resolve();
115
425
  KEYS = [
116
426
  "SUPABASE_URL",
117
427
  "SUPABASE_SERVICE_KEY",
@@ -123,17 +433,21 @@ var init_config = __esm(() => {
123
433
  });
124
434
 
125
435
  // src/lib/config.ts
126
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
127
- import { join as join2 } from "path";
436
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
437
+ import { join as join5 } from "path";
438
+ function configPath() {
439
+ return join5(_getRegistryDir(), "config.json");
440
+ }
128
441
  function readConfig() {
129
442
  try {
130
- return JSON.parse(readFileSync2(CONFIG_PATH, "utf-8"));
443
+ return JSON.parse(readFileSync3(configPath(), "utf-8"));
131
444
  } catch {
132
445
  return null;
133
446
  }
134
447
  }
135
448
  function writeConfig(config) {
136
- writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2) + `
449
+ mkdirSync3(_getRegistryDir(), { recursive: true });
450
+ writeFileSync3(configPath(), JSON.stringify(config, null, 2) + `
137
451
  `);
138
452
  }
139
453
  function patchConfig(patch) {
@@ -160,10 +474,8 @@ function isPlainObject(v) {
160
474
  function isSyncEnabled() {
161
475
  return readConfig()?.sync?.enabled === true;
162
476
  }
163
- var CONFIG_PATH;
164
477
  var init_config2 = __esm(() => {
165
- init_paths();
166
- CONFIG_PATH = join2(paths.root, "config.json");
478
+ init_registry();
167
479
  });
168
480
 
169
481
  // src/sync/trigger.ts
@@ -191,15 +503,29 @@ var init_trigger = __esm(() => {
191
503
  });
192
504
 
193
505
  // src/cli/router.ts
194
- import { existsSync as existsSync2 } from "fs";
506
+ import { existsSync as existsSync4 } from "fs";
195
507
  function register(name, handler) {
196
508
  commands.set(name, handler);
197
509
  }
198
510
  function alias(from, to) {
199
511
  aliases.set(from, to);
200
512
  }
513
+ function migrateOrAbort() {
514
+ const result = migrateLegacyLayout();
515
+ if (result.migrated) {
516
+ const fileList = result.moved.join(", ");
517
+ console.log(`Migrated to per-project layout (moved: ${fileList}). Active project: ${DEFAULT_PROJECT_SLUG}.`);
518
+ return;
519
+ }
520
+ if (!result.migrated && result.reason === "db-held") {
521
+ const procs = result.holders.map((h) => `${h.command}(${h.pid})`).join(", ");
522
+ console.error(`Cannot migrate to per-project layout: legacy database is held by ${procs}.
523
+ ` + `Close all active bertrand sessions and try again.`);
524
+ process.exit(1);
525
+ }
526
+ }
201
527
  async function autoInitIfFirstRun() {
202
- if (existsSync2(paths.db))
528
+ if (existsSync4(resolveActiveProject().db))
203
529
  return;
204
530
  const init = commands.get("init");
205
531
  if (!init)
@@ -216,6 +542,9 @@ async function autoInitIfFirstRun() {
216
542
  async function route(argv) {
217
543
  const args = argv.slice(2);
218
544
  const command = args[0];
545
+ if (!command || !HOOK_COMMANDS.has(command)) {
546
+ migrateOrAbort();
547
+ }
219
548
  if (!command) {
220
549
  await autoInitIfFirstRun();
221
550
  triggerBackgroundPull();
@@ -252,17 +581,28 @@ Usage:
252
581
  bertrand log <session> View session log
253
582
  bertrand stats <session> Session statistics
254
583
  bertrand archive <name> Archive/unarchive a session
584
+ bertrand project <op> list|create|switch|current|rename|remove (see: bertrand project --help)
255
585
  bertrand update Hook-facing state writer (internal)
256
586
  bertrand serve Start dashboard HTTP server
257
587
  bertrand sync <op> push|pull|status|onboard (see: bertrand sync --help)
258
588
  `.trim());
259
589
  }
260
- var commands, aliases;
590
+ var commands, aliases, HOOK_COMMANDS;
261
591
  var init_router = __esm(() => {
262
- init_paths();
592
+ init_resolve();
593
+ init_migrate_layout();
594
+ init_registry();
263
595
  init_trigger();
264
596
  commands = new Map;
265
597
  aliases = new Map;
598
+ HOOK_COMMANDS = new Set([
599
+ "update",
600
+ "snapshot",
601
+ "recap-thinking",
602
+ "assistant-message",
603
+ "notify",
604
+ "badge"
605
+ ]);
266
606
  });
267
607
 
268
608
  // src/db/schema.ts
@@ -273,17 +613,17 @@ __export(exports_schema, {
273
613
  sessionStats: () => sessionStats,
274
614
  sessionLabels: () => sessionLabels,
275
615
  labels: () => labels,
276
- groups: () => groups,
277
616
  events: () => events,
278
- conversations: () => conversations
617
+ conversations: () => conversations,
618
+ categories: () => categories
279
619
  });
280
620
  import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
281
621
  import { sql } from "drizzle-orm";
282
- var groups, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
622
+ var categories, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
283
623
  var init_schema = __esm(() => {
284
- groups = sqliteTable("groups", {
624
+ categories = sqliteTable("categories", {
285
625
  id: text("id").primaryKey(),
286
- parentId: text("parent_id").references(() => groups.id, {
626
+ parentId: text("parent_id").references(() => categories.id, {
287
627
  onDelete: "cascade"
288
628
  }),
289
629
  slug: text("slug").notNull(),
@@ -293,8 +633,8 @@ var init_schema = __esm(() => {
293
633
  color: text("color"),
294
634
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`)
295
635
  }, (t) => [
296
- uniqueIndex("groups_parent_slug").on(t.parentId, t.slug),
297
- index("groups_path").on(t.path)
636
+ uniqueIndex("categories_parent_slug").on(t.parentId, t.slug),
637
+ index("categories_path").on(t.path)
298
638
  ]);
299
639
  labels = sqliteTable("labels", {
300
640
  id: text("id").primaryKey(),
@@ -304,7 +644,7 @@ var init_schema = __esm(() => {
304
644
  });
305
645
  sessions = sqliteTable("sessions", {
306
646
  id: text("id").primaryKey(),
307
- groupId: text("group_id").notNull().references(() => groups.id, { onDelete: "cascade" }),
647
+ categoryId: text("category_id").notNull().references(() => categories.id, { onDelete: "cascade" }),
308
648
  slug: text("slug").notNull(),
309
649
  name: text("name").notNull(),
310
650
  status: text("status", {
@@ -317,7 +657,7 @@ var init_schema = __esm(() => {
317
657
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
318
658
  updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`)
319
659
  }, (t) => [
320
- uniqueIndex("sessions_group_slug").on(t.groupId, t.slug),
660
+ uniqueIndex("sessions_category_slug").on(t.categoryId, t.slug),
321
661
  index("sessions_status").on(t.status),
322
662
  index("sessions_started").on(t.startedAt)
323
663
  ]);
@@ -383,25 +723,70 @@ var init_schema = __esm(() => {
383
723
  // src/db/client.ts
384
724
  import { Database } from "bun:sqlite";
385
725
  import { drizzle } from "drizzle-orm/bun-sqlite";
386
- import { mkdirSync } from "fs";
726
+ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
727
+ import { mkdirSync as mkdirSync4 } from "fs";
387
728
  import { dirname } from "path";
388
729
  function getDb() {
389
- if (_db)
390
- return _db;
391
- mkdirSync(dirname(paths.db), { recursive: true });
392
- const sqlite = new Database(paths.db);
730
+ if (_testDb)
731
+ return _testDb;
732
+ return openDb(resolveActiveProject().db);
733
+ }
734
+ function getDbForProject(slug) {
735
+ if (_testDb)
736
+ return _testDb;
737
+ return openDb(projectPaths(slug).db);
738
+ }
739
+ function invalidateDbCache(slug) {
740
+ if (!slug) {
741
+ _cache.clear();
742
+ _migrated.clear();
743
+ return;
744
+ }
745
+ const dbPath = projectPaths(slug).db;
746
+ _cache.delete(dbPath);
747
+ _migrated.delete(dbPath);
748
+ }
749
+ function openDb(dbPath) {
750
+ const cached = _cache.get(dbPath);
751
+ if (cached)
752
+ return cached;
753
+ mkdirSync4(dirname(dbPath), { recursive: true });
754
+ const sqlite = new Database(dbPath);
755
+ sqlite.exec("PRAGMA busy_timeout = 5000");
393
756
  sqlite.exec("PRAGMA journal_mode = WAL");
394
757
  sqlite.exec("PRAGMA foreign_keys = ON");
395
758
  sqlite.exec("PRAGMA synchronous = NORMAL");
396
759
  sqlite.exec("PRAGMA cache_size = -8000");
397
760
  sqlite.exec("PRAGMA temp_store = MEMORY");
398
- _db = drizzle(sqlite, { schema: exports_schema });
399
- return _db;
761
+ const db = drizzle(sqlite, { schema: exports_schema });
762
+ if (!_migrated.has(dbPath)) {
763
+ try {
764
+ migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
765
+ if (!hasSessionsTable(sqlite)) {
766
+ sqlite.exec("DROP TABLE IF EXISTS __drizzle_migrations");
767
+ migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
768
+ }
769
+ } catch (err) {
770
+ sqlite.close();
771
+ throw err;
772
+ }
773
+ _migrated.add(dbPath);
774
+ }
775
+ _cache.set(dbPath, db);
776
+ return db;
400
777
  }
401
- var _db = null;
778
+ function hasSessionsTable(sqlite) {
779
+ const row = sqlite.query("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'").get();
780
+ return row !== null;
781
+ }
782
+ var MIGRATIONS_FOLDER, _cache, _migrated, _testDb = null;
402
783
  var init_client = __esm(() => {
403
- init_paths();
784
+ init_resolve();
785
+ init_paths2();
404
786
  init_schema();
787
+ MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
788
+ _cache = new Map;
789
+ _migrated = new Set;
405
790
  });
406
791
 
407
792
  // src/lib/id.ts
@@ -421,18 +806,18 @@ function createSession(opts) {
421
806
  function getSession(id) {
422
807
  return getDb().select().from(sessions).where(eq(sessions.id, id)).get();
423
808
  }
424
- function getSessionByGroupSlug(groupId, slug) {
425
- return getDb().select().from(sessions).where(and(eq(sessions.groupId, groupId), eq(sessions.slug, slug))).get();
809
+ function getSessionByCategorySlug(categoryId, slug) {
810
+ return getDb().select().from(sessions).where(and(eq(sessions.categoryId, categoryId), eq(sessions.slug, slug))).get();
426
811
  }
427
- function getSessionsByGroup(groupId) {
428
- return getDb().select().from(sessions).where(eq(sessions.groupId, groupId)).all();
812
+ function getSessionsByCategory(categoryId) {
813
+ return getDb().select().from(sessions).where(eq(sessions.categoryId, categoryId)).all();
429
814
  }
430
815
  function getActiveSessions() {
431
- return getDb().select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
816
+ return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
432
817
  }
433
818
  function getAllSessions(opts) {
434
819
  const db = getDb();
435
- const query = db.select({ session: sessions, groupPath: groups.path }).from(sessions).innerJoin(groups, eq(sessions.groupId, groups.id));
820
+ const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq(sessions.categoryId, categories.id));
436
821
  if (opts?.excludeArchived) {
437
822
  return query.where(inArray(sessions.status, [
438
823
  "active",
@@ -457,6 +842,28 @@ var init_sessions = __esm(() => {
457
842
  init_id();
458
843
  });
459
844
 
845
+ // src/db/queries/conversations.ts
846
+ import { eq as eq2, and as and2, desc, sql as sql3 } from "drizzle-orm";
847
+ function createConversation(opts) {
848
+ return getDb().insert(conversations).values(opts).returning().get();
849
+ }
850
+ function getConversation(id) {
851
+ return getDb().select().from(conversations).where(eq2(conversations.id, id)).get();
852
+ }
853
+ function getConversationsBySession(sessionId) {
854
+ return getDb().select().from(conversations).where(and2(eq2(conversations.sessionId, sessionId), eq2(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
855
+ }
856
+ function endConversation(id) {
857
+ return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq2(conversations.id, id)).returning().get();
858
+ }
859
+ function updateLastQuestion(id, question) {
860
+ return getDb().update(conversations).set({ lastQuestion: question }).where(eq2(conversations.id, id)).returning().get();
861
+ }
862
+ var init_conversations = __esm(() => {
863
+ init_client();
864
+ init_schema();
865
+ });
866
+
460
867
  // src/lib/markdown.ts
461
868
  function normalizeMarkdown(input) {
462
869
  return input.replace(/\r\n?/g, `
@@ -506,7 +913,7 @@ function normalizeAnsweredMeta(meta) {
506
913
  }
507
914
 
508
915
  // src/db/queries/events.ts
509
- import { eq as eq2, and as and2, desc } from "drizzle-orm";
916
+ import { eq as eq3, and as and3, desc as desc2 } from "drizzle-orm";
510
917
  function insertEvent(opts) {
511
918
  return getDb().insert(events).values({
512
919
  sessionId: opts.sessionId,
@@ -517,17 +924,27 @@ function insertEvent(opts) {
517
924
  }).returning().get();
518
925
  }
519
926
  function getEventsBySession(sessionId) {
520
- return getDb().select().from(events).where(eq2(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
927
+ return getDb().select().from(events).where(eq3(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
521
928
  }
522
929
  function getEventsByType(sessionId, eventType) {
523
- return getDb().select().from(events).where(and2(eq2(events.sessionId, sessionId), eq2(events.event, eventType))).orderBy(events.createdAt).all();
930
+ return getDb().select().from(events).where(and3(eq3(events.sessionId, sessionId), eq3(events.event, eventType))).orderBy(events.createdAt).all();
931
+ }
932
+ function getLatestEventOfType(sessionId, eventType, conversationId) {
933
+ const conditions = [
934
+ eq3(events.sessionId, sessionId),
935
+ eq3(events.event, eventType)
936
+ ];
937
+ if (conversationId) {
938
+ conditions.push(eq3(events.conversationId, conversationId));
939
+ }
940
+ return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
524
941
  }
525
942
  function getLatestRecaps() {
526
943
  const rows = getDb().select({
527
944
  sessionId: events.sessionId,
528
945
  meta: events.meta,
529
946
  createdAt: events.createdAt
530
- }).from(events).where(eq2(events.event, "session.recap")).orderBy(desc(events.createdAt)).all();
947
+ }).from(events).where(eq3(events.event, "session.recap")).orderBy(desc2(events.createdAt)).all();
531
948
  const result = {};
532
949
  for (const row of rows) {
533
950
  if (result[row.sessionId])
@@ -545,37 +962,326 @@ var init_events = __esm(() => {
545
962
  init_schema();
546
963
  });
547
964
 
548
- // src/db/queries/conversations.ts
549
- import { eq as eq3, and as and3, desc as desc2, sql as sql3 } from "drizzle-orm";
550
- function createConversation(opts) {
551
- return getDb().insert(conversations).values(opts).returning().get();
965
+ // src/db/events/emit.ts
966
+ function emitSessionStarted(args) {
967
+ return insertEvent({
968
+ sessionId: args.sessionId,
969
+ conversationId: args.conversationId,
970
+ event: "session.started",
971
+ meta: {
972
+ category_path: args.categoryPath,
973
+ session_name: args.sessionName,
974
+ session_slug: args.sessionSlug,
975
+ labels: args.labels,
976
+ summary: args.summary ?? null
977
+ }
978
+ });
552
979
  }
553
- function getConversation(id) {
554
- return getDb().select().from(conversations).where(eq3(conversations.id, id)).get();
980
+ function emitSessionResumed(args) {
981
+ return insertEvent({
982
+ sessionId: args.sessionId,
983
+ conversationId: args.conversationId,
984
+ event: "session.resumed",
985
+ meta: { claude_id: args.conversationId }
986
+ });
555
987
  }
556
- function getConversationsBySession(sessionId) {
557
- return getDb().select().from(conversations).where(and3(eq3(conversations.sessionId, sessionId), eq3(conversations.discarded, false))).orderBy(desc2(conversations.startedAt)).all();
988
+ function emitSessionPaused(args) {
989
+ return insertEvent({
990
+ sessionId: args.sessionId,
991
+ conversationId: args.conversationId,
992
+ event: "session.paused",
993
+ meta: { claude_id: args.conversationId }
994
+ });
558
995
  }
559
- function endConversation(id) {
560
- return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq3(conversations.id, id)).returning().get();
996
+ function emitSessionPausedByRecovery(args) {
997
+ return insertEvent({
998
+ sessionId: args.sessionId,
999
+ event: "session.paused",
1000
+ summary: "Recovered from stale state (process not found)",
1001
+ meta: { stale_pid: args.stalePid }
1002
+ });
561
1003
  }
562
- function updateLastQuestion(id, question) {
563
- return getDb().update(conversations).set({ lastQuestion: question }).where(eq3(conversations.id, id)).returning().get();
1004
+ function emitSessionEnded(args) {
1005
+ return insertEvent({
1006
+ sessionId: args.sessionId,
1007
+ event: "session.end"
1008
+ });
564
1009
  }
565
- var init_conversations = __esm(() => {
566
- init_client();
567
- init_schema();
1010
+ function emitClaudeStarted(args) {
1011
+ return insertEvent({
1012
+ sessionId: args.sessionId,
1013
+ conversationId: args.conversationId,
1014
+ event: "claude.started",
1015
+ meta: {
1016
+ claude_id: args.conversationId,
1017
+ model: args.model,
1018
+ claude_version: args.claudeVersion,
1019
+ git: args.git,
1020
+ cwd: args.cwd
1021
+ }
1022
+ });
1023
+ }
1024
+ function emitClaudeEnded(args) {
1025
+ return insertEvent({
1026
+ sessionId: args.sessionId,
1027
+ conversationId: args.conversationId,
1028
+ event: "claude.ended",
1029
+ meta: { claude_id: args.conversationId, exit_code: args.exitCode }
1030
+ });
1031
+ }
1032
+ function emitUserPrompted(args) {
1033
+ return insertEvent({
1034
+ sessionId: args.sessionId,
1035
+ conversationId: args.conversationId,
1036
+ event: "user.prompt",
1037
+ meta: { prompt: args.prompt, claude_id: args.conversationId }
1038
+ });
1039
+ }
1040
+ function emitSessionWaiting(args) {
1041
+ return insertEvent({
1042
+ sessionId: args.sessionId,
1043
+ conversationId: args.conversationId,
1044
+ event: "session.waiting",
1045
+ summary: args.question,
1046
+ meta: { question: args.question, claude_id: args.conversationId }
1047
+ });
1048
+ }
1049
+ function emitSessionAnswered(args) {
1050
+ const joinedAnswers = Object.values(args.answers).map((v) => String(v)).join(", ") || undefined;
1051
+ return insertEvent({
1052
+ sessionId: args.sessionId,
1053
+ conversationId: args.conversationId,
1054
+ event: "session.answered",
1055
+ summary: joinedAnswers,
1056
+ meta: {
1057
+ answers: args.answers,
1058
+ annotations: args.annotations ?? {},
1059
+ questions: args.questions ?? [],
1060
+ claude_id: args.conversationId
1061
+ }
1062
+ });
1063
+ }
1064
+ function emitSessionRecap(args) {
1065
+ return insertEvent({
1066
+ sessionId: args.sessionId,
1067
+ conversationId: args.conversationId,
1068
+ event: "session.recap",
1069
+ summary: args.recap.slice(0, 200),
1070
+ meta: { recap: args.recap, claude_id: args.conversationId }
1071
+ });
1072
+ }
1073
+ function emitPermissionRequested(args) {
1074
+ return insertEvent({
1075
+ sessionId: args.sessionId,
1076
+ conversationId: args.conversationId,
1077
+ event: "permission.request",
1078
+ meta: {
1079
+ tool: args.tool,
1080
+ detail: args.detail,
1081
+ claude_id: args.conversationId
1082
+ }
1083
+ });
1084
+ }
1085
+ function emitPermissionResolved(args) {
1086
+ return insertEvent({
1087
+ sessionId: args.sessionId,
1088
+ conversationId: args.conversationId,
1089
+ event: "permission.resolve",
1090
+ meta: {
1091
+ tool: args.tool,
1092
+ detail: args.detail,
1093
+ outcome: args.outcome,
1094
+ claude_id: args.conversationId
1095
+ }
1096
+ });
1097
+ }
1098
+ function emitToolUsed(args) {
1099
+ const summary = formatToolSummary(args.tool, args.detail);
1100
+ return insertEvent({
1101
+ sessionId: args.sessionId,
1102
+ conversationId: args.conversationId,
1103
+ event: "tool.used",
1104
+ summary,
1105
+ meta: {
1106
+ tool: args.tool,
1107
+ detail: args.detail,
1108
+ outcome: args.outcome,
1109
+ claude_id: args.conversationId
1110
+ }
1111
+ });
1112
+ }
1113
+ function formatToolSummary(tool, detail) {
1114
+ if (!detail)
1115
+ return tool;
1116
+ switch (tool) {
1117
+ case "Bash":
1118
+ return `ran \`${detail.slice(0, 120)}\``;
1119
+ case "Read":
1120
+ return `read ${detail}`;
1121
+ case "Glob":
1122
+ case "Grep":
1123
+ return `${tool.toLowerCase()} ${detail}`;
1124
+ case "TodoWrite":
1125
+ return "updated todos";
1126
+ case "WebFetch":
1127
+ return `fetched ${detail}`;
1128
+ case "WebSearch":
1129
+ return `searched: ${detail}`;
1130
+ default:
1131
+ return `${tool}: ${detail}`;
1132
+ }
1133
+ }
1134
+ function emitToolApplied(args) {
1135
+ return insertEvent({
1136
+ sessionId: args.sessionId,
1137
+ conversationId: args.conversationId,
1138
+ event: "tool.applied",
1139
+ summary: args.summary,
1140
+ meta: {
1141
+ permissions: args.permissions,
1142
+ outcome: "applied",
1143
+ claude_id: args.conversationId
1144
+ }
1145
+ });
1146
+ }
1147
+ function emitAssistantMessage(args) {
1148
+ return insertEvent({
1149
+ sessionId: args.sessionId,
1150
+ conversationId: args.conversationId,
1151
+ event: "assistant.message",
1152
+ summary: args.summary,
1153
+ meta: {
1154
+ model: args.model,
1155
+ text: args.text,
1156
+ thinkingBlocks: args.thinkingBlocks,
1157
+ thinkingBytes: args.thinkingBytes,
1158
+ claude_id: args.conversationId
1159
+ }
1160
+ });
1161
+ }
1162
+ function emitAssistantRecap(args) {
1163
+ return insertEvent({
1164
+ sessionId: args.sessionId,
1165
+ conversationId: args.conversationId,
1166
+ event: "assistant.recap",
1167
+ summary: args.recap.length > 80 ? `${args.recap.slice(0, 77)}...` : args.recap,
1168
+ meta: { recap: args.recap, claude_id: args.conversationId }
1169
+ });
1170
+ }
1171
+ function emitContextSnapshot(args) {
1172
+ return insertEvent({
1173
+ sessionId: args.sessionId,
1174
+ conversationId: args.conversationId,
1175
+ event: "context.snapshot",
1176
+ summary: `${args.remainingPct}% remaining`,
1177
+ meta: {
1178
+ model: args.model,
1179
+ input_tokens: String(args.inputTokens),
1180
+ cache_creation_tokens: String(args.cacheCreationTokens),
1181
+ cache_read_tokens: String(args.cacheReadTokens),
1182
+ context_window_tokens: String(args.totalContextTokens),
1183
+ remaining_pct: String(args.remainingPct),
1184
+ claude_id: args.conversationId
1185
+ }
1186
+ });
1187
+ }
1188
+ var init_emit = __esm(() => {
1189
+ init_events();
568
1190
  });
569
1191
 
570
1192
  // src/cli/commands/update.ts
571
1193
  var exports_update = {};
1194
+ __export(exports_update, {
1195
+ shouldIgnoreStatusFlip: () => shouldIgnoreStatusFlip
1196
+ });
1197
+ function shouldIgnoreStatusFlip(newStatus, sessionPid) {
1198
+ if (!newStatus)
1199
+ return false;
1200
+ if (newStatus !== "active" && newStatus !== "waiting")
1201
+ return false;
1202
+ return sessionPid === null;
1203
+ }
1204
+ function dispatchHookEvent(event, ctx) {
1205
+ const { sessionId, conversationId, meta, summary } = ctx;
1206
+ switch (event) {
1207
+ case "user.prompt":
1208
+ emitUserPrompted({
1209
+ sessionId,
1210
+ conversationId,
1211
+ prompt: String(meta.prompt ?? "")
1212
+ });
1213
+ return true;
1214
+ case "session.waiting":
1215
+ emitSessionWaiting({
1216
+ sessionId,
1217
+ conversationId,
1218
+ question: String(meta.question ?? "Waiting for input")
1219
+ });
1220
+ return true;
1221
+ case "session.answered":
1222
+ emitSessionAnswered({
1223
+ sessionId,
1224
+ conversationId,
1225
+ answers: meta.answers ?? {},
1226
+ annotations: meta.annotations ?? {},
1227
+ questions: meta.questions ?? []
1228
+ });
1229
+ return true;
1230
+ case "session.recap":
1231
+ emitSessionRecap({
1232
+ sessionId,
1233
+ conversationId,
1234
+ recap: String(meta.recap ?? "")
1235
+ });
1236
+ return true;
1237
+ case "session.paused":
1238
+ emitSessionPaused({ sessionId, conversationId });
1239
+ return true;
1240
+ case "permission.request":
1241
+ emitPermissionRequested({
1242
+ sessionId,
1243
+ conversationId,
1244
+ tool: String(meta.tool ?? ""),
1245
+ detail: String(meta.detail ?? "")
1246
+ });
1247
+ return true;
1248
+ case "permission.resolve":
1249
+ emitPermissionResolved({
1250
+ sessionId,
1251
+ conversationId,
1252
+ tool: String(meta.tool ?? ""),
1253
+ detail: String(meta.detail ?? ""),
1254
+ outcome: meta.outcome === "denied" ? "denied" : "approved"
1255
+ });
1256
+ return true;
1257
+ case "tool.applied":
1258
+ emitToolApplied({
1259
+ sessionId,
1260
+ conversationId,
1261
+ summary: summary ?? "edited a file",
1262
+ permissions: meta.permissions ?? []
1263
+ });
1264
+ return true;
1265
+ case "tool.used":
1266
+ emitToolUsed({
1267
+ sessionId,
1268
+ conversationId,
1269
+ tool: String(meta.tool ?? "Unknown"),
1270
+ detail: String(meta.detail ?? ""),
1271
+ outcome: meta.outcome === "approved" ? "approved" : "auto"
1272
+ });
1273
+ return true;
1274
+ default:
1275
+ return false;
1276
+ }
1277
+ }
572
1278
  var EVENT_STATUS_MAP;
573
1279
  var init_update = __esm(() => {
574
1280
  init_router();
575
1281
  init_sessions();
576
- init_events();
577
1282
  init_conversations();
578
1283
  init_trigger();
1284
+ init_emit();
579
1285
  EVENT_STATUS_MAP = {
580
1286
  "session.waiting": "waiting",
581
1287
  "session.answered": "active",
@@ -619,7 +1325,8 @@ var init_update = __esm(() => {
619
1325
  if (newStatus && newStatus === session.status) {
620
1326
  return;
621
1327
  }
622
- let meta;
1328
+ const ignoreStatusFlip = shouldIgnoreStatusFlip(newStatus, session.pid);
1329
+ let meta = {};
623
1330
  if (metaJson) {
624
1331
  try {
625
1332
  meta = JSON.parse(metaJson);
@@ -630,16 +1337,16 @@ var init_update = __esm(() => {
630
1337
  }
631
1338
  const rawConvoId = meta?.claude_id || process.env.BERTRAND_CLAUDE_ID || undefined;
632
1339
  const conversationId = rawConvoId && getConversation(rawConvoId) ? rawConvoId : undefined;
633
- const answersObj = meta?.answers;
634
- const joinedAnswers = answersObj ? Object.values(answersObj).join(", ") || undefined : undefined;
635
- insertEvent({
1340
+ const dispatched = dispatchHookEvent(event, {
636
1341
  sessionId,
637
1342
  conversationId,
638
- event,
639
- summary: summaryArg || meta?.question || joinedAnswers || undefined,
640
- meta
1343
+ meta,
1344
+ summary: summaryArg
641
1345
  });
642
- if (newStatus) {
1346
+ if (!dispatched) {
1347
+ return;
1348
+ }
1349
+ if (newStatus && !ignoreStatusFlip) {
643
1350
  updateSessionStatus(sessionId, newStatus);
644
1351
  }
645
1352
  if (event === "session.waiting" && conversationId && meta?.question) {
@@ -652,7 +1359,7 @@ var init_update = __esm(() => {
652
1359
  });
653
1360
 
654
1361
  // src/lib/transcript.ts
655
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1362
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
656
1363
  function getContextWindowSize(model) {
657
1364
  for (const [prefix, size] of Object.entries(CONTEXT_WINDOW_SIZES)) {
658
1365
  if (model.startsWith(prefix))
@@ -661,9 +1368,9 @@ function getContextWindowSize(model) {
661
1368
  return 200000;
662
1369
  }
663
1370
  function getLatestAssistantTurn(filePath) {
664
- if (!existsSync3(filePath))
1371
+ if (!existsSync5(filePath))
665
1372
  return null;
666
- const text2 = readFileSync3(filePath, "utf-8");
1373
+ const text2 = readFileSync4(filePath, "utf-8");
667
1374
  const lines = text2.split(`
668
1375
  `);
669
1376
  const assistantEntries = [];
@@ -721,9 +1428,9 @@ function getLatestAssistantTurn(filePath) {
721
1428
  };
722
1429
  }
723
1430
  function getContextSnapshot(filePath) {
724
- if (!existsSync3(filePath))
1431
+ if (!existsSync5(filePath))
725
1432
  return null;
726
- const text2 = readFileSync3(filePath, "utf-8");
1433
+ const text2 = readFileSync4(filePath, "utf-8");
727
1434
  const lines = text2.split(`
728
1435
  `);
729
1436
  for (let i = lines.length - 1;i >= 0; i--) {
@@ -777,7 +1484,7 @@ var init_snapshot = __esm(() => {
777
1484
  init_router();
778
1485
  init_sessions();
779
1486
  init_conversations();
780
- init_events();
1487
+ init_emit();
781
1488
  init_transcript();
782
1489
  register("snapshot", async (args) => {
783
1490
  let sessionId = "";
@@ -810,20 +1517,15 @@ var init_snapshot = __esm(() => {
810
1517
  if (!snapshot)
811
1518
  return;
812
1519
  const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
813
- insertEvent({
1520
+ emitContextSnapshot({
814
1521
  sessionId,
815
1522
  conversationId: convoId,
816
- event: "context.snapshot",
817
- summary: `${snapshot.remainingPct}% remaining`,
818
- meta: {
819
- model: snapshot.model,
820
- input_tokens: String(snapshot.inputTokens),
821
- cache_creation_tokens: String(snapshot.cacheCreationTokens),
822
- cache_read_tokens: String(snapshot.cacheReadTokens),
823
- context_window_tokens: String(snapshot.totalContextTokens),
824
- remaining_pct: String(snapshot.remainingPct),
825
- claude_id: convoId
826
- }
1523
+ model: snapshot.model,
1524
+ inputTokens: snapshot.inputTokens,
1525
+ cacheCreationTokens: snapshot.cacheCreationTokens,
1526
+ cacheReadTokens: snapshot.cacheReadTokens,
1527
+ totalContextTokens: snapshot.totalContextTokens,
1528
+ remainingPct: snapshot.remainingPct
827
1529
  });
828
1530
  });
829
1531
  });
@@ -836,14 +1538,16 @@ function summarize(text2) {
836
1538
  const trimmed = firstLine.trim();
837
1539
  return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
838
1540
  }
839
- var RECAP_TAG_RE;
1541
+ var RECAP_RE, RECAP_TAG_GLOBAL;
840
1542
  var init_assistant_message = __esm(() => {
841
1543
  init_router();
842
1544
  init_sessions();
843
1545
  init_conversations();
844
1546
  init_events();
1547
+ init_emit();
845
1548
  init_transcript();
846
- RECAP_TAG_RE = /<recap>[\s\S]*?<\/recap>/gi;
1549
+ RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
1550
+ RECAP_TAG_GLOBAL = /<recap>[\s\S]*?<\/recap>/gi;
847
1551
  register("assistant-message", async (args) => {
848
1552
  let sessionId = "";
849
1553
  let transcriptPath = "";
@@ -874,34 +1578,50 @@ var init_assistant_message = __esm(() => {
874
1578
  const turn = getLatestAssistantTurn(transcriptPath);
875
1579
  if (!turn)
876
1580
  return;
877
- const text2 = turn.text.replace(RECAP_TAG_RE, "").trim();
1581
+ const fullText = turn.text;
1582
+ const textSansRecap = fullText.replace(RECAP_TAG_GLOBAL, "").trim();
878
1583
  const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
879
- insertEvent({
880
- sessionId,
881
- conversationId: convoId,
882
- event: "assistant.message",
883
- summary: text2 ? summarize(text2) : "thinking only",
884
- meta: {
885
- model: turn.model,
886
- text: text2,
887
- thinkingBlocks: turn.thinkingBlocks,
888
- thinkingBytes: turn.thinkingBytes,
889
- claude_id: convoId
1584
+ if (textSansRecap || turn.thinkingBlocks > 0) {
1585
+ const latestMsg = getLatestEventOfType(sessionId, "assistant.message", convoId);
1586
+ const latestText = latestMsg?.meta?.text;
1587
+ if (latestText !== textSansRecap) {
1588
+ emitAssistantMessage({
1589
+ sessionId,
1590
+ conversationId: convoId,
1591
+ text: textSansRecap,
1592
+ model: turn.model,
1593
+ thinkingBlocks: turn.thinkingBlocks,
1594
+ thinkingBytes: turn.thinkingBytes,
1595
+ summary: textSansRecap ? summarize(textSansRecap) : "thinking only"
1596
+ });
890
1597
  }
891
- });
1598
+ }
1599
+ const recapMatch = fullText.match(RECAP_RE);
1600
+ const recap = recapMatch?.[1]?.trim();
1601
+ if (recap) {
1602
+ const latestRecap = getLatestEventOfType(sessionId, "assistant.recap", convoId);
1603
+ const latestRecapText = latestRecap?.meta?.recap;
1604
+ if (latestRecapText !== recap) {
1605
+ emitAssistantRecap({
1606
+ sessionId,
1607
+ conversationId: convoId,
1608
+ recap
1609
+ });
1610
+ }
1611
+ }
892
1612
  });
893
1613
  });
894
1614
 
895
1615
  // src/cli/commands/recap-thinking.ts
896
1616
  var exports_recap_thinking = {};
897
- var RECAP_RE;
1617
+ var RECAP_RE2;
898
1618
  var init_recap_thinking = __esm(() => {
899
1619
  init_router();
900
1620
  init_sessions();
901
1621
  init_conversations();
902
- init_events();
1622
+ init_emit();
903
1623
  init_transcript();
904
- RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
1624
+ RECAP_RE2 = /<recap>([\s\S]*?)<\/recap>/i;
905
1625
  register("recap-thinking", async (args) => {
906
1626
  let sessionId = "";
907
1627
  let transcriptPath = "";
@@ -929,17 +1649,15 @@ var init_recap_thinking = __esm(() => {
929
1649
  const turn = getLatestAssistantTurn(transcriptPath);
930
1650
  if (!turn?.text)
931
1651
  return;
932
- const match = turn.text.match(RECAP_RE);
1652
+ const match = turn.text.match(RECAP_RE2);
933
1653
  const recap = match?.[1]?.trim();
934
1654
  if (!recap)
935
1655
  return;
936
1656
  const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
937
- insertEvent({
1657
+ emitAssistantRecap({
938
1658
  sessionId,
939
1659
  conversationId: convoId,
940
- event: "assistant.recap",
941
- summary: recap.length > 80 ? `${recap.slice(0, 77)}...` : recap,
942
- meta: { recap, claude_id: convoId }
1660
+ recap
943
1661
  });
944
1662
  });
945
1663
  });
@@ -990,8 +1708,8 @@ class NoopAdapter {
990
1708
  }
991
1709
 
992
1710
  // src/terminal/index.ts
993
- import { readFileSync as readFileSync4 } from "fs";
994
- import { join as join3 } from "path";
1711
+ import { readFileSync as readFileSync5 } from "fs";
1712
+ import { join as join6 } from "path";
995
1713
  function getTerminalAdapter() {
996
1714
  if (cachedAdapter)
997
1715
  return cachedAdapter;
@@ -1010,7 +1728,7 @@ function getTerminalAdapter() {
1010
1728
  }
1011
1729
  function readConfigTerminal() {
1012
1730
  try {
1013
- const config = JSON.parse(readFileSync4(join3(paths.root, "config.json"), "utf-8"));
1731
+ const config = JSON.parse(readFileSync5(join6(paths.root, "config.json"), "utf-8"));
1014
1732
  return config.terminal ?? null;
1015
1733
  } catch {
1016
1734
  return null;
@@ -1259,6 +1977,14 @@ function aggregateToolUsage(sessionId) {
1259
1977
  continue;
1260
1978
  counts[tool] = (counts[tool] ?? 0) + 1;
1261
1979
  }
1980
+ const used = getEventsByType(sessionId, "tool.used");
1981
+ for (const ev of used) {
1982
+ const meta = ev.meta;
1983
+ const tool = meta?.tool;
1984
+ if (!tool)
1985
+ continue;
1986
+ counts[tool] = (counts[tool] ?? 0) + 1;
1987
+ }
1262
1988
  return counts;
1263
1989
  }
1264
1990
  function contextTokenStats(sessionId) {
@@ -1349,7 +2075,7 @@ function archiveAllPaused() {
1349
2075
  const archived = [];
1350
2076
  for (const row of paused) {
1351
2077
  const updated = updateSessionStatus(row.session.id, "archived");
1352
- archived.push({ session: updated, groupPath: row.groupPath });
2078
+ archived.push({ session: updated, categoryPath: row.categoryPath });
1353
2079
  }
1354
2080
  return { archived };
1355
2081
  }
@@ -1361,8 +2087,8 @@ var init_session_archive = __esm(() => {
1361
2087
 
1362
2088
  // src/server/index.ts
1363
2089
  import { execFile } from "child_process";
1364
- import { existsSync as existsSync4 } from "fs";
1365
- import { join as join4 } from "path";
2090
+ import { existsSync as existsSync6 } from "fs";
2091
+ import { join as join7 } from "path";
1366
2092
  function liveStats(sessionId) {
1367
2093
  return {
1368
2094
  sessionId,
@@ -1376,6 +2102,24 @@ function archiveResponse(result) {
1376
2102
  const meta = ARCHIVE_ERROR[result.reason] ?? { status: 400, message: "Operation failed" };
1377
2103
  return Response.json({ error: meta.message, reason: result.reason }, { status: meta.status });
1378
2104
  }
2105
+ async function handleSwitchProject(req) {
2106
+ let body;
2107
+ try {
2108
+ body = await req.json();
2109
+ } catch {
2110
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
2111
+ }
2112
+ const slug = body.slug;
2113
+ if (typeof slug !== "string") {
2114
+ return Response.json({ error: "slug must be a string" }, { status: 400 });
2115
+ }
2116
+ if (!projectExists(slug)) {
2117
+ return Response.json({ error: `Unknown project: ${slug}` }, { status: 404 });
2118
+ }
2119
+ setActiveProjectSlug(slug);
2120
+ queueMicrotask(() => process.exit(0));
2121
+ return Response.json({ ok: true, slug, willRestart: true });
2122
+ }
1379
2123
  async function handleOpen(req) {
1380
2124
  let body;
1381
2125
  try {
@@ -1415,11 +2159,11 @@ function match(pathname, url) {
1415
2159
  }
1416
2160
  function findDashboardDir() {
1417
2161
  const candidates = [
1418
- join4(import.meta.dir, "dashboard"),
1419
- join4(import.meta.dir, "..", "dashboard")
2162
+ join7(import.meta.dir, "dashboard"),
2163
+ join7(import.meta.dir, "..", "dashboard")
1420
2164
  ];
1421
2165
  for (const dir of candidates) {
1422
- if (existsSync4(join4(dir, "index.html")))
2166
+ if (existsSync6(join7(dir, "index.html")))
1423
2167
  return dir;
1424
2168
  }
1425
2169
  return null;
@@ -1428,13 +2172,13 @@ async function serveDashboard(pathname) {
1428
2172
  if (!DASHBOARD_DIR)
1429
2173
  return null;
1430
2174
  const requested = pathname === "/" ? "/index.html" : pathname;
1431
- const filePath = join4(DASHBOARD_DIR, requested);
2175
+ const filePath = join7(DASHBOARD_DIR, requested);
1432
2176
  if (!filePath.startsWith(DASHBOARD_DIR))
1433
2177
  return null;
1434
2178
  const file = Bun.file(filePath);
1435
2179
  if (await file.exists())
1436
2180
  return new Response(file);
1437
- return new Response(Bun.file(join4(DASHBOARD_DIR, "index.html")));
2181
+ return new Response(Bun.file(join7(DASHBOARD_DIR, "index.html")));
1438
2182
  }
1439
2183
  function startServer(port = PORT) {
1440
2184
  const server = Bun.serve({
@@ -1455,6 +2199,11 @@ function startServer(port = PORT) {
1455
2199
  r.headers.set("Access-Control-Allow-Origin", "*");
1456
2200
  return r;
1457
2201
  }
2202
+ if (req.method === "POST" && url.pathname === "/api/active-project") {
2203
+ const r = await handleSwitchProject(req);
2204
+ r.headers.set("Access-Control-Allow-Origin", "*");
2205
+ return r;
2206
+ }
1458
2207
  if (req.method === "POST") {
1459
2208
  const archiveMatch = /^\/api\/sessions\/([^/]+)\/archive$/.exec(url.pathname);
1460
2209
  if (archiveMatch) {
@@ -1517,7 +2266,18 @@ var PORT, listSessions = (_params, url) => {
1517
2266
  return getSessionStats(sessionId) ?? liveStats(sessionId);
1518
2267
  }, getEngagement = ({
1519
2268
  sessionId
1520
- }) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
2269
+ }) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), listAllProjects = () => {
2270
+ const active = resolveActiveProject();
2271
+ return listProjects().map((p) => ({
2272
+ slug: p.slug,
2273
+ name: p.name,
2274
+ active: p.slug === active.slug,
2275
+ lastUsedAt: p.lastUsedAt
2276
+ }));
2277
+ }, getActiveProjectMeta = () => {
2278
+ const active = resolveActiveProject();
2279
+ return { slug: active.slug, name: active.name };
2280
+ }, routes, ARCHIVE_ERROR, DASHBOARD_DIR;
1521
2281
  var init_server = __esm(() => {
1522
2282
  init_sessions();
1523
2283
  init_events();
@@ -1525,6 +2285,8 @@ var init_server = __esm(() => {
1525
2285
  init_timing();
1526
2286
  init_engagement_stats();
1527
2287
  init_session_archive();
2288
+ init_registry();
2289
+ init_resolve();
1528
2290
  PORT = Number(process.env.BERTRAND_PORT ?? 5200);
1529
2291
  routes = [
1530
2292
  [/^\/api\/sessions$/, listSessions],
@@ -1533,7 +2295,9 @@ var init_server = __esm(() => {
1533
2295
  [/^\/api\/stats$/, listAllStats],
1534
2296
  [/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
1535
2297
  [/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
1536
- [/^\/api\/recaps$/, listRecaps]
2298
+ [/^\/api\/recaps$/, listRecaps],
2299
+ [/^\/api\/projects$/, listAllProjects],
2300
+ [/^\/api\/active-project$/, getActiveProjectMeta]
1537
2301
  ];
1538
2302
  ARCHIVE_ERROR = {
1539
2303
  "not-found": { status: 404, message: "Session not found" },
@@ -1557,36 +2321,41 @@ var init_serve = __esm(() => {
1557
2321
 
1558
2322
  // src/sync/snapshot.ts
1559
2323
  import { Database as Database2 } from "bun:sqlite";
1560
- import { existsSync as existsSync5, unlinkSync } from "fs";
2324
+ import { existsSync as existsSync7, unlinkSync } from "fs";
2325
+ function snapshotPathFor(dbPath) {
2326
+ return `${dbPath}.sync-snapshot`;
2327
+ }
1561
2328
  function takeSnapshot() {
1562
2329
  cleanupSnapshot();
1563
- const src = new Database2(paths.db, { readonly: true });
2330
+ const dbPath = resolveActiveProject().db;
2331
+ const target = snapshotPathFor(dbPath);
2332
+ const src = new Database2(dbPath, { readonly: true });
1564
2333
  try {
1565
- src.exec(`VACUUM INTO '${SNAPSHOT_PATH.replace(/'/g, "''")}'`);
2334
+ src.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
1566
2335
  } finally {
1567
2336
  src.close();
1568
2337
  }
1569
- return SNAPSHOT_PATH;
2338
+ return target;
1570
2339
  }
1571
2340
  function cleanupSnapshot() {
2341
+ const base = snapshotPathFor(resolveActiveProject().db);
1572
2342
  for (const suffix of SIDECAR_SUFFIXES) {
1573
- const p = SNAPSHOT_PATH + suffix;
1574
- if (existsSync5(p)) {
2343
+ const p = base + suffix;
2344
+ if (existsSync7(p)) {
1575
2345
  try {
1576
2346
  unlinkSync(p);
1577
2347
  } catch {}
1578
2348
  }
1579
2349
  }
1580
2350
  }
1581
- var SIDECAR_SUFFIXES, SNAPSHOT_PATH;
2351
+ var SIDECAR_SUFFIXES;
1582
2352
  var init_snapshot2 = __esm(() => {
1583
- init_paths();
2353
+ init_resolve();
1584
2354
  SIDECAR_SUFFIXES = ["", "-wal", "-shm"];
1585
- SNAPSHOT_PATH = `${paths.db}.sync-snapshot`;
1586
2355
  });
1587
2356
 
1588
2357
  // src/sync/crypto.ts
1589
- import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
2358
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "crypto";
1590
2359
  function parseKey(base64Key) {
1591
2360
  const buf = Buffer.from(base64Key, "base64");
1592
2361
  if (buf.length !== 32) {
@@ -1595,11 +2364,11 @@ function parseKey(base64Key) {
1595
2364
  return buf;
1596
2365
  }
1597
2366
  function generateKeyBase64() {
1598
- return randomBytes(32).toString("base64");
2367
+ return randomBytes2(32).toString("base64");
1599
2368
  }
1600
2369
  function encrypt(plaintext, base64Key) {
1601
2370
  const key = parseKey(base64Key);
1602
- const iv = randomBytes(IV_LEN);
2371
+ const iv = randomBytes2(IV_LEN);
1603
2372
  const cipher = createCipheriv(ALGO, key, iv);
1604
2373
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
1605
2374
  const tag = cipher.getAuthTag();
@@ -1627,9 +2396,8 @@ var init_crypto = __esm(() => {
1627
2396
 
1628
2397
  // src/sync/engine.ts
1629
2398
  import { createClient } from "@supabase/supabase-js";
1630
- import { readFileSync as readFileSync5, renameSync, statSync as statSync2, openSync, writeSync, fsyncSync, closeSync } from "fs";
2399
+ import { readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
1631
2400
  import { dirname as dirname2 } from "path";
1632
- import { execFileSync } from "child_process";
1633
2401
  function client(cfg) {
1634
2402
  if (!cfg)
1635
2403
  return null;
@@ -1658,7 +2426,7 @@ async function push() {
1658
2426
  error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
1659
2427
  };
1660
2428
  }
1661
- const plaintext = readFileSync5(snapshotPath);
2429
+ const plaintext = readFileSync6(snapshotPath);
1662
2430
  const ciphertext = encrypt(plaintext, cfg.encryptionKey);
1663
2431
  const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
1664
2432
  contentType: "application/octet-stream",
@@ -1688,17 +2456,18 @@ async function pull(opts = {}) {
1688
2456
  if (!cfg || !supabase) {
1689
2457
  return { ok: false, operation: "pull", error: "sync config incomplete" };
1690
2458
  }
1691
- const holders = findHolders(paths.db);
2459
+ const dbPath = resolveActiveProject().db;
2460
+ const holders = findHolders(dbPath);
1692
2461
  if (holders.length > 0) {
1693
2462
  const procs = holders.map((h) => `${h.command}(${h.pid})`).join(", ");
1694
2463
  if (!opts.force) {
1695
2464
  return {
1696
2465
  ok: false,
1697
2466
  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).`
2467
+ error: `${dbPath} is held by ${procs} \u2014 close active bertrand sessions before pulling, ` + `or pass --force to overwrite anyway (risks corrupting the running session).`
1699
2468
  };
1700
2469
  }
1701
- console.warn(`warning: --force pulling while ${procs} hold ${paths.db}. The running process may crash on next file access.`);
2470
+ console.warn(`warning: --force pulling while ${procs} hold ${dbPath}. The running process may crash on next file access.`);
1702
2471
  }
1703
2472
  const started = performance.now();
1704
2473
  try {
@@ -1717,7 +2486,7 @@ async function pull(opts = {}) {
1717
2486
  }
1718
2487
  const ciphertext = Buffer.from(await data.arrayBuffer());
1719
2488
  const plaintext = decrypt(ciphertext, cfg.encryptionKey);
1720
- const tmp = `${paths.db}.pull-${process.pid}`;
2489
+ const tmp = `${dbPath}.pull-${process.pid}`;
1721
2490
  const fd = openSync(tmp, "w");
1722
2491
  try {
1723
2492
  writeSync(fd, plaintext);
@@ -1725,8 +2494,8 @@ async function pull(opts = {}) {
1725
2494
  } finally {
1726
2495
  closeSync(fd);
1727
2496
  }
1728
- renameSync(tmp, paths.db);
1729
- const dirFd = openSync(dirname2(paths.db), "r");
2497
+ renameSync3(tmp, dbPath);
2498
+ const dirFd = openSync(dirname2(dbPath), "r");
1730
2499
  try {
1731
2500
  fsyncSync(dirFd);
1732
2501
  } finally {
@@ -1747,9 +2516,10 @@ async function status() {
1747
2516
  const cfg = loadSyncConfig();
1748
2517
  if (!cfg)
1749
2518
  return { configured: false, local: null, remote: null };
2519
+ const dbPath = resolveActiveProject().db;
1750
2520
  let local = null;
1751
2521
  try {
1752
- const s = statSync2(paths.db);
2522
+ const s = statSync3(dbPath);
1753
2523
  local = { size: s.size, modifiedAt: new Date(s.mtimeMs) };
1754
2524
  } catch {
1755
2525
  local = null;
@@ -1774,48 +2544,25 @@ async function status() {
1774
2544
  }
1775
2545
  return { configured: true, local, remote };
1776
2546
  }
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
2547
  var init_engine = __esm(() => {
1804
- init_paths();
2548
+ init_resolve();
2549
+ init_lsof();
1805
2550
  init_config();
1806
2551
  init_snapshot2();
1807
2552
  init_crypto();
1808
2553
  });
1809
2554
 
1810
2555
  // src/sync/invite.ts
1811
- function encodeInvite(cfg) {
2556
+ function encodeInvite(cfg, project) {
1812
2557
  const bundle = {
1813
2558
  v: VERSION,
1814
2559
  url: cfg.supabaseUrl,
1815
2560
  key: cfg.supabaseServiceKey,
1816
2561
  bucket: cfg.bucket,
1817
2562
  obj: cfg.objectKey,
1818
- ek: cfg.encryptionKey
2563
+ ek: cfg.encryptionKey,
2564
+ psl: project.slug,
2565
+ pn: project.name
1819
2566
  };
1820
2567
  return SCHEME + Buffer.from(JSON.stringify(bundle), "utf8").toString("base64url");
1821
2568
  }
@@ -1839,22 +2586,125 @@ function decodeInvite(invite) {
1839
2586
  }
1840
2587
  const bundle = parsed;
1841
2588
  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.`);
2589
+ throw new Error(`invite version ${String(bundle.v)} is not supported (expected v${VERSION}). ` + `Both machines must run a per-project-aware bertrand. ` + `Upgrade the source machine and regenerate the invite.`);
1843
2590
  }
1844
- for (const field of ["url", "key", "bucket", "obj", "ek"]) {
2591
+ for (const field of ["url", "key", "bucket", "obj", "ek", "psl", "pn"]) {
1845
2592
  if (!bundle[field] || typeof bundle[field] !== "string") {
1846
2593
  throw new Error(`invite is missing required field: ${field}`);
1847
2594
  }
1848
2595
  }
1849
2596
  return {
1850
- supabaseUrl: bundle.url,
1851
- supabaseServiceKey: bundle.key,
1852
- bucket: bundle.bucket,
1853
- objectKey: bundle.obj,
1854
- encryptionKey: bundle.ek
2597
+ config: {
2598
+ supabaseUrl: bundle.url,
2599
+ supabaseServiceKey: bundle.key,
2600
+ bucket: bundle.bucket,
2601
+ objectKey: bundle.obj,
2602
+ encryptionKey: bundle.ek
2603
+ },
2604
+ project: {
2605
+ slug: bundle.psl,
2606
+ name: bundle.pn
2607
+ }
2608
+ };
2609
+ }
2610
+ var SCHEME = "bertrand-sync://", VERSION = 2;
2611
+
2612
+ // src/db/migrate.ts
2613
+ import { Database as Database3 } from "bun:sqlite";
2614
+ import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
2615
+ import { migrate as migrate2 } from "drizzle-orm/bun-sqlite/migrator";
2616
+ import { mkdirSync as mkdirSync5 } from "fs";
2617
+ import { dirname as dirname3 } from "path";
2618
+ function runMigrations(dbPath) {
2619
+ const target = dbPath ?? resolveActiveProject().db;
2620
+ mkdirSync5(dirname3(target), { recursive: true });
2621
+ const sqlite = new Database3(target);
2622
+ sqlite.exec("PRAGMA busy_timeout = 5000");
2623
+ sqlite.exec("PRAGMA journal_mode = WAL");
2624
+ sqlite.exec("PRAGMA foreign_keys = ON");
2625
+ const db = drizzle2(sqlite);
2626
+ migrate2(db, { migrationsFolder: MIGRATIONS_FOLDER2 });
2627
+ sqlite.close();
2628
+ }
2629
+ var MIGRATIONS_FOLDER2;
2630
+ var init_migrate = __esm(() => {
2631
+ init_resolve();
2632
+ MIGRATIONS_FOLDER2 = import.meta.dir + "/migrations";
2633
+ if (false) {}
2634
+ });
2635
+
2636
+ // src/lib/projects/create.ts
2637
+ import { mkdirSync as mkdirSync6 } from "fs";
2638
+ function createProject(opts) {
2639
+ registerProject({ slug: opts.slug, name: opts.name ?? opts.slug });
2640
+ try {
2641
+ const paths2 = projectPaths(opts.slug);
2642
+ mkdirSync6(paths2.root, { recursive: true });
2643
+ runMigrations(paths2.db);
2644
+ } catch (err) {
2645
+ try {
2646
+ removeProject(opts.slug);
2647
+ } catch {}
2648
+ throw err;
2649
+ }
2650
+ }
2651
+ var init_create = __esm(() => {
2652
+ init_migrate();
2653
+ init_registry();
2654
+ init_paths2();
2655
+ });
2656
+
2657
+ // src/sync/bootstrap.ts
2658
+ import { hostname } from "os";
2659
+ async function bootstrapFromInvite(invite) {
2660
+ let decoded;
2661
+ try {
2662
+ decoded = decodeInvite(invite);
2663
+ } catch (e) {
2664
+ return {
2665
+ ok: false,
2666
+ reason: "decode-failed",
2667
+ error: e instanceof Error ? e.message : String(e)
2668
+ };
2669
+ }
2670
+ const { config: cfg, project } = decoded;
2671
+ if (listProjects().some((p) => p.slug === project.slug)) {
2672
+ return {
2673
+ ok: false,
2674
+ reason: "slug-collision",
2675
+ error: `Project "${project.slug}" already exists on this machine. Remove it first (\`bertrand project remove ${project.slug} --purge\`) or rename it before importing.`
2676
+ };
2677
+ }
2678
+ createProject({ slug: project.slug, name: project.name });
2679
+ setActiveProjectSlug(project.slug);
2680
+ _resetActiveProjectCache();
2681
+ resolveActiveProject();
2682
+ saveSyncConfig({ ...cfg, clientName: `bertrand-${hostname()}` });
2683
+ patchConfig({ sync: { enabled: true } });
2684
+ const result = await pull();
2685
+ if (!result.ok) {
2686
+ return {
2687
+ ok: false,
2688
+ reason: "pull-failed",
2689
+ error: result.error
2690
+ };
2691
+ }
2692
+ return {
2693
+ ok: true,
2694
+ project,
2695
+ pulled: result.pulled ?? false,
2696
+ bytes: result.bytes ?? 0,
2697
+ durationMs: result.durationMs ?? 0
1855
2698
  };
1856
2699
  }
1857
- var SCHEME = "bertrand-sync://", VERSION = 1;
2700
+ var init_bootstrap = __esm(() => {
2701
+ init_config();
2702
+ init_engine();
2703
+ init_registry();
2704
+ init_create();
2705
+ init_resolve();
2706
+ init_config2();
2707
+ });
1858
2708
 
1859
2709
  // src/lib/format.ts
1860
2710
  function formatDuration(ms) {
@@ -1912,32 +2762,65 @@ var init_format = __esm(() => {
1912
2762
 
1913
2763
  // src/cli/commands/sync.ts
1914
2764
  var exports_sync = {};
1915
- import { hostname } from "os";
2765
+ import { hostname as hostname2 } from "os";
1916
2766
  function printUsage2() {
1917
2767
  console.log(`
1918
- bertrand sync \u2014 replicate your local DB to Supabase Storage (opt-in)
2768
+ bertrand sync \u2014 replicate a project's local DB to Supabase Storage (opt-in)
1919
2769
 
1920
2770
  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
2771
+ bertrand sync onboard [--project <slug>] Interactive setup for a project
2772
+ bertrand sync invite [--project <slug>] Print a paste-able bundle
2773
+ bertrand sync <bundle> Decode bundle, create project, save config, pull
2774
+ bertrand sync push [--project <slug>] VACUUM \u2192 encrypt \u2192 upload
2775
+ bertrand sync pull [--force] [--project <slug>]
2776
+ Download \u2192 decrypt \u2192 atomic-replace
2777
+ bertrand sync status [--project <slug>] Local + remote object size and timestamps
2778
+ bertrand sync enable [--project <slug>] Turn auto-triggers on
2779
+ bertrand sync disable [--project <slug>] Turn auto-triggers off
2780
+
2781
+ All subcommands default to the active project. Pass --project to operate
2782
+ on a different one without switching active.
1929
2783
 
1930
2784
  Cross-machine setup:
1931
2785
  Machine A: bertrand sync invite
1932
2786
  \u2192 bertrand-sync://eyJ\u2026 (transmit via Signal/iMessage/AirDrop \u2014 sensitive)
1933
2787
  Machine B: bertrand sync bertrand-sync://eyJ\u2026
1934
- \u2192 onboards, pulls, ready
2788
+ \u2192 creates the project, onboards, pulls, ready
1935
2789
  `.trim());
1936
2790
  }
1937
2791
  function abort(reason) {
1938
2792
  console.error(`Aborted: ${reason}`);
1939
2793
  process.exit(1);
1940
2794
  }
2795
+ function extractProjectFlag(args) {
2796
+ const rest = [];
2797
+ let slug;
2798
+ for (let i = 0;i < args.length; i++) {
2799
+ const a = args[i];
2800
+ if (a === "--project") {
2801
+ slug = args[i + 1];
2802
+ i++;
2803
+ continue;
2804
+ }
2805
+ if (a.startsWith("--project=")) {
2806
+ slug = a.slice("--project=".length);
2807
+ continue;
2808
+ }
2809
+ rest.push(a);
2810
+ }
2811
+ return { slug, rest };
2812
+ }
2813
+ function applyProjectOverride(slug) {
2814
+ if (!slug)
2815
+ return;
2816
+ const known = listProjects().some((p) => p.slug === slug);
2817
+ if (!known) {
2818
+ abort(`Unknown project: ${slug}. Run \`bertrand project list\` to see registered projects.`);
2819
+ return;
2820
+ }
2821
+ process.env.BERTRAND_PROJECT = slug;
2822
+ _resetActiveProjectCache();
2823
+ }
1941
2824
  function prompt(label, opts = {}) {
1942
2825
  const suffix = opts.default ? ` [${opts.default}]` : "";
1943
2826
  process.stdout.write(`${label}${suffix}: `);
@@ -1962,9 +2845,10 @@ function prompt(label, opts = {}) {
1962
2845
  });
1963
2846
  }
1964
2847
  async function runOnboard() {
2848
+ const active = resolveActiveProject();
1965
2849
  const existing = loadSyncConfig();
1966
2850
  if (existing) {
1967
- console.log(`Existing config at ${paths.syncEnv}:`);
2851
+ console.log(`Existing config for project "${active.slug}" at ${active.syncEnv}:`);
1968
2852
  console.log(` SUPABASE_URL: ${existing.supabaseUrl}`);
1969
2853
  console.log(` SUPABASE_SERVICE_KEY: ${existing.supabaseServiceKey.slice(0, 12)}\u2026`);
1970
2854
  console.log(` BERTRAND_SYNC_BUCKET: ${existing.bucket}`);
@@ -1972,10 +2856,10 @@ async function runOnboard() {
1972
2856
  console.log(` BERTRAND_ENCRYPTION_KEY: (set, redacted)`);
1973
2857
  console.log(` BERTRAND_CLIENT_NAME: ${existing.clientName}`);
1974
2858
  console.log(`
1975
- Delete ${paths.syncEnv} and re-run to start over.`);
2859
+ Delete ${active.syncEnv} and re-run to start over.`);
1976
2860
  return;
1977
2861
  }
1978
- console.log(`Bertrand sync setup \u2014 Supabase Storage
2862
+ console.log(`Bertrand sync setup for project "${active.slug}" (${active.name})
1979
2863
  `);
1980
2864
  console.log(`In the Supabase dashboard (app.supabase.com):`);
1981
2865
  console.log(` 1. Create or pick a project`);
@@ -1985,7 +2869,7 @@ Delete ${paths.syncEnv} and re-run to start over.`);
1985
2869
  console.log(` is below it and warns "keep secret")`);
1986
2870
  console.log(` 4. Storage \u2192 New bucket: name it "bertrand", PRIVATE (uncheck "Public bucket")
1987
2871
  `);
1988
- const projectId = await prompt("Project ID");
2872
+ const projectId = await prompt("Supabase Project ID");
1989
2873
  if (!projectId)
1990
2874
  return abort("no project ID provided");
1991
2875
  if (!/^[a-z0-9]{15,30}$/i.test(projectId)) {
@@ -2000,8 +2884,9 @@ Delete ${paths.syncEnv} and re-run to start over.`);
2000
2884
  return abort("service key must be a JWT starting with eyJ \u2014 did you paste the URL by mistake?");
2001
2885
  }
2002
2886
  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()}`;
2887
+ const defaultObject = `projects/${active.slug}/bertrand.db.enc`;
2888
+ const objectKey = await prompt("Object key", { default: defaultObject });
2889
+ const defaultName = `bertrand-${hostname2()}`;
2005
2890
  const clientName = await prompt("Client name", { default: defaultName });
2006
2891
  console.log(`
2007
2892
  Encryption key`);
@@ -2022,7 +2907,7 @@ Encryption key`);
2022
2907
  encryptionKey = generateKeyBase64();
2023
2908
  console.log(`
2024
2909
  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:
2910
+ console.log(`other machine that should be able to pull this project:
2026
2911
  `);
2027
2912
  console.log(` ${encryptionKey}
2028
2913
  `);
@@ -2031,18 +2916,16 @@ Generated a new key. Save this somewhere safe \u2014 you'll need it on every`);
2031
2916
  }
2032
2917
  saveSyncConfig({ supabaseUrl, supabaseServiceKey, bucket, objectKey, encryptionKey, clientName });
2033
2918
  patchConfig({ sync: { enabled: true } });
2034
- console.log(`Wrote ${paths.syncEnv} (mode 0600). Sync auto-triggers enabled.`);
2919
+ console.log(`Wrote ${active.syncEnv} (mode 0600). Sync auto-triggers enabled.`);
2035
2920
  console.log(`
2036
2921
  Next:`);
2037
- console.log(` bertrand sync push Send your current local DB to Supabase`);
2922
+ console.log(` bertrand sync push Send this project's DB to Supabase`);
2923
+ console.log(` bertrand sync invite Generate a bundle to import on another machine`);
2038
2924
  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
2925
  }
2043
2926
  async function runPush() {
2044
2927
  if (!hasSyncConfig())
2045
- return abort("no sync config \u2014 run `bertrand sync onboard`");
2928
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard`");
2046
2929
  process.stdout.write("Snapshot \u2192 encrypt \u2192 upload\u2026 ");
2047
2930
  const result = await push();
2048
2931
  if (result.ok) {
@@ -2055,7 +2938,7 @@ async function runPush() {
2055
2938
  }
2056
2939
  async function runPull(args) {
2057
2940
  if (!hasSyncConfig())
2058
- return abort("no sync config \u2014 run `bertrand sync onboard`");
2941
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard`");
2059
2942
  const force = args.includes("--force");
2060
2943
  process.stdout.write("Download \u2192 decrypt \u2192 replace\u2026 ");
2061
2944
  const result = await pull({ force });
@@ -2072,11 +2955,12 @@ async function runPull(args) {
2072
2955
  }
2073
2956
  }
2074
2957
  async function runStatus() {
2958
+ const active = resolveActiveProject();
2075
2959
  if (!hasSyncConfig()) {
2076
- console.log("Sync is not configured. Run `bertrand sync onboard` to set up.");
2960
+ console.log(`Sync is not configured for project "${active.slug}". Run \`bertrand sync onboard\` to set up.`);
2077
2961
  return;
2078
2962
  }
2079
- console.log(`Sync status (${paths.syncEnv}):`);
2963
+ console.log(`Sync status for project "${active.slug}" (${active.syncEnv}):`);
2080
2964
  console.log(` Auto-triggers: ${isSyncEnabled() ? "enabled" : "disabled"}`);
2081
2965
  const s = await status();
2082
2966
  if (s.local) {
@@ -2092,7 +2976,7 @@ async function runStatus() {
2092
2976
  }
2093
2977
  function runEnable() {
2094
2978
  if (!hasSyncConfig()) {
2095
- return abort("no sync config \u2014 run `bertrand sync onboard` first");
2979
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard` first");
2096
2980
  }
2097
2981
  patchConfig({ sync: { enabled: true } });
2098
2982
  console.log("Sync auto-triggers enabled. Pull-on-launch and push-on-session-end will fire.");
@@ -2100,44 +2984,39 @@ function runEnable() {
2100
2984
  function runInvite() {
2101
2985
  const cfg = loadSyncConfig();
2102
2986
  if (!cfg) {
2103
- return abort("no sync config \u2014 run `bertrand sync onboard` first");
2987
+ return abort("no sync config for the active project \u2014 run `bertrand sync onboard` first");
2104
2988
  }
2105
- const invite = encodeInvite(cfg);
2106
- console.log("Sensitive: this bundle contains a Supabase service_role token AND your DB encryption key.");
2989
+ const active = resolveActiveProject();
2990
+ const invite = encodeInvite(cfg, { slug: active.slug, name: active.name });
2991
+ console.log("Sensitive: this bundle contains a Supabase service_role token AND the project's encryption key.");
2107
2992
  console.log(`Transmit only over a secure channel (Signal, iMessage, AirDrop). Treat like an SSH private key.
2993
+ `);
2994
+ console.log(`Project: ${active.slug} (${active.name})
2108
2995
  `);
2109
2996
  console.log(invite);
2110
2997
  console.log(`
2111
2998
  On the other machine:`);
2112
2999
  console.log(` bertrand sync ${invite.slice(0, 32)}\u2026`);
3000
+ console.log(` (creates the project locally, saves config, runs first pull)`);
2113
3001
  }
2114
3002
  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.`);
3003
+ console.log("Importing project from invite\u2026");
3004
+ const result = await bootstrapFromInvite(invite);
3005
+ if (!result.ok) {
3006
+ if (result.reason === "pull-failed") {
3007
+ console.error(`Pull failed: ${result.error}`);
3008
+ console.error(`(Config is saved, so you can fix and re-run \`bertrand sync pull\`.)`);
3009
+ process.exit(1);
2136
3010
  }
3011
+ return abort(result.error);
3012
+ }
3013
+ const active = resolveActiveProject();
3014
+ console.log(`Created project "${result.project.slug}" (${result.project.name}) and wrote ${active.syncEnv}.`);
3015
+ if (result.pulled) {
3016
+ console.log(`\u2713 Done (${formatBytes(result.bytes)} in ${result.durationMs}ms). Run \`bertrand\` to start.`);
2137
3017
  } 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);
3018
+ console.log(`\u2713 Config saved, but no remote object yet \u2014 nothing to pull.`);
3019
+ console.log(` Run \`bertrand sync push\` on the source machine first.`);
2141
3020
  }
2142
3021
  }
2143
3022
  function runDisable() {
@@ -2157,7 +3036,9 @@ var init_sync = __esm(() => {
2157
3036
  init_config();
2158
3037
  init_engine();
2159
3038
  init_crypto();
2160
- init_paths();
3039
+ init_bootstrap();
3040
+ init_registry();
3041
+ init_resolve();
2161
3042
  init_format();
2162
3043
  init_config2();
2163
3044
  register("sync", async (args) => {
@@ -2166,12 +3047,14 @@ var init_sync = __esm(() => {
2166
3047
  await runBootstrap(sub);
2167
3048
  return;
2168
3049
  }
3050
+ const { slug: projectOverride, rest } = extractProjectFlag(args.slice(1));
3051
+ applyProjectOverride(projectOverride);
2169
3052
  switch (sub) {
2170
3053
  case "push":
2171
3054
  await runPush();
2172
3055
  return;
2173
3056
  case "pull":
2174
- await runPull(args.slice(1));
3057
+ await runPull(rest);
2175
3058
  return;
2176
3059
  case "status":
2177
3060
  await runStatus();
@@ -2201,48 +3084,48 @@ var init_sync = __esm(() => {
2201
3084
  });
2202
3085
  });
2203
3086
 
2204
- // src/db/queries/groups.ts
3087
+ // src/db/queries/categories.ts
2205
3088
  import { eq as eq6, like, or, isNull } from "drizzle-orm";
2206
- function createGroup(opts) {
3089
+ function createCategory(opts) {
2207
3090
  const db = getDb();
2208
3091
  const id = createId();
2209
3092
  let path = opts.slug;
2210
3093
  let depth = 0;
2211
3094
  if (opts.parentId) {
2212
- const parent = db.select().from(groups).where(eq6(groups.id, opts.parentId)).get();
3095
+ const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
2213
3096
  if (!parent)
2214
- throw new Error(`Parent group ${opts.parentId} not found`);
3097
+ throw new Error(`Parent category ${opts.parentId} not found`);
2215
3098
  path = `${parent.path}/${opts.slug}`;
2216
3099
  depth = parent.depth + 1;
2217
3100
  }
2218
- return db.insert(groups).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
3101
+ return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
2219
3102
  }
2220
- function getGroup(id) {
2221
- return getDb().select().from(groups).where(eq6(groups.id, id)).get();
3103
+ function getCategory(id) {
3104
+ return getDb().select().from(categories).where(eq6(categories.id, id)).get();
2222
3105
  }
2223
- function getGroupByPath(path) {
2224
- return getDb().select().from(groups).where(eq6(groups.path, path)).get();
3106
+ function getCategoryByPath(path) {
3107
+ return getDb().select().from(categories).where(eq6(categories.path, path)).get();
2225
3108
  }
2226
- function getOrCreateGroupPath(path) {
2227
- const existing = getGroupByPath(path);
3109
+ function getOrCreateCategoryPath(path) {
3110
+ const existing = getCategoryByPath(path);
2228
3111
  if (existing)
2229
3112
  return existing.id;
2230
3113
  const segments = path.split("/");
2231
3114
  let parentId;
2232
3115
  for (let i = 0;i < segments.length; i++) {
2233
3116
  const partialPath = segments.slice(0, i + 1).join("/");
2234
- const group = getGroupByPath(partialPath);
2235
- if (group) {
2236
- parentId = group.id;
3117
+ const category = getCategoryByPath(partialPath);
3118
+ if (category) {
3119
+ parentId = category.id;
2237
3120
  } else {
2238
3121
  const slug = segments[i];
2239
- const created = createGroup({ slug, name: slug, parentId });
3122
+ const created = createCategory({ slug, name: slug, parentId });
2240
3123
  parentId = created.id;
2241
3124
  }
2242
3125
  }
2243
3126
  return parentId;
2244
3127
  }
2245
- var init_groups = __esm(() => {
3128
+ var init_categories = __esm(() => {
2246
3129
  init_client();
2247
3130
  init_schema();
2248
3131
  init_id();
@@ -2299,19 +3182,19 @@ var init_template2 = __esm(() => {
2299
3182
  });
2300
3183
 
2301
3184
  // src/contract/context.ts
2302
- function buildSiblingContext(groupId, groupPath, currentSessionId) {
2303
- const siblings = getSessionsByGroup(groupId).filter((s) => s.id !== currentSessionId);
3185
+ function buildSiblingContext(categoryId, categoryPath, currentSessionId) {
3186
+ const siblings = getSessionsByCategory(categoryId).filter((s) => s.id !== currentSessionId);
2304
3187
  if (siblings.length === 0)
2305
3188
  return "";
2306
3189
  const lines = siblings.map((s) => {
2307
3190
  const ago = s.updatedAt ? formatAgo(s.updatedAt) : "unknown";
2308
3191
  const summary = s.summary ? ` \u2014 "${s.summary}"` : "";
2309
- return `- ${groupPath}/${s.slug}: ${s.status}${summary} (${ago})`;
3192
+ return `- ${categoryPath}/${s.slug}: ${s.status}${summary} (${ago})`;
2310
3193
  });
2311
3194
  const guidance = [
2312
3195
  "",
2313
3196
  "To inspect any sibling session's full record, run:",
2314
- " bertrand log <group>/<slug> --json",
3197
+ " bertrand log <category>/<slug> --json",
2315
3198
  "Returns session metadata, stats, conversations, and the full event timeline.",
2316
3199
  "Reach for this when the user references work done in another session, or you need to verify what was decided or tried elsewhere."
2317
3200
  ].join(`
@@ -2336,13 +3219,16 @@ function launchClaude(opts) {
2336
3219
  args.push("--session-id", opts.claudeId);
2337
3220
  }
2338
3221
  args.push("--append-system-prompt", opts.contract);
3222
+ const active = resolveActiveProject();
2339
3223
  const env = {
2340
3224
  ...process.env,
2341
3225
  BERTRAND_PID: String(process.pid),
2342
3226
  BERTRAND_CLAUDE_ID: opts.claudeId,
2343
3227
  BERTRAND_SESSION: opts.sessionId,
2344
3228
  BERTRAND_SESSION_NAME: opts.sessionName,
2345
- BERTRAND_SESSION_SLUG: opts.sessionSlug
3229
+ BERTRAND_SESSION_SLUG: opts.sessionSlug,
3230
+ BERTRAND_PROJECT: active.slug,
3231
+ BERTRAND_PROJECT_DB: active.db
2346
3232
  };
2347
3233
  return new Promise((resolve, reject) => {
2348
3234
  const child = spawn2("claude", args, {
@@ -2372,8 +3258,13 @@ function launchClaude(opts) {
2372
3258
  });
2373
3259
  });
2374
3260
  }
3261
+ function isClaudeRunning() {
3262
+ return activeChild !== null;
3263
+ }
2375
3264
  var activeChild = null;
2376
- var init_process = () => {};
3265
+ var init_process = __esm(() => {
3266
+ init_resolve();
3267
+ });
2377
3268
 
2378
3269
  // src/engine/spawn-context.ts
2379
3270
  import { execFile as execFile2 } from "child_process";
@@ -2444,11 +3335,11 @@ var init_spawn_context = __esm(() => {
2444
3335
 
2445
3336
  // src/lib/server-lifecycle.ts
2446
3337
  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";
3338
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2 } from "fs";
3339
+ import { join as join8 } from "path";
2449
3340
  function readPidFile() {
2450
3341
  try {
2451
- const pid = Number(readFileSync6(deps.pidFile, "utf-8").trim());
3342
+ const pid = Number(readFileSync7(deps.pidFile, "utf-8").trim());
2452
3343
  return Number.isFinite(pid) && pid > 0 ? pid : null;
2453
3344
  } catch {
2454
3345
  return null;
@@ -2495,7 +3386,7 @@ async function ensureServerStarted() {
2495
3386
  });
2496
3387
  child.unref();
2497
3388
  if (child.pid)
2498
- writeFileSync3(deps.pidFile, String(child.pid));
3389
+ writeFileSync4(deps.pidFile, String(child.pid));
2499
3390
  }
2500
3391
  function stopServerIfIdle() {
2501
3392
  if (deps.getActiveCount() > 0)
@@ -2513,11 +3404,11 @@ var init_server_lifecycle = __esm(() => {
2513
3404
  init_paths();
2514
3405
  init_sessions();
2515
3406
  defaultDeps = {
2516
- pidFile: join5(paths.root, "server.pid"),
3407
+ pidFile: join8(paths.root, "server.pid"),
2517
3408
  port: Number(process.env.BERTRAND_PORT ?? 5200),
2518
3409
  resolveBin() {
2519
3410
  try {
2520
- const config = JSON.parse(readFileSync6(join5(paths.root, "config.json"), "utf-8"));
3411
+ const config = JSON.parse(readFileSync7(join8(paths.root, "config.json"), "utf-8"));
2521
3412
  return typeof config?.bin === "string" ? config.bin : null;
2522
3413
  } catch {
2523
3414
  return null;
@@ -2530,17 +3421,52 @@ var init_server_lifecycle = __esm(() => {
2530
3421
 
2531
3422
  // src/engine/session.ts
2532
3423
  import { randomUUID } from "crypto";
3424
+ function forceFinalizeLive() {
3425
+ if (!liveSession)
3426
+ return;
3427
+ const session = getSession(liveSession.sessionId);
3428
+ if (!session) {
3429
+ liveSession = null;
3430
+ return;
3431
+ }
3432
+ if (session.status !== "active" && session.status !== "waiting") {
3433
+ liveSession = null;
3434
+ return;
3435
+ }
3436
+ try {
3437
+ updateSession(liveSession.sessionId, {
3438
+ status: "paused",
3439
+ pid: null,
3440
+ endedAt: new Date().toISOString()
3441
+ });
3442
+ } catch {}
3443
+ liveSession = null;
3444
+ }
3445
+ function installExitHandlers() {
3446
+ if (exitHandlersInstalled)
3447
+ return;
3448
+ exitHandlersInstalled = true;
3449
+ process.on("exit", forceFinalizeLive);
3450
+ const onSignal = (signal) => {
3451
+ if (isClaudeRunning())
3452
+ return;
3453
+ process.exit(signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143);
3454
+ };
3455
+ process.on("SIGINT", onSignal);
3456
+ process.on("SIGTERM", onSignal);
3457
+ process.on("SIGHUP", onSignal);
3458
+ }
2533
3459
  async function launch(opts) {
2534
- const existingGroup = getGroupByPath(opts.groupPath);
2535
- if (existingGroup) {
2536
- const existing = getSessionByGroupSlug(existingGroup.id, opts.slug);
3460
+ const existingCategory = getCategoryByPath(opts.categoryPath);
3461
+ if (existingCategory) {
3462
+ const existing = getSessionByCategorySlug(existingCategory.id, opts.slug);
2537
3463
  if (existing) {
2538
- throw new Error(`Session "${opts.slug}" already exists in group "${opts.groupPath}"`);
3464
+ throw new Error(`Session "${opts.slug}" already exists in category "${opts.categoryPath}"`);
2539
3465
  }
2540
3466
  }
2541
- const groupId = getOrCreateGroupPath(opts.groupPath);
3467
+ const categoryId = getOrCreateCategoryPath(opts.categoryPath);
2542
3468
  const session = createSession({
2543
- groupId,
3469
+ categoryId,
2544
3470
  slug: opts.slug,
2545
3471
  name: opts.name ?? opts.slug
2546
3472
  });
@@ -2554,34 +3480,29 @@ async function launch(opts) {
2554
3480
  sessionId: session.id
2555
3481
  });
2556
3482
  updateSession(session.id, { status: "active", pid: process.pid });
3483
+ liveSession = { sessionId: session.id, claudeId };
3484
+ installExitHandlers();
2557
3485
  await ensureServerStarted();
2558
3486
  const spawnContext = await captureSpawnContext();
2559
- const sessionName = `${opts.groupPath}/${opts.slug}`;
2560
- insertEvent({
3487
+ const sessionName = `${opts.categoryPath}/${opts.slug}`;
3488
+ emitSessionStarted({
2561
3489
  sessionId: session.id,
2562
3490
  conversationId: claudeId,
2563
- event: "session.started",
2564
- meta: {
2565
- group_path: opts.groupPath,
2566
- session_name: opts.name ?? opts.slug,
2567
- session_slug: opts.slug,
2568
- labels: opts.labelNames ?? [],
2569
- summary: session.summary ?? null
2570
- }
3491
+ categoryPath: opts.categoryPath,
3492
+ sessionName: opts.name ?? opts.slug,
3493
+ sessionSlug: opts.slug,
3494
+ labels: opts.labelNames ?? [],
3495
+ summary: session.summary ?? null
2571
3496
  });
2572
- insertEvent({
3497
+ emitClaudeStarted({
2573
3498
  sessionId: session.id,
2574
3499
  conversationId: claudeId,
2575
- event: "claude.started",
2576
- meta: {
2577
- claude_id: claudeId,
2578
- model: spawnContext.model,
2579
- claude_version: spawnContext.claudeVersion,
2580
- git: spawnContext.git,
2581
- cwd: spawnContext.cwd
2582
- }
3500
+ model: spawnContext.model,
3501
+ claudeVersion: spawnContext.claudeVersion,
3502
+ git: spawnContext.git,
3503
+ cwd: spawnContext.cwd
2583
3504
  });
2584
- const siblingContext = buildSiblingContext(groupId, opts.groupPath, session.id);
3505
+ const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
2585
3506
  const contract = buildContract(sessionName, siblingContext);
2586
3507
  const exitCode = await launchClaude({
2587
3508
  sessionId: session.id,
@@ -2597,18 +3518,18 @@ async function resume(opts) {
2597
3518
  const session = getSession(opts.sessionId);
2598
3519
  if (!session)
2599
3520
  throw new Error(`Session not found: ${opts.sessionId}`);
2600
- const group = getGroup(session.groupId);
2601
- const sessionName = group ? `${group.path}/${session.slug}` : session.name;
3521
+ const category = getCategory(session.categoryId);
3522
+ const sessionName = category ? `${category.path}/${session.slug}` : session.name;
2602
3523
  updateSession(session.id, { status: "active", pid: process.pid });
3524
+ liveSession = { sessionId: session.id, claudeId: opts.conversationId };
3525
+ installExitHandlers();
2603
3526
  await ensureServerStarted();
2604
- insertEvent({
3527
+ emitSessionResumed({
2605
3528
  sessionId: session.id,
2606
- conversationId: opts.conversationId,
2607
- event: "session.resumed",
2608
- meta: { claude_id: opts.conversationId }
3529
+ conversationId: opts.conversationId
2609
3530
  });
2610
- const groupPath = group?.path ?? "";
2611
- const siblingContext = buildSiblingContext(session.groupId, groupPath, session.id);
3531
+ const categoryPath = category?.path ?? "";
3532
+ const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
2612
3533
  const contract = buildContract(sessionName, siblingContext);
2613
3534
  const exitCode = await launchClaude({
2614
3535
  sessionId: session.id,
@@ -2629,29 +3550,28 @@ function finalizeSession(sessionId, conversationId, exitCode) {
2629
3550
  if (conversationExists) {
2630
3551
  endConversation(conversationId);
2631
3552
  }
2632
- insertEvent({
3553
+ emitClaudeEnded({
2633
3554
  sessionId,
2634
3555
  conversationId: safeConversationId,
2635
- event: "claude.ended",
2636
- meta: { claude_id: conversationId, exit_code: exitCode }
3556
+ exitCode
2637
3557
  });
2638
3558
  updateSession(sessionId, {
2639
3559
  status: "paused",
2640
3560
  pid: null,
2641
3561
  endedAt: new Date().toISOString()
2642
3562
  });
2643
- insertEvent({
2644
- sessionId,
2645
- event: "session.end"
2646
- });
3563
+ if (liveSession?.sessionId === sessionId)
3564
+ liveSession = null;
3565
+ emitSessionEnded({ sessionId });
2647
3566
  computeAndPersist(sessionId);
2648
3567
  stopServerIfIdle();
2649
3568
  }
3569
+ var liveSession = null, exitHandlersInstalled = false;
2650
3570
  var init_session = __esm(() => {
2651
3571
  init_sessions();
2652
3572
  init_conversations();
2653
- init_events();
2654
- init_groups();
3573
+ init_emit();
3574
+ init_categories();
2655
3575
  init_labels();
2656
3576
  init_template2();
2657
3577
  init_context();
@@ -2663,8 +3583,8 @@ var init_session = __esm(() => {
2663
3583
 
2664
3584
  // src/tui/app.tsx
2665
3585
  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";
3586
+ import { existsSync as existsSync8, readFileSync as readFileSync8, unlinkSync as unlinkSync3 } from "fs";
3587
+ import { join as join9 } from "path";
2668
3588
  import { randomUUID as randomUUID2 } from "crypto";
2669
3589
  async function runScreen(screen, ...args) {
2670
3590
  const tmpFile = `/tmp/bertrand-tui-${process.pid}-${Date.now()}.json`;
@@ -2677,12 +3597,12 @@ async function runScreen(screen, ...args) {
2677
3597
  if (exitCode !== 0) {
2678
3598
  throw new Error(`TUI screen "${screen}" exited with code ${exitCode}`);
2679
3599
  }
2680
- const result = JSON.parse(readFileSync7(tmpFile, "utf-8"));
3600
+ const result = JSON.parse(readFileSync8(tmpFile, "utf-8"));
2681
3601
  unlinkSync3(tmpFile);
2682
3602
  return result;
2683
3603
  }
2684
- async function startLaunchTui() {
2685
- return runScreen("launch");
3604
+ async function startStartupTui(skipProjectPicker, initialProjectSlug) {
3605
+ return runScreen("startup", String(skipProjectPicker), initialProjectSlug);
2686
3606
  }
2687
3607
  async function startExitTui(sessionId) {
2688
3608
  return runScreen("exit", sessionId);
@@ -2732,26 +3652,38 @@ async function runSessionLoop(sessionId) {
2732
3652
  }
2733
3653
  }
2734
3654
  }
3655
+ function shouldShowProjectPicker() {
3656
+ if (process.env.BERTRAND_PROJECT)
3657
+ return false;
3658
+ const projects = listProjects();
3659
+ return projects.length !== 1;
3660
+ }
2735
3661
  async function startTui() {
2736
- const selection = await startLaunchTui();
3662
+ const skipProjectPicker = !shouldShowProjectPicker();
3663
+ const initialProjectSlug = getActiveProjectSlug();
3664
+ const selection = await startStartupTui(skipProjectPicker, initialProjectSlug);
3665
+ _resetActiveProjectCache();
2737
3666
  switch (selection.type) {
2738
3667
  case "quit":
2739
- break;
3668
+ return;
2740
3669
  case "create": {
2741
- const sessionId = await launch(selection);
3670
+ const sessionId = await launch({
3671
+ categoryPath: selection.categoryPath,
3672
+ slug: selection.slug
3673
+ });
2742
3674
  await runSessionLoop(sessionId);
2743
- break;
3675
+ return;
2744
3676
  }
2745
3677
  case "pick": {
2746
3678
  const conversationId = await resolveConversationForResume(selection.sessionId);
2747
3679
  if (!conversationId)
2748
- break;
3680
+ return;
2749
3681
  const sessionId = await resume({
2750
3682
  sessionId: selection.sessionId,
2751
3683
  conversationId
2752
3684
  });
2753
3685
  await runSessionLoop(sessionId);
2754
- break;
3686
+ return;
2755
3687
  }
2756
3688
  }
2757
3689
  }
@@ -2761,9 +3693,11 @@ var init_app = __esm(() => {
2761
3693
  init_conversations();
2762
3694
  init_session_archive();
2763
3695
  init_session();
3696
+ init_registry();
3697
+ init_resolve();
2764
3698
  SCREEN_ENTRY = (() => {
2765
- const built = join6(import.meta.dir, "run-screen.js");
2766
- return existsSync6(built) ? built : join6(import.meta.dir, "run-screen.tsx");
3699
+ const built = join9(import.meta.dir, "run-screen.js");
3700
+ return existsSync8(built) ? built : join9(import.meta.dir, "run-screen.tsx");
2767
3701
  })();
2768
3702
  });
2769
3703
 
@@ -2775,7 +3709,7 @@ function parseSessionName(input) {
2775
3709
  }
2776
3710
  const segments = trimmed.split("/").filter(Boolean);
2777
3711
  if (segments.length < 2) {
2778
- throw new Error(`Session name must include at least one group: "group/session" (got "${trimmed}")`);
3712
+ throw new Error(`Session name must include at least one category: "category/session" (got "${trimmed}")`);
2779
3713
  }
2780
3714
  for (const segment of segments) {
2781
3715
  if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {
@@ -2783,8 +3717,8 @@ function parseSessionName(input) {
2783
3717
  }
2784
3718
  }
2785
3719
  const slug = segments[segments.length - 1];
2786
- const groupPath = segments.slice(0, -1).join("/");
2787
- return { groupPath, slug };
3720
+ const categoryPath = segments.slice(0, -1).join("/");
3721
+ return { categoryPath, slug };
2788
3722
  }
2789
3723
 
2790
3724
  // src/engine/recovery.ts
@@ -2805,11 +3739,9 @@ function recoverStaleSessions() {
2805
3739
  status: "paused",
2806
3740
  pid: null
2807
3741
  });
2808
- insertEvent({
3742
+ emitSessionPausedByRecovery({
2809
3743
  sessionId: session.id,
2810
- event: "session.paused",
2811
- summary: "Recovered from stale state (process not found)",
2812
- meta: { stale_pid: session.pid }
3744
+ stalePid: session.pid
2813
3745
  });
2814
3746
  recovered++;
2815
3747
  }
@@ -2818,7 +3750,7 @@ function recoverStaleSessions() {
2818
3750
  }
2819
3751
  var init_recovery = __esm(() => {
2820
3752
  init_sessions();
2821
- init_events();
3753
+ init_emit();
2822
3754
  });
2823
3755
 
2824
3756
  // src/cli/commands/launch.ts
@@ -2832,8 +3764,8 @@ var init_launch = __esm(() => {
2832
3764
  recoverStaleSessions();
2833
3765
  const sessionName = args[0];
2834
3766
  if (sessionName) {
2835
- const { groupPath, slug } = parseSessionName(sessionName);
2836
- const sessionId = await launch({ groupPath, slug });
3767
+ const { categoryPath, slug } = parseSessionName(sessionName);
3768
+ const sessionId = await launch({ categoryPath, slug });
2837
3769
  await runSessionLoop(sessionId);
2838
3770
  return;
2839
3771
  }
@@ -2841,33 +3773,14 @@ var init_launch = __esm(() => {
2841
3773
  });
2842
3774
  });
2843
3775
 
2844
- // src/db/migrate.ts
2845
- import { Database as Database3 } from "bun:sqlite";
2846
- import { drizzle as drizzle2 } from "drizzle-orm/bun-sqlite";
2847
- import { migrate } from "drizzle-orm/bun-sqlite/migrator";
2848
- import { mkdirSync as mkdirSync2 } from "fs";
2849
- import { dirname as dirname3 } from "path";
2850
- function runMigrations() {
2851
- mkdirSync2(dirname3(paths.db), { recursive: true });
2852
- const sqlite = new Database3(paths.db);
2853
- sqlite.exec("PRAGMA journal_mode = WAL");
2854
- sqlite.exec("PRAGMA foreign_keys = ON");
2855
- const db = drizzle2(sqlite);
2856
- migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
2857
- sqlite.close();
2858
- }
2859
- var MIGRATIONS_FOLDER;
2860
- var init_migrate = __esm(() => {
2861
- init_paths();
2862
- MIGRATIONS_FOLDER = import.meta.dir + "/migrations";
2863
- if (false) {}
2864
- });
2865
-
2866
3776
  // src/hooks/scripts.ts
3777
+ function quietHelper(bin) {
3778
+ return `bq() { ${bin} "$@" 2>/dev/null || true; }`;
3779
+ }
2867
3780
  function waitingScript(bin) {
2868
- const BIN = bin;
2869
3781
  return `#!/usr/bin/env bash
2870
3782
  # Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
3783
+ ${quietHelper(bin)}
2871
3784
  sid="\${BERTRAND_SESSION:-}"
2872
3785
  [ -z "$sid" ] && exit 0
2873
3786
 
@@ -2891,23 +3804,26 @@ question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut
2891
3804
  # Clear working debounce marker so next resume\u2192working transition fires
2892
3805
  rm -f "/tmp/bertrand-working-$sid"
2893
3806
 
2894
- ${BIN} update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
3807
+ bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
2895
3808
 
2896
- # Context snapshot \u2014 extract transcript path and capture token usage
3809
+ # Context snapshot \u2014 extract transcript path and capture token usage.
3810
+ # assistant-message captures the latest turn's text + recap tag in one pass;
3811
+ # replaces the older recap-thinking call which only grabbed the recap. Dedup
3812
+ # inside the command makes it idempotent vs the matching Stop-time capture,
3813
+ # so the same turn never lands twice.
2897
3814
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
2898
3815
  if [ -n "$tpath" ]; then
2899
- ${BIN} snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
2900
- ${BIN} recap-thinking --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3816
+ bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3817
+ bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
2901
3818
  fi
2902
3819
 
2903
3820
  # Badge + notify in background \u2014 terminal UI doesn't need to block Claude
2904
- ${BIN} badge message-question --color '#e0b956' --priority 20 --beep &
2905
- ${BIN} notify bertrand "$question" &
3821
+ bq badge message-question --color '#e0b956' --priority 20 --beep &
3822
+ bq notify bertrand "$question" &
2906
3823
  wait
2907
3824
  `;
2908
3825
  }
2909
3826
  function answeredScript(bin) {
2910
- const BIN = bin;
2911
3827
  return `#!/usr/bin/env bash
2912
3828
  # Hook: PostToolUse AskUserQuestion \u2192 mark session as active
2913
3829
  #
@@ -2915,6 +3831,7 @@ function answeredScript(bin) {
2915
3831
  # Claude Code so the agent halts immediately instead of taking another turn.
2916
3832
  # This is the mechanical enforcement of the contract's loop-exit rule \u2014 the
2917
3833
  # contract prose is a soft hint, this JSON is the guarantee.
3834
+ ${quietHelper(bin)}
2918
3835
  sid="\${BERTRAND_SESSION:-}"
2919
3836
  [ -z "$sid" ] && exit 0
2920
3837
 
@@ -2936,9 +3853,9 @@ meta="$(printf '%s' "$input" | jq --arg cid "$cid" '
2936
3853
  # Concatenate all answer values into a single string for the Done-for-now check.
2937
3854
  done_check="$(printf '%s' "$meta" | jq -r '.answers | to_entries | map(.value | tostring) | join(" ")' 2>/dev/null)"
2938
3855
 
2939
- ${BIN} update --session-id "$sid" --event session.answered --meta "$meta"
3856
+ bq update --session-id "$sid" --event session.answered --meta "$meta"
2940
3857
 
2941
- ${BIN} badge --clear &
3858
+ bq badge --clear &
2942
3859
 
2943
3860
  # Halt the agent loop if the user signaled Done for now. The Stop hook
2944
3861
  # (on-done.sh) will fire afterwards and mark the session as paused.
@@ -2951,7 +3868,7 @@ if printf '%s' "$done_check" | grep -q "Done for now"; then
2951
3868
  [.questions[]?.options[]? | select(.label == "Done for now") | .description] | first // empty
2952
3869
  ' 2>/dev/null)"
2953
3870
  if [ -n "$recap" ]; then
2954
- ${BIN} update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
3871
+ bq update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
2955
3872
  fi
2956
3873
 
2957
3874
  printf '{"continue": false, "stopReason": "User selected Done for now"}\\n'
@@ -2961,9 +3878,9 @@ wait
2961
3878
  `;
2962
3879
  }
2963
3880
  function activeScript(bin) {
2964
- const BIN = bin;
2965
3881
  return `#!/usr/bin/env bash
2966
3882
  # Hook: PreToolUse (catch-all) \u2192 flip waiting to active
3883
+ ${quietHelper(bin)}
2967
3884
  sid="\${BERTRAND_SESSION:-}"
2968
3885
  [ -z "$sid" ] && exit 0
2969
3886
 
@@ -2983,13 +3900,13 @@ ${EXTRACT_TOOL}
2983
3900
 
2984
3901
  touch "$marker"
2985
3902
  cid="\${BERTRAND_CLAUDE_ID:-}"
2986
- ${BIN} update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3903
+ bq update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
2987
3904
  `;
2988
3905
  }
2989
3906
  function permissionWaitScript(bin) {
2990
- const BIN = bin;
2991
3907
  return `#!/usr/bin/env bash
2992
3908
  # Hook: PermissionRequest \u2192 mark pending, emit permission.request
3909
+ ${quietHelper(bin)}
2993
3910
  sid="\${BERTRAND_SESSION:-}"
2994
3911
  [ -z "$sid" ] && exit 0
2995
3912
 
@@ -3008,38 +3925,45 @@ case "$tool" in
3008
3925
  esac
3009
3926
 
3010
3927
  cid="\${BERTRAND_CLAUDE_ID:-}"
3011
- ${BIN} update --session-id "$sid" --event permission.request --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, claude_id:$cid}')"
3928
+ bq update --session-id "$sid" --event permission.request --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, claude_id:$cid}')"
3012
3929
 
3013
3930
  # Badge + notify in background
3014
- ${BIN} badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
3015
- ${BIN} notify bertrand "Needs permission: $tool" &
3931
+ bq badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
3932
+ bq notify bertrand "Needs permission: $tool" &
3016
3933
  wait
3017
3934
  `;
3018
3935
  }
3019
3936
  function permissionDoneScript(bin) {
3020
- const BIN = bin;
3021
3937
  return `#!/usr/bin/env bash
3022
3938
  # Hook: PostToolUse (catch-all)
3023
3939
  #
3024
- # Two flows:
3025
- # 1. Edit/Write/MultiEdit: ALWAYS emit tool.applied with diff, regardless of permission
3026
- # flow. This is the only way to capture diffs for auto-approved edits \u2014 bertrand
3027
- # must never require disabling auto-approve to gather data.
3028
- # 2. Other tools: emit permission.resolve only if a PermissionRequest preceded this
3029
- # (marker exists). Rejected tools never reach PostToolUse, so a request without a
3030
- # resolve = rejected.
3940
+ # Captures every tool call Claude makes. Three event flows:
3941
+ # 1. Edit/Write/MultiEdit \u2192 tool.applied with diff payload. Keeps the
3942
+ # existing dashboard diff-renderer happy and is the only place we get
3943
+ # old_string/new_string on auto-approved edits.
3944
+ # 2. Tools that went through a permission prompt \u2192 permission.resolve.
3945
+ # The PermissionRequest hook set a marker; we clear it and log the
3946
+ # approval. (Denials never reach PostToolUse, so absence-of-resolve
3947
+ # means the user said no.)
3948
+ # 3. Everything else (auto-approved Bash/Read/Grep/Glob/etc.) \u2192 tool.used
3949
+ # with outcome:"auto". Previously this case dropped the call entirely;
3950
+ # now Claude's read-only / shell activity shows up in the timeline.
3951
+ ${quietHelper(bin)}
3031
3952
  sid="\${BERTRAND_SESSION:-}"
3032
3953
  [ -z "$sid" ] && exit 0
3033
3954
 
3034
3955
  input="$(cat)"
3035
3956
  ${EXTRACT_TOOL}
3036
3957
 
3958
+ # Don't double-log: AskUserQuestion has its own waiting/answered events
3959
+ [ "$tool" = "AskUserQuestion" ] && exit 0
3960
+
3037
3961
  marker="/tmp/bertrand-perm-pending-$sid"
3038
3962
  had_marker=0
3039
3963
  if [ -f "$marker" ]; then
3040
3964
  had_marker=1
3041
3965
  rm -f "$marker"
3042
- ${BIN} badge --clear &
3966
+ bq badge --clear &
3043
3967
  fi
3044
3968
 
3045
3969
  cid="\${BERTRAND_CLAUDE_ID:-}"
@@ -3051,10 +3975,6 @@ case "$tool" in
3051
3975
  Write) summary="wrote a file" ;;
3052
3976
  *) summary="edited a file" ;;
3053
3977
  esac
3054
- # Single jq pass: build meta.permissions[] with diff data so the dashboard renders
3055
- # via the existing WorkContent path (same shape as collapsed permission events).
3056
- # Emit camelCase keys (oldStr/newStr/edits) directly so WorkContent reads
3057
- # meta.permissions[] without going through transforms.ts's snake\u2192camel adapter.
3058
3978
  meta="$(printf '%s' "$input" | jq --arg t "$tool" --arg d "$detail" --arg cid "$cid" '
3059
3979
  {
3060
3980
  permissions: [
@@ -3067,28 +3987,40 @@ case "$tool" in
3067
3987
  claude_id: $cid
3068
3988
  }
3069
3989
  ')"
3070
- ${BIN} update --session-id "$sid" --event tool.applied --summary "$summary" --meta "$meta"
3990
+ bq update --session-id "$sid" --event tool.applied --summary "$summary" --meta "$meta"
3071
3991
  wait
3072
3992
  exit 0
3073
3993
  ;;
3074
3994
  esac
3075
3995
 
3076
- # Other tools: only emit permission.resolve if there was a real prompt
3077
- [ "$had_marker" = "0" ] && exit 0
3078
-
3996
+ # Extract a tool-shaped detail for the timeline summary. Bash gets the
3997
+ # command, file tools get the path; everything else falls back to a generic
3998
+ # label inside the emit helper.
3079
3999
  detail=""
3080
4000
  case "$tool" in
3081
4001
  Bash) detail="$(printf '%s' "$input" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
4002
+ Read|NotebookRead) detail="$(printf '%s' "$input" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
4003
+ Glob) detail="$(printf '%s' "$input" | grep -o '"pattern":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
4004
+ Grep) detail="$(printf '%s' "$input" | grep -o '"pattern":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
4005
+ WebFetch) detail="$(printf '%s' "$input" | grep -o '"url":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-300)" ;;
4006
+ WebSearch) detail="$(printf '%s' "$input" | grep -o '"query":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
3082
4007
  esac
3083
4008
 
3084
- ${BIN} update --session-id "$sid" --event permission.resolve --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"approved", claude_id:$cid}')"
4009
+ if [ "$had_marker" = "1" ]; then
4010
+ # Prompted-then-approved path. Keep permission.resolve for back-compat
4011
+ # rendering; downstream consumers can migrate to tool.used at their pace.
4012
+ bq update --session-id "$sid" --event permission.resolve --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"approved", claude_id:$cid}')"
4013
+ else
4014
+ # Auto-approved path. Without tool.used, these calls were invisible.
4015
+ bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"auto", claude_id:$cid}')"
4016
+ fi
3085
4017
  wait
3086
4018
  `;
3087
4019
  }
3088
4020
  function userPromptScript(bin) {
3089
- const BIN = bin;
3090
4021
  return `#!/usr/bin/env bash
3091
4022
  # Hook: UserPromptSubmit \u2192 record user free-text prompt
4023
+ ${quietHelper(bin)}
3092
4024
  sid="\${BERTRAND_SESSION:-}"
3093
4025
  [ -z "$sid" ] && exit 0
3094
4026
 
@@ -3098,28 +4030,31 @@ cid="\${BERTRAND_CLAUDE_ID:-}"
3098
4030
  meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
3099
4031
  [ -z "$meta" ] && exit 0
3100
4032
 
3101
- ${BIN} update --session-id "$sid" --event user.prompt --meta "$meta"
4033
+ bq update --session-id "$sid" --event user.prompt --meta "$meta"
3102
4034
  `;
3103
4035
  }
3104
4036
  function doneScript(bin) {
3105
- const BIN = bin;
3106
4037
  return `#!/usr/bin/env bash
3107
4038
  # Hook: Stop \u2192 mark session as paused
4039
+ ${quietHelper(bin)}
3108
4040
  sid="\${BERTRAND_SESSION:-}"
3109
4041
  [ -z "$sid" ] && exit 0
3110
4042
 
3111
4043
  input="$(cat)"
3112
4044
  cid="\${BERTRAND_CLAUDE_ID:-}"
3113
- ${BIN} update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
4045
+ bq update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3114
4046
 
3115
- # Final context snapshot \u2014 capture token usage at session end
4047
+ # Final transcript reads. assistant-message dedups against the most-recent
4048
+ # AskUQ-time capture, so a Done-for-now exit (no work between AskUQ and Stop)
4049
+ # lands zero new events here; intermediate Stops with fresh assistant output
4050
+ # do land new ones.
3116
4051
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
3117
4052
  if [ -n "$tpath" ]; then
3118
- ${BIN} snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3119
- ${BIN} assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
4053
+ bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
4054
+ bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3120
4055
  fi
3121
4056
 
3122
- ${BIN} badge check --color '#58c142' --priority 10
4057
+ bq badge check --color '#58c142' --priority 10
3123
4058
  `;
3124
4059
  }
3125
4060
  var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
@@ -3136,13 +4071,13 @@ var init_scripts = __esm(() => {
3136
4071
  });
3137
4072
 
3138
4073
  // src/hooks/install.ts
3139
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4, chmodSync as chmodSync2 } from "fs";
3140
- import { join as join7 } from "path";
4074
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
4075
+ import { join as join10 } from "path";
3141
4076
  function installHookScripts(bin) {
3142
- mkdirSync3(paths.hooks, { recursive: true });
4077
+ mkdirSync7(paths.hooks, { recursive: true });
3143
4078
  for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
3144
- const filePath = join7(paths.hooks, filename);
3145
- writeFileSync4(filePath, scriptFn(bin));
4079
+ const filePath = join10(paths.hooks, filename);
4080
+ writeFileSync5(filePath, scriptFn(bin));
3146
4081
  chmodSync2(filePath, 493);
3147
4082
  }
3148
4083
  console.log(`Installed ${Object.keys(HOOK_SCRIPTS).length} hook scripts to ${paths.hooks}`);
@@ -3153,8 +4088,8 @@ var init_install = __esm(() => {
3153
4088
  });
3154
4089
 
3155
4090
  // src/hooks/settings.ts
3156
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4 } from "fs";
3157
- import { join as join8, dirname as dirname4 } from "path";
4091
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "fs";
4092
+ import { join as join11, dirname as dirname4 } from "path";
3158
4093
  import { homedir as homedir2 } from "os";
3159
4094
  function isBertrandGroup(group) {
3160
4095
  return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
@@ -3162,7 +4097,7 @@ function isBertrandGroup(group) {
3162
4097
  function installHookSettings() {
3163
4098
  let settings = {};
3164
4099
  try {
3165
- settings = JSON.parse(readFileSync8(SETTINGS_PATH, "utf-8"));
4100
+ settings = JSON.parse(readFileSync9(SETTINGS_PATH, "utf-8"));
3166
4101
  } catch {}
3167
4102
  const existingHooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
3168
4103
  const merged = { ...existingHooks };
@@ -3171,15 +4106,15 @@ function installHookSettings() {
3171
4106
  merged[eventType] = [...existing, ...bertrandGroups];
3172
4107
  }
3173
4108
  settings.hooks = merged;
3174
- mkdirSync4(dirname4(SETTINGS_PATH), { recursive: true });
3175
- writeFileSync5(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
4109
+ mkdirSync8(dirname4(SETTINGS_PATH), { recursive: true });
4110
+ writeFileSync6(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
3176
4111
  `);
3177
4112
  console.log(`Updated ${SETTINGS_PATH} with bertrand hooks`);
3178
4113
  }
3179
4114
  var SETTINGS_PATH, BERTRAND_HOOKS;
3180
4115
  var init_settings = __esm(() => {
3181
4116
  init_paths();
3182
- SETTINGS_PATH = join8(homedir2(), ".claude", "settings.json");
4117
+ SETTINGS_PATH = join11(homedir2(), ".claude", "settings.json");
3183
4118
  BERTRAND_HOOKS = {
3184
4119
  PreToolUse: [
3185
4120
  {
@@ -3223,8 +4158,8 @@ var init_settings = __esm(() => {
3223
4158
  });
3224
4159
 
3225
4160
  // src/lib/completions.ts
3226
- import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5 } from "fs";
3227
- import { join as join9 } from "path";
4161
+ import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
4162
+ import { join as join12 } from "path";
3228
4163
  function bashCompletion() {
3229
4164
  return `# bertrand bash completion
3230
4165
  _bertrand() {
@@ -3254,11 +4189,11 @@ function fishCompletion() {
3254
4189
  `;
3255
4190
  }
3256
4191
  function generateCompletions() {
3257
- const dir = join9(paths.root, "completions");
3258
- mkdirSync5(dir, { recursive: true });
3259
- writeFileSync6(join9(dir, "bertrand.bash"), bashCompletion());
3260
- writeFileSync6(join9(dir, "_bertrand"), zshCompletion());
3261
- writeFileSync6(join9(dir, "bertrand.fish"), fishCompletion());
4192
+ const dir = join12(paths.root, "completions");
4193
+ mkdirSync9(dir, { recursive: true });
4194
+ writeFileSync7(join12(dir, "bertrand.bash"), bashCompletion());
4195
+ writeFileSync7(join12(dir, "_bertrand"), zshCompletion());
4196
+ writeFileSync7(join12(dir, "bertrand.fish"), fishCompletion());
3262
4197
  console.log(`Shell completions written to ${dir}`);
3263
4198
  console.log(" Add to your shell config:");
3264
4199
  console.log(` bash: source ${dir}/bertrand.bash`);
@@ -3274,8 +4209,10 @@ var init_completions = __esm(() => {
3274
4209
  "log",
3275
4210
  "stats",
3276
4211
  "archive",
4212
+ "project",
3277
4213
  "update",
3278
4214
  "serve",
4215
+ "sync",
3279
4216
  "import",
3280
4217
  "completion",
3281
4218
  "badge",
@@ -3285,9 +4222,9 @@ var init_completions = __esm(() => {
3285
4222
 
3286
4223
  // src/cli/commands/init.ts
3287
4224
  var exports_init = {};
3288
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync7, chmodSync as chmodSync3 } from "fs";
4225
+ import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3 } from "fs";
3289
4226
  import { execSync as execSync2 } from "child_process";
3290
- import { join as join10 } from "path";
4227
+ import { join as join13 } from "path";
3291
4228
  function detectTerminal() {
3292
4229
  try {
3293
4230
  execSync2("which wsh", { stdio: "ignore" });
@@ -3302,10 +4239,10 @@ function resolveBin() {
3302
4239
  return onPath;
3303
4240
  const entry = process.argv[1];
3304
4241
  if (entry && SOURCE_ENTRY.test(entry)) {
3305
- const launcherDir = join10(paths.root, "bin");
3306
- const launcher = join10(launcherDir, "bertrand-dev");
3307
- mkdirSync6(launcherDir, { recursive: true });
3308
- writeFileSync7(launcher, `#!/usr/bin/env bash
4242
+ const launcherDir = join13(paths.root, "bin");
4243
+ const launcher = join13(launcherDir, "bertrand-dev");
4244
+ mkdirSync10(launcherDir, { recursive: true });
4245
+ writeFileSync8(launcher, `#!/usr/bin/env bash
3309
4246
  exec ${process.execPath} ${JSON.stringify(entry)} "$@"
3310
4247
  `);
3311
4248
  chmodSync3(launcher, 493);
@@ -3320,16 +4257,18 @@ var init_init = __esm(() => {
3320
4257
  init_install();
3321
4258
  init_settings();
3322
4259
  init_paths();
4260
+ init_resolve();
3323
4261
  init_completions();
3324
4262
  init_config2();
3325
4263
  SOURCE_ENTRY = /\/src\/index\.tsx?$/;
3326
4264
  register("init", async () => {
3327
4265
  console.log(`bertrand init
3328
4266
  `);
3329
- mkdirSync6(paths.root, { recursive: true });
3330
- mkdirSync6(paths.hooks, { recursive: true });
3331
- runMigrations();
3332
- console.log(` Database: ${paths.db}`);
4267
+ mkdirSync10(paths.root, { recursive: true });
4268
+ mkdirSync10(paths.hooks, { recursive: true });
4269
+ const active = resolveActiveProject();
4270
+ runMigrations(active.db);
4271
+ console.log(` Database: ${active.db}`);
3333
4272
  const bin = resolveBin();
3334
4273
  if (!bin) {
3335
4274
  console.error(`
@@ -3363,7 +4302,7 @@ function buildRows(sessions2) {
3363
4302
  return sessions2.sort((a, b) => new Date(b.session.updatedAt).getTime() - new Date(a.session.updatedAt).getTime()).map((row) => {
3364
4303
  const stats = getSessionStats(row.session.id);
3365
4304
  return {
3366
- name: `${row.groupPath}/${row.session.slug}`,
4305
+ name: `${row.categoryPath}/${row.session.slug}`,
3367
4306
  status: row.session.status,
3368
4307
  updatedAt: row.session.updatedAt,
3369
4308
  conversations: stats?.conversationCount ?? 0,
@@ -3403,7 +4342,7 @@ var STATUS_DOTS;
3403
4342
  var init_list = __esm(() => {
3404
4343
  init_router();
3405
4344
  init_sessions();
3406
- init_groups();
4345
+ init_categories();
3407
4346
  init_stats();
3408
4347
  init_format();
3409
4348
  STATUS_DOTS = {
@@ -3416,17 +4355,17 @@ var init_list = __esm(() => {
3416
4355
  register("list", async (args) => {
3417
4356
  const isJson = args.includes("--json");
3418
4357
  const showAll = args.includes("--all") || args.includes("-a");
3419
- const groupFlag = args.indexOf("--group");
3420
- const groupPath = groupFlag !== -1 ? args[groupFlag + 1] : undefined;
4358
+ const categoryFlag = args.indexOf("--category");
4359
+ const categoryPath = categoryFlag !== -1 ? args[categoryFlag + 1] : undefined;
3421
4360
  let sessionRows;
3422
- if (groupPath) {
3423
- const group = getGroupByPath(groupPath);
3424
- if (!group) {
3425
- console.error(`Group not found: ${groupPath}`);
4361
+ if (categoryPath) {
4362
+ const category = getCategoryByPath(categoryPath);
4363
+ if (!category) {
4364
+ console.error(`Category not found: ${categoryPath}`);
3426
4365
  process.exit(1);
3427
4366
  }
3428
- const groupSessions = getSessionsByGroup(group.id);
3429
- sessionRows = groupSessions.map((s) => ({ session: s, groupPath: group.path }));
4367
+ const categorySessions = getSessionsByCategory(category.id);
4368
+ sessionRows = categorySessions.map((s) => ({ session: s, categoryPath: category.path }));
3430
4369
  if (!showAll) {
3431
4370
  sessionRows = sessionRows.filter((r) => r.session.status !== "archived");
3432
4371
  }
@@ -3538,8 +4477,11 @@ var init_catalog = __esm(() => {
3538
4477
  "vercel.deploy": { label: "deployed", category: "integration", color: 245, detailColor: 245, skip: false },
3539
4478
  "user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
3540
4479
  "context.snapshot": { label: "context", category: "context", color: 245, detailColor: 245, skip: true },
4480
+ "tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: true },
3541
4481
  "tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
3542
- "session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false }
4482
+ "session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
4483
+ "assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
4484
+ "assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false }
3543
4485
  };
3544
4486
  DEFAULT_INFO = {
3545
4487
  label: "unknown",
@@ -3579,7 +4521,7 @@ function collapsePermissions(events2) {
3579
4521
  let i = 0;
3580
4522
  while (i < events2.length) {
3581
4523
  const ev = events2[i];
3582
- if (ev.event !== "permission.request" && ev.event !== "permission.resolve") {
4524
+ if (!ROLLUP_EVENTS.has(ev.event)) {
3583
4525
  result.push(ev);
3584
4526
  i++;
3585
4527
  continue;
@@ -3587,7 +4529,7 @@ function collapsePermissions(events2) {
3587
4529
  const batch = [];
3588
4530
  while (i < events2.length) {
3589
4531
  const current = events2[i];
3590
- if (current.event !== "permission.request" && current.event !== "permission.resolve")
4532
+ if (!ROLLUP_EVENTS.has(current.event))
3591
4533
  break;
3592
4534
  batch.push(current);
3593
4535
  i++;
@@ -3596,7 +4538,7 @@ function collapsePermissions(events2) {
3596
4538
  continue;
3597
4539
  const toolCounts = new Map;
3598
4540
  for (const pev of batch) {
3599
- if (pev.event !== "permission.request")
4541
+ if (pev.event !== "permission.request" && pev.event !== "tool.used")
3600
4542
  continue;
3601
4543
  const meta = pev.meta;
3602
4544
  const tool = meta?.tool ?? "unknown";
@@ -3655,8 +4597,14 @@ function filterSkipped(events2) {
3655
4597
  function compact(events2) {
3656
4598
  return deduplicate(collapsePermissions(repairQAPairs(filterSkipped(events2))));
3657
4599
  }
4600
+ var ROLLUP_EVENTS;
3658
4601
  var init_compact = __esm(() => {
3659
4602
  init_catalog();
4603
+ ROLLUP_EVENTS = new Set([
4604
+ "permission.request",
4605
+ "permission.resolve",
4606
+ "tool.used"
4607
+ ]);
3660
4608
  });
3661
4609
 
3662
4610
  // src/cli/commands/log.ts
@@ -3673,10 +4621,10 @@ function showAllSessions() {
3673
4621
  console.log("No sessions.");
3674
4622
  return;
3675
4623
  }
3676
- const maxName = Math.max(...rows.map((r) => `${r.groupPath}/${r.session.slug}`.length), 4);
4624
+ const maxName = Math.max(...rows.map((r) => `${r.categoryPath}/${r.session.slug}`.length), 4);
3677
4625
  console.log(`${DIM}${" "} ${"NAME".padEnd(maxName)} ${"STATUS".padEnd(10)} ${"EVENTS".padEnd(6)} LAST ACTIVE${RESET}`);
3678
4626
  for (const row of rows) {
3679
- const name = `${row.groupPath}/${row.session.slug}`;
4627
+ const name = `${row.categoryPath}/${row.session.slug}`;
3680
4628
  const dot = STATUS_DOTS2[row.session.status] ?? "?";
3681
4629
  const stats = getSessionStats(row.session.id);
3682
4630
  const eventCount = String(stats?.eventCount ?? 0).padEnd(6);
@@ -3833,7 +4781,7 @@ var init_log = __esm(() => {
3833
4781
  init_router();
3834
4782
  init_sessions();
3835
4783
  init_events();
3836
- init_groups();
4784
+ init_categories();
3837
4785
  init_stats();
3838
4786
  init_conversations();
3839
4787
  init_catalog();
@@ -3854,18 +4802,18 @@ var init_log = __esm(() => {
3854
4802
  showAllSessions();
3855
4803
  return;
3856
4804
  }
3857
- const { groupPath, slug } = parseSessionName(target);
3858
- const group = getGroupByPath(groupPath);
3859
- if (!group) {
3860
- console.error(`Group not found: ${groupPath}`);
4805
+ const { categoryPath, slug } = parseSessionName(target);
4806
+ const category = getCategoryByPath(categoryPath);
4807
+ if (!category) {
4808
+ console.error(`Category not found: ${categoryPath}`);
3861
4809
  process.exit(1);
3862
4810
  }
3863
- const session = getSessionByGroupSlug(group.id, slug);
4811
+ const session = getSessionByCategorySlug(category.id, slug);
3864
4812
  if (!session) {
3865
4813
  console.error(`Session not found: ${target}`);
3866
4814
  process.exit(1);
3867
4815
  }
3868
- showSessionLog(session, `${groupPath}/${slug}`, isJson);
4816
+ showSessionLog(session, `${categoryPath}/${slug}`, isJson);
3869
4817
  });
3870
4818
  });
3871
4819
 
@@ -3938,7 +4886,7 @@ function renderGlobal(metrics, isJson) {
3938
4886
  console.log(` PRs: ${totals.prs}`);
3939
4887
  console.log(` Interactions: ${totals.interactions}`);
3940
4888
  }
3941
- function renderGroup(metrics, groupPath, isJson) {
4889
+ function renderCategory(metrics, categoryPath, isJson) {
3942
4890
  if (isJson) {
3943
4891
  console.log(JSON.stringify(metrics, null, 2));
3944
4892
  return;
@@ -3948,7 +4896,7 @@ function renderGroup(metrics, groupPath, isJson) {
3948
4896
  const reset = "\x1B[0m";
3949
4897
  const sorted = [...metrics].sort((a, b) => b.durationS - a.durationS);
3950
4898
  const maxName = Math.max(...sorted.map((m) => m.name.length), 4);
3951
- console.log(`${bold}${groupPath}${reset}
4899
+ console.log(`${bold}${categoryPath}${reset}
3952
4900
  `);
3953
4901
  console.log(`${dim}${"NAME".padEnd(maxName)} ${"DURATION".padEnd(8)} ${"CLAUDE".padEnd(8)} ${"WAIT".padEnd(8)} ${"ACT%".padEnd(5)} ${"CONVOS".padEnd(6)} PRS${reset}`);
3954
4902
  for (const m of sorted) {
@@ -3989,7 +4937,7 @@ var init_stats2 = __esm(() => {
3989
4937
  init_router();
3990
4938
  init_sessions();
3991
4939
  init_stats();
3992
- init_groups();
4940
+ init_categories();
3993
4941
  init_events();
3994
4942
  init_timing();
3995
4943
  init_format();
@@ -4000,34 +4948,34 @@ var init_stats2 = __esm(() => {
4000
4948
  const target = filteredArgs[0];
4001
4949
  if (!target) {
4002
4950
  const rows = getAllSessions();
4003
- const metrics = rows.map((r) => getMetrics(r.session.id, `${r.groupPath}/${r.session.slug}`, r.session.status));
4951
+ const metrics = rows.map((r) => getMetrics(r.session.id, `${r.categoryPath}/${r.session.slug}`, r.session.status));
4004
4952
  renderGlobal(metrics, isJson);
4005
4953
  return;
4006
4954
  }
4007
4955
  if (target.endsWith("/")) {
4008
- const groupPath2 = target.replace(/\/+$/, "");
4009
- const group2 = getGroupByPath(groupPath2);
4010
- if (!group2) {
4011
- console.error(`Group not found: ${groupPath2}`);
4956
+ const categoryPath2 = target.replace(/\/+$/, "");
4957
+ const category2 = getCategoryByPath(categoryPath2);
4958
+ if (!category2) {
4959
+ console.error(`Category not found: ${categoryPath2}`);
4012
4960
  process.exit(1);
4013
4961
  }
4014
- const groupSessions = getSessionsByGroup(group2.id);
4015
- const metrics = groupSessions.map((s) => getMetrics(s.id, s.slug, s.status));
4016
- renderGroup(metrics, groupPath2, isJson);
4962
+ const categorySessions = getSessionsByCategory(category2.id);
4963
+ const metrics = categorySessions.map((s) => getMetrics(s.id, s.slug, s.status));
4964
+ renderCategory(metrics, categoryPath2, isJson);
4017
4965
  return;
4018
4966
  }
4019
- const { groupPath, slug } = parseSessionName(target);
4020
- const group = getGroupByPath(groupPath);
4021
- if (!group) {
4022
- console.error(`Group not found: ${groupPath}`);
4967
+ const { categoryPath, slug } = parseSessionName(target);
4968
+ const category = getCategoryByPath(categoryPath);
4969
+ if (!category) {
4970
+ console.error(`Category not found: ${categoryPath}`);
4023
4971
  process.exit(1);
4024
4972
  }
4025
- const session = getSessionByGroupSlug(group.id, slug);
4973
+ const session = getSessionByCategorySlug(category.id, slug);
4026
4974
  if (!session) {
4027
4975
  console.error(`Session not found: ${target}`);
4028
4976
  process.exit(1);
4029
4977
  }
4030
- const m = getMetrics(session.id, `${groupPath}/${slug}`, session.status);
4978
+ const m = getMetrics(session.id, `${categoryPath}/${slug}`, session.status);
4031
4979
  renderSession(m, isJson);
4032
4980
  });
4033
4981
  });
@@ -4042,9 +4990,9 @@ var init_backfill_stats = __esm(() => {
4042
4990
  const includeArchived = args.includes("--include-archived");
4043
4991
  const rows = getAllSessions({ excludeArchived: !includeArchived });
4044
4992
  console.log(`Backfilling stats for ${rows.length} session(s)${includeArchived ? "" : " (excluding archived)"}...`);
4045
- for (const { session, groupPath } of rows) {
4993
+ for (const { session, categoryPath } of rows) {
4046
4994
  computeAndPersist(session.id);
4047
- console.log(` \u2713 ${groupPath}/${session.slug}`);
4995
+ console.log(` \u2713 ${categoryPath}/${session.slug}`);
4048
4996
  }
4049
4997
  console.log("Done.");
4050
4998
  });
@@ -4053,23 +5001,23 @@ var init_backfill_stats = __esm(() => {
4053
5001
  // src/cli/commands/archive.ts
4054
5002
  var exports_archive = {};
4055
5003
  function resolveSession(name) {
4056
- const { groupPath, slug } = parseSessionName(name);
4057
- const group = getGroupByPath(groupPath);
4058
- if (!group) {
4059
- console.error(`Group not found: ${groupPath}`);
5004
+ const { categoryPath, slug } = parseSessionName(name);
5005
+ const category = getCategoryByPath(categoryPath);
5006
+ if (!category) {
5007
+ console.error(`Category not found: ${categoryPath}`);
4060
5008
  process.exit(1);
4061
5009
  }
4062
- const session = getSessionByGroupSlug(group.id, slug);
5010
+ const session = getSessionByCategorySlug(category.id, slug);
4063
5011
  if (!session) {
4064
5012
  console.error(`Session not found: ${name}`);
4065
5013
  process.exit(1);
4066
5014
  }
4067
- return { session, groupPath };
5015
+ return { session, categoryPath };
4068
5016
  }
4069
5017
  var init_archive = __esm(() => {
4070
5018
  init_router();
4071
5019
  init_sessions();
4072
- init_groups();
5020
+ init_categories();
4073
5021
  init_session_archive();
4074
5022
  register("archive", async (args) => {
4075
5023
  const isUndo = args.includes("--undo");
@@ -4082,8 +5030,8 @@ var init_archive = __esm(() => {
4082
5030
  console.log("No paused sessions to archive.");
4083
5031
  return;
4084
5032
  }
4085
- for (const { session: session2, groupPath: groupPath2 } of archived) {
4086
- console.log(` archived ${groupPath2}/${session2.slug}`);
5033
+ for (const { session: session2, categoryPath: categoryPath2 } of archived) {
5034
+ console.log(` archived ${categoryPath2}/${session2.slug}`);
4087
5035
  }
4088
5036
  console.log(`
4089
5037
  Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
@@ -4095,8 +5043,8 @@ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
4095
5043
  console.error(" bertrand archive --all-paused");
4096
5044
  process.exit(1);
4097
5045
  }
4098
- const { session, groupPath } = resolveSession(sessionName);
4099
- const fullName = `${groupPath}/${session.slug}`;
5046
+ const { session, categoryPath } = resolveSession(sessionName);
5047
+ const fullName = `${categoryPath}/${session.slug}`;
4100
5048
  if (isUndo) {
4101
5049
  const result2 = unarchiveSession(session.id);
4102
5050
  if (!result2.ok) {
@@ -4129,6 +5077,302 @@ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
4129
5077
  });
4130
5078
  });
4131
5079
 
5080
+ // src/cli/commands/project.ts
5081
+ var exports_project = {};
5082
+ __export(exports_project, {
5083
+ switchSubcommand: () => switchSubcommand,
5084
+ renameSubcommand: () => renameSubcommand,
5085
+ removeSubcommand: () => removeSubcommand,
5086
+ listSubcommand: () => listSubcommand,
5087
+ importSubcommand: () => importSubcommand,
5088
+ currentSubcommand: () => currentSubcommand,
5089
+ createSubcommand: () => createSubcommand,
5090
+ _UsageError: () => _UsageError
5091
+ });
5092
+ import { existsSync as existsSync9, rmSync } from "fs";
5093
+ function countSessions(slug) {
5094
+ const dbFile = projectPaths(slug).db;
5095
+ if (!existsSync9(dbFile))
5096
+ return { total: 0, active: 0 };
5097
+ try {
5098
+ const db = getDbForProject(slug);
5099
+ const all = db.select({ status: sessions.status }).from(sessions).all();
5100
+ return {
5101
+ total: all.length,
5102
+ active: all.filter((s) => s.status === "active" || s.status === "waiting").length
5103
+ };
5104
+ } catch {
5105
+ return UNREADABLE_COUNTS;
5106
+ }
5107
+ }
5108
+ function validateSlug(slug) {
5109
+ if (!slug) {
5110
+ throw new UsageError("Slug required.");
5111
+ }
5112
+ if (!SLUG_PATTERN.test(slug)) {
5113
+ throw new UsageError(`Invalid slug "${slug}": must start with alphanumeric and contain only letters, digits, dots, underscores, or dashes.`);
5114
+ }
5115
+ }
5116
+ function flagKey(token) {
5117
+ if (!token.startsWith("--"))
5118
+ return null;
5119
+ const eq8 = token.indexOf("=");
5120
+ return eq8 === -1 ? token.slice(2) : token.slice(2, eq8);
5121
+ }
5122
+ function flagInlineValue(token) {
5123
+ if (!token.startsWith("--"))
5124
+ return null;
5125
+ const eq8 = token.indexOf("=");
5126
+ return eq8 === -1 ? null : token.slice(eq8 + 1);
5127
+ }
5128
+ function parseFlag(args, name) {
5129
+ for (let i = 0;i < args.length; i++) {
5130
+ const key = flagKey(args[i]);
5131
+ if (key !== name)
5132
+ continue;
5133
+ const inline = flagInlineValue(args[i]);
5134
+ if (inline !== null)
5135
+ return inline;
5136
+ return args[i + 1];
5137
+ }
5138
+ return;
5139
+ }
5140
+ function hasFlag(args, name) {
5141
+ return args.some((a) => flagKey(a) === name);
5142
+ }
5143
+ function positional(args) {
5144
+ const out = [];
5145
+ for (let i = 0;i < args.length; i++) {
5146
+ const a = args[i];
5147
+ if (a.startsWith("--")) {
5148
+ if (a.includes("="))
5149
+ continue;
5150
+ const next = args[i + 1];
5151
+ if (next && !next.startsWith("--"))
5152
+ i++;
5153
+ continue;
5154
+ }
5155
+ out.push(a);
5156
+ }
5157
+ return out;
5158
+ }
5159
+ function listSubcommand(args) {
5160
+ const isJson = hasFlag(args, "json");
5161
+ const projects = listProjects();
5162
+ const activeSlug = getActiveProjectSlug();
5163
+ const rows = projects.map((p) => ({
5164
+ slug: p.slug,
5165
+ name: p.name,
5166
+ active: p.slug === activeSlug,
5167
+ sessions: countSessions(p.slug),
5168
+ lastUsedAt: p.lastUsedAt
5169
+ }));
5170
+ if (isJson) {
5171
+ console.log(JSON.stringify(rows, null, 2));
5172
+ return;
5173
+ }
5174
+ if (rows.length === 0) {
5175
+ console.log("No projects registered yet.");
5176
+ return;
5177
+ }
5178
+ const dim = "\x1B[2m";
5179
+ const reset = "\x1B[0m";
5180
+ const bold = "\x1B[1m";
5181
+ const maxSlug = Math.max(...rows.map((r) => r.slug.length), 4);
5182
+ const maxName = Math.max(...rows.map((r) => r.name.length), 4);
5183
+ console.log(`${dim} ${"SLUG".padEnd(maxSlug)} ${"NAME".padEnd(maxName)} ${"SESSIONS".padEnd(10)} LAST USED${reset}`);
5184
+ for (const r of rows) {
5185
+ const marker = r.active ? `${bold}*${reset}` : " ";
5186
+ const sessionStr = r.sessions.unreadable ? "?".padEnd(10) : `${r.sessions.total} (${r.sessions.active} active)`.padEnd(10);
5187
+ const ago = formatAgo(r.lastUsedAt);
5188
+ console.log(`${marker} ${r.slug.padEnd(maxSlug)} ${r.name.padEnd(maxName)} ${sessionStr} ${ago}`);
5189
+ }
5190
+ }
5191
+ function createSubcommand(args) {
5192
+ const [slug] = positional(args);
5193
+ validateSlug(slug ?? "");
5194
+ const customName = parseFlag(args, "name");
5195
+ const activate = hasFlag(args, "activate");
5196
+ if (listProjects().some((p) => p.slug === slug)) {
5197
+ throw new UsageError(`Project "${slug}" already exists.`);
5198
+ }
5199
+ createProject({ slug, name: customName });
5200
+ if (activate) {
5201
+ setActiveProjectSlug(slug);
5202
+ _resetActiveProjectCache();
5203
+ }
5204
+ console.log(`Created project "${slug}"${activate ? " (now active)" : ""}.`);
5205
+ }
5206
+ function switchSubcommand(args) {
5207
+ const [slug] = positional(args);
5208
+ if (!slug) {
5209
+ throw new UsageError("Usage: bertrand project switch <slug>");
5210
+ }
5211
+ const projects = listProjects();
5212
+ if (!projects.some((p) => p.slug === slug)) {
5213
+ throw new UsageError(`Unknown project slug "${slug}".`);
5214
+ }
5215
+ const currentSlug = getActiveProjectSlug();
5216
+ if (currentSlug !== slug) {
5217
+ const counts = countSessions(currentSlug);
5218
+ if (counts.active > 0) {
5219
+ throw new UsageError(`Cannot switch: project "${currentSlug}" has ${counts.active} active/waiting session(s). Pause them first.`);
5220
+ }
5221
+ }
5222
+ setActiveProjectSlug(slug);
5223
+ _resetActiveProjectCache();
5224
+ console.log(`Switched active project to "${slug}".`);
5225
+ }
5226
+ function currentSubcommand(args) {
5227
+ const isJson = hasFlag(args, "json");
5228
+ const active = resolveActiveProject();
5229
+ if (isJson) {
5230
+ console.log(JSON.stringify(active, null, 2));
5231
+ return;
5232
+ }
5233
+ console.log(`Active project: ${active.slug} (${active.name})`);
5234
+ console.log(` Root: ${active.root}`);
5235
+ console.log(` DB: ${active.db}`);
5236
+ console.log(` SyncEnv: ${active.syncEnv}`);
5237
+ }
5238
+ function renameSubcommand(args) {
5239
+ const [slug, ...rest] = positional(args);
5240
+ const newName = rest.join(" ");
5241
+ if (!slug || !newName) {
5242
+ throw new UsageError("Usage: bertrand project rename <slug> <new-name>");
5243
+ }
5244
+ renameProject(slug, newName);
5245
+ console.log(`Renamed "${slug}" to "${newName}".`);
5246
+ }
5247
+ function removeSubcommand(args) {
5248
+ const [slug] = positional(args);
5249
+ if (!slug) {
5250
+ throw new UsageError("Usage: bertrand project remove <slug> [--force] [--purge]");
5251
+ }
5252
+ validateSlug(slug);
5253
+ const force = hasFlag(args, "force");
5254
+ const purge = hasFlag(args, "purge");
5255
+ const projects = listProjects();
5256
+ const entry = projects.find((p) => p.slug === slug);
5257
+ if (!entry) {
5258
+ throw new UsageError(`Unknown project slug "${slug}".`);
5259
+ }
5260
+ if (slug === getActiveProjectSlug()) {
5261
+ throw new UsageError(`Cannot remove the active project "${slug}". Switch to another project first.`);
5262
+ }
5263
+ if (!force) {
5264
+ const counts = countSessions(slug);
5265
+ if (counts.total > 0) {
5266
+ throw new UsageError(`Project "${slug}" has ${counts.total} session(s). Pass --force to remove anyway.`);
5267
+ }
5268
+ }
5269
+ removeProject(slug);
5270
+ invalidateDbCache(slug);
5271
+ if (purge) {
5272
+ rmSync(projectPaths(slug).root, { recursive: true, force: true });
5273
+ }
5274
+ console.log(`Removed project "${slug}"${purge ? " (directory purged)" : " (directory left on disk; pass --purge to delete)"}.`);
5275
+ }
5276
+ async function importSubcommand(args) {
5277
+ const [bundle] = positional(args);
5278
+ if (!bundle) {
5279
+ throw new UsageError("Usage: bertrand project import <bundle>");
5280
+ }
5281
+ if (!isInvite(bundle)) {
5282
+ throw new UsageError(`Argument doesn't look like an invite bundle (expected to start with bertrand-sync://). ` + `Generate one on the source machine via \`bertrand sync invite\`.`);
5283
+ }
5284
+ console.log("Importing project from invite\u2026");
5285
+ const result = await bootstrapFromInvite(bundle);
5286
+ if (!result.ok) {
5287
+ throw new UsageError(result.error);
5288
+ }
5289
+ console.log(`\u2713 Created project "${result.project.slug}" (${result.project.name}) and activated it.`);
5290
+ if (result.pulled) {
5291
+ console.log(` Pulled ${result.bytes} bytes in ${result.durationMs}ms.`);
5292
+ } else {
5293
+ console.log(` No remote object yet \u2014 run \`bertrand sync push\` on the source machine first.`);
5294
+ }
5295
+ }
5296
+ function printProjectUsage() {
5297
+ console.log(`
5298
+ bertrand project \u2014 manage projects
5299
+
5300
+ Usage:
5301
+ bertrand project list [--json] List all projects
5302
+ bertrand project create <slug> [--name "..."] [--activate]
5303
+ Create a new project
5304
+ bertrand project switch <slug> Set the active project
5305
+ bertrand project current [--json] Show the active project
5306
+ bertrand project rename <slug> <new-name> Rename a project (display name only)
5307
+ bertrand project remove <slug> [--force] [--purge]
5308
+ Remove a project entry
5309
+ bertrand project import <bundle> Import a project from a \`bertrand sync invite\` bundle
5310
+ `.trim());
5311
+ }
5312
+ var SLUG_PATTERN, UsageError, UNREADABLE_COUNTS, KNOWN_SUBS, _UsageError;
5313
+ var init_project = __esm(() => {
5314
+ init_router();
5315
+ init_client();
5316
+ init_schema();
5317
+ init_registry();
5318
+ init_paths2();
5319
+ init_create();
5320
+ init_resolve();
5321
+ init_bootstrap();
5322
+ init_format();
5323
+ SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
5324
+ UsageError = class UsageError extends Error {
5325
+ };
5326
+ UNREADABLE_COUNTS = { total: 0, active: 0, unreadable: true };
5327
+ KNOWN_SUBS = new Set([
5328
+ "list",
5329
+ "create",
5330
+ "switch",
5331
+ "current",
5332
+ "rename",
5333
+ "remove",
5334
+ "import"
5335
+ ]);
5336
+ register("project", async (args) => {
5337
+ const sub = args[0];
5338
+ try {
5339
+ switch (sub) {
5340
+ case "list":
5341
+ return listSubcommand(args.slice(1));
5342
+ case "create":
5343
+ return createSubcommand(args.slice(1));
5344
+ case "switch":
5345
+ return switchSubcommand(args.slice(1));
5346
+ case "current":
5347
+ return currentSubcommand(args.slice(1));
5348
+ case "rename":
5349
+ return renameSubcommand(args.slice(1));
5350
+ case "remove":
5351
+ return removeSubcommand(args.slice(1));
5352
+ case "import":
5353
+ return await importSubcommand(args.slice(1));
5354
+ case undefined:
5355
+ case "--help":
5356
+ case "-h":
5357
+ printProjectUsage();
5358
+ return;
5359
+ default:
5360
+ throw new UsageError(`Unknown subcommand: ${sub}`);
5361
+ }
5362
+ } catch (err) {
5363
+ if (err instanceof UsageError) {
5364
+ console.error(err.message);
5365
+ if (sub && !KNOWN_SUBS.has(sub)) {
5366
+ printProjectUsage();
5367
+ }
5368
+ process.exit(1);
5369
+ }
5370
+ throw err;
5371
+ }
5372
+ });
5373
+ _UsageError = UsageError;
5374
+ });
5375
+
4132
5376
  // src/index.ts
4133
5377
  init_router();
4134
5378
  var command = process.argv[2];
@@ -4160,7 +5404,8 @@ if (command && command in hotPath) {
4160
5404
  Promise.resolve().then(() => (init_serve(), exports_serve)),
4161
5405
  Promise.resolve().then(() => (init_badge(), exports_badge)),
4162
5406
  Promise.resolve().then(() => (init_notify(), exports_notify)),
4163
- Promise.resolve().then(() => (init_sync(), exports_sync))
5407
+ Promise.resolve().then(() => (init_sync(), exports_sync)),
5408
+ Promise.resolve().then(() => (init_project(), exports_project))
4164
5409
  ]);
4165
5410
  }
4166
5411
  await route(process.argv);