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 +1735 -490
- package/dist/dashboard/assets/{index-e53khqNa.js → index-DgvcDkSY.js} +103 -103
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/sw.js +1 -1
- package/dist/migrations/0003_categories_rename.sql +8 -0
- package/dist/migrations/meta/0003_snapshot.json +777 -0
- package/dist/migrations/meta/_journal.json +7 -0
- package/dist/run-screen.js +663 -171
- package/package.json +1 -1
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/
|
|
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
|
|
62
|
-
return
|
|
366
|
+
function defaultPath() {
|
|
367
|
+
return resolveActiveProject().syncEnv;
|
|
63
368
|
}
|
|
64
|
-
function
|
|
65
|
-
|
|
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 =
|
|
376
|
+
const mode = statSync2(path).mode & 511;
|
|
68
377
|
if (mode & 63) {
|
|
69
|
-
console.warn(`warning: ${
|
|
378
|
+
console.warn(`warning: ${path} was mode 0${mode.toString(8)} (should be 0600). Tightening to 0600.`);
|
|
70
379
|
try {
|
|
71
|
-
chmodSync(
|
|
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(
|
|
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
|
-
|
|
418
|
+
writeFileSync2(path, lines.join(`
|
|
109
419
|
`), { mode: 384 });
|
|
110
|
-
chmodSync(
|
|
420
|
+
chmodSync(path, 384);
|
|
111
421
|
}
|
|
112
422
|
var KEYS;
|
|
113
423
|
var init_config = __esm(() => {
|
|
114
|
-
|
|
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
|
|
127
|
-
import { join as
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
622
|
+
var categories, labels, sessions, sessionLabels, conversations, events, worktreeAssociations, sessionStats;
|
|
283
623
|
var init_schema = __esm(() => {
|
|
284
|
-
|
|
624
|
+
categories = sqliteTable("categories", {
|
|
285
625
|
id: text("id").primaryKey(),
|
|
286
|
-
parentId: text("parent_id").references(() =>
|
|
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("
|
|
297
|
-
index("
|
|
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
|
-
|
|
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("
|
|
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 {
|
|
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 (
|
|
390
|
-
return
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
425
|
-
return getDb().select().from(sessions).where(and(eq(sessions.
|
|
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
|
|
428
|
-
return getDb().select().from(sessions).where(eq(sessions.
|
|
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,
|
|
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,
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
554
|
-
return
|
|
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
|
|
557
|
-
return
|
|
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
|
|
560
|
-
return
|
|
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
|
|
563
|
-
return
|
|
1004
|
+
function emitSessionEnded(args) {
|
|
1005
|
+
return insertEvent({
|
|
1006
|
+
sessionId: args.sessionId,
|
|
1007
|
+
event: "session.end"
|
|
1008
|
+
});
|
|
564
1009
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
|
634
|
-
const joinedAnswers = answersObj ? Object.values(answersObj).join(", ") || undefined : undefined;
|
|
635
|
-
insertEvent({
|
|
1340
|
+
const dispatched = dispatchHookEvent(event, {
|
|
636
1341
|
sessionId,
|
|
637
1342
|
conversationId,
|
|
638
|
-
|
|
639
|
-
summary: summaryArg
|
|
640
|
-
meta
|
|
1343
|
+
meta,
|
|
1344
|
+
summary: summaryArg
|
|
641
1345
|
});
|
|
642
|
-
if (
|
|
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
|
|
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 (!
|
|
1371
|
+
if (!existsSync5(filePath))
|
|
665
1372
|
return null;
|
|
666
|
-
const text2 =
|
|
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 (!
|
|
1431
|
+
if (!existsSync5(filePath))
|
|
725
1432
|
return null;
|
|
726
|
-
const text2 =
|
|
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
|
-
|
|
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
|
-
|
|
1520
|
+
emitContextSnapshot({
|
|
814
1521
|
sessionId,
|
|
815
1522
|
conversationId: convoId,
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1581
|
+
const fullText = turn.text;
|
|
1582
|
+
const textSansRecap = fullText.replace(RECAP_TAG_GLOBAL, "").trim();
|
|
878
1583
|
const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
|
|
879
|
-
|
|
880
|
-
sessionId,
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
|
1617
|
+
var RECAP_RE2;
|
|
898
1618
|
var init_recap_thinking = __esm(() => {
|
|
899
1619
|
init_router();
|
|
900
1620
|
init_sessions();
|
|
901
1621
|
init_conversations();
|
|
902
|
-
|
|
1622
|
+
init_emit();
|
|
903
1623
|
init_transcript();
|
|
904
|
-
|
|
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(
|
|
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
|
-
|
|
1657
|
+
emitAssistantRecap({
|
|
938
1658
|
sessionId,
|
|
939
1659
|
conversationId: convoId,
|
|
940
|
-
|
|
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
|
|
994
|
-
import { join as
|
|
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(
|
|
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,
|
|
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
|
|
1365
|
-
import { join as
|
|
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
|
-
|
|
1419
|
-
|
|
2162
|
+
join7(import.meta.dir, "dashboard"),
|
|
2163
|
+
join7(import.meta.dir, "..", "dashboard")
|
|
1420
2164
|
];
|
|
1421
2165
|
for (const dir of candidates) {
|
|
1422
|
-
if (
|
|
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 =
|
|
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(
|
|
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(),
|
|
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
|
|
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
|
|
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 '${
|
|
2334
|
+
src.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
|
1566
2335
|
} finally {
|
|
1567
2336
|
src.close();
|
|
1568
2337
|
}
|
|
1569
|
-
return
|
|
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 =
|
|
1574
|
-
if (
|
|
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
|
|
2351
|
+
var SIDECAR_SUFFIXES;
|
|
1582
2352
|
var init_snapshot2 = __esm(() => {
|
|
1583
|
-
|
|
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
|
|
2367
|
+
return randomBytes2(32).toString("base64");
|
|
1599
2368
|
}
|
|
1600
2369
|
function encrypt(plaintext, base64Key) {
|
|
1601
2370
|
const key = parseKey(base64Key);
|
|
1602
|
-
const iv =
|
|
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
|
|
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 =
|
|
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
|
|
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: `${
|
|
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 ${
|
|
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 = `${
|
|
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
|
-
|
|
1729
|
-
const dirFd = openSync(dirname2(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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
|
|
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
|
|
2768
|
+
bertrand sync \u2014 replicate a project's local DB to Supabase Storage (opt-in)
|
|
1919
2769
|
|
|
1920
2770
|
Usage:
|
|
1921
|
-
bertrand sync onboard
|
|
1922
|
-
bertrand sync invite
|
|
1923
|
-
bertrand sync <bundle>
|
|
1924
|
-
bertrand sync push
|
|
1925
|
-
bertrand sync pull [--force]
|
|
1926
|
-
|
|
1927
|
-
bertrand sync
|
|
1928
|
-
bertrand sync
|
|
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 ${
|
|
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 ${
|
|
2859
|
+
Delete ${active.syncEnv} and re-run to start over.`);
|
|
1976
2860
|
return;
|
|
1977
2861
|
}
|
|
1978
|
-
console.log(`Bertrand sync setup
|
|
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
|
|
2004
|
-
const
|
|
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
|
|
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 ${
|
|
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
|
|
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(
|
|
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 (${
|
|
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
|
|
2106
|
-
|
|
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
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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.
|
|
2139
|
-
console.
|
|
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
|
-
|
|
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(
|
|
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/
|
|
3087
|
+
// src/db/queries/categories.ts
|
|
2205
3088
|
import { eq as eq6, like, or, isNull } from "drizzle-orm";
|
|
2206
|
-
function
|
|
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(
|
|
3095
|
+
const parent = db.select().from(categories).where(eq6(categories.id, opts.parentId)).get();
|
|
2213
3096
|
if (!parent)
|
|
2214
|
-
throw new Error(`Parent
|
|
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(
|
|
3101
|
+
return db.insert(categories).values({ id, slug: opts.slug, name: opts.name, parentId: opts.parentId, path, depth }).returning().get();
|
|
2219
3102
|
}
|
|
2220
|
-
function
|
|
2221
|
-
return getDb().select().from(
|
|
3103
|
+
function getCategory(id) {
|
|
3104
|
+
return getDb().select().from(categories).where(eq6(categories.id, id)).get();
|
|
2222
3105
|
}
|
|
2223
|
-
function
|
|
2224
|
-
return getDb().select().from(
|
|
3106
|
+
function getCategoryByPath(path) {
|
|
3107
|
+
return getDb().select().from(categories).where(eq6(categories.path, path)).get();
|
|
2225
3108
|
}
|
|
2226
|
-
function
|
|
2227
|
-
const existing =
|
|
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
|
|
2235
|
-
if (
|
|
2236
|
-
parentId =
|
|
3117
|
+
const category = getCategoryByPath(partialPath);
|
|
3118
|
+
if (category) {
|
|
3119
|
+
parentId = category.id;
|
|
2237
3120
|
} else {
|
|
2238
3121
|
const slug = segments[i];
|
|
2239
|
-
const created =
|
|
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
|
|
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(
|
|
2303
|
-
const siblings =
|
|
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 `- ${
|
|
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 <
|
|
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
|
|
2448
|
-
import { join as
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
2535
|
-
if (
|
|
2536
|
-
const existing =
|
|
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
|
|
3464
|
+
throw new Error(`Session "${opts.slug}" already exists in category "${opts.categoryPath}"`);
|
|
2539
3465
|
}
|
|
2540
3466
|
}
|
|
2541
|
-
const
|
|
3467
|
+
const categoryId = getOrCreateCategoryPath(opts.categoryPath);
|
|
2542
3468
|
const session = createSession({
|
|
2543
|
-
|
|
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.
|
|
2560
|
-
|
|
3487
|
+
const sessionName = `${opts.categoryPath}/${opts.slug}`;
|
|
3488
|
+
emitSessionStarted({
|
|
2561
3489
|
sessionId: session.id,
|
|
2562
3490
|
conversationId: claudeId,
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
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
|
-
|
|
3497
|
+
emitClaudeStarted({
|
|
2573
3498
|
sessionId: session.id,
|
|
2574
3499
|
conversationId: claudeId,
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
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(
|
|
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
|
|
2601
|
-
const sessionName =
|
|
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
|
-
|
|
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
|
|
2611
|
-
const siblingContext = buildSiblingContext(session.
|
|
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
|
-
|
|
3553
|
+
emitClaudeEnded({
|
|
2633
3554
|
sessionId,
|
|
2634
3555
|
conversationId: safeConversationId,
|
|
2635
|
-
|
|
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
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
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
|
-
|
|
2654
|
-
|
|
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
|
|
2667
|
-
import { join as
|
|
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(
|
|
3600
|
+
const result = JSON.parse(readFileSync8(tmpFile, "utf-8"));
|
|
2681
3601
|
unlinkSync3(tmpFile);
|
|
2682
3602
|
return result;
|
|
2683
3603
|
}
|
|
2684
|
-
async function
|
|
2685
|
-
return runScreen("
|
|
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
|
|
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
|
-
|
|
3668
|
+
return;
|
|
2740
3669
|
case "create": {
|
|
2741
|
-
const sessionId = await launch(
|
|
3670
|
+
const sessionId = await launch({
|
|
3671
|
+
categoryPath: selection.categoryPath,
|
|
3672
|
+
slug: selection.slug
|
|
3673
|
+
});
|
|
2742
3674
|
await runSessionLoop(sessionId);
|
|
2743
|
-
|
|
3675
|
+
return;
|
|
2744
3676
|
}
|
|
2745
3677
|
case "pick": {
|
|
2746
3678
|
const conversationId = await resolveConversationForResume(selection.sessionId);
|
|
2747
3679
|
if (!conversationId)
|
|
2748
|
-
|
|
3680
|
+
return;
|
|
2749
3681
|
const sessionId = await resume({
|
|
2750
3682
|
sessionId: selection.sessionId,
|
|
2751
3683
|
conversationId
|
|
2752
3684
|
});
|
|
2753
3685
|
await runSessionLoop(sessionId);
|
|
2754
|
-
|
|
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 =
|
|
2766
|
-
return
|
|
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
|
|
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
|
|
2787
|
-
return {
|
|
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
|
-
|
|
3742
|
+
emitSessionPausedByRecovery({
|
|
2809
3743
|
sessionId: session.id,
|
|
2810
|
-
|
|
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
|
-
|
|
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 {
|
|
2836
|
-
const sessionId = await launch({
|
|
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
|
-
|
|
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
|
-
|
|
2900
|
-
|
|
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
|
-
|
|
2905
|
-
|
|
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
|
-
|
|
3856
|
+
bq update --session-id "$sid" --event session.answered --meta "$meta"
|
|
2940
3857
|
|
|
2941
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3015
|
-
|
|
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
|
-
#
|
|
3025
|
-
# 1. Edit/Write/MultiEdit
|
|
3026
|
-
#
|
|
3027
|
-
#
|
|
3028
|
-
# 2.
|
|
3029
|
-
#
|
|
3030
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
3077
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4045
|
+
bq update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
|
|
3114
4046
|
|
|
3115
|
-
# Final
|
|
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
|
-
|
|
3119
|
-
|
|
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
|
-
|
|
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
|
|
3140
|
-
import { join as
|
|
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
|
-
|
|
4077
|
+
mkdirSync7(paths.hooks, { recursive: true });
|
|
3143
4078
|
for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
|
|
3144
|
-
const filePath =
|
|
3145
|
-
|
|
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
|
|
3157
|
-
import { join as
|
|
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(
|
|
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
|
-
|
|
3175
|
-
|
|
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 =
|
|
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
|
|
3227
|
-
import { join as
|
|
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 =
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
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
|
|
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
|
|
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 =
|
|
3306
|
-
const launcher =
|
|
3307
|
-
|
|
3308
|
-
|
|
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
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
3420
|
-
const
|
|
4358
|
+
const categoryFlag = args.indexOf("--category");
|
|
4359
|
+
const categoryPath = categoryFlag !== -1 ? args[categoryFlag + 1] : undefined;
|
|
3421
4360
|
let sessionRows;
|
|
3422
|
-
if (
|
|
3423
|
-
const
|
|
3424
|
-
if (!
|
|
3425
|
-
console.error(`
|
|
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
|
|
3429
|
-
sessionRows =
|
|
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 (
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
3858
|
-
const
|
|
3859
|
-
if (!
|
|
3860
|
-
console.error(`
|
|
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 =
|
|
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, `${
|
|
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
|
|
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}${
|
|
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
|
-
|
|
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.
|
|
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
|
|
4009
|
-
const
|
|
4010
|
-
if (!
|
|
4011
|
-
console.error(`
|
|
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
|
|
4015
|
-
const metrics =
|
|
4016
|
-
|
|
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 {
|
|
4020
|
-
const
|
|
4021
|
-
if (!
|
|
4022
|
-
console.error(`
|
|
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 =
|
|
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, `${
|
|
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,
|
|
4993
|
+
for (const { session, categoryPath } of rows) {
|
|
4046
4994
|
computeAndPersist(session.id);
|
|
4047
|
-
console.log(` \u2713 ${
|
|
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 {
|
|
4057
|
-
const
|
|
4058
|
-
if (!
|
|
4059
|
-
console.error(`
|
|
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 =
|
|
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,
|
|
5015
|
+
return { session, categoryPath };
|
|
4068
5016
|
}
|
|
4069
5017
|
var init_archive = __esm(() => {
|
|
4070
5018
|
init_router();
|
|
4071
5019
|
init_sessions();
|
|
4072
|
-
|
|
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,
|
|
4086
|
-
console.log(` archived ${
|
|
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,
|
|
4099
|
-
const fullName = `${
|
|
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);
|