bertrand 0.23.0 → 0.24.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/README.md +1 -1
- package/dist/bertrand.js +170 -201
- package/dist/dashboard/assets/index-BXNZ0sJ0.css +1 -0
- package/dist/dashboard/assets/index-DM4zHgCh.js +694 -0
- package/dist/dashboard/index.html +2 -2
- package/package.json +1 -1
- package/dist/dashboard/assets/index-DW_dgQQt.js +0 -659
- package/dist/dashboard/assets/index-D_l3EPhC.css +0 -1
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ Key tables ([`src/db/schema.ts`](src/db/schema.ts)):
|
|
|
138
138
|
- **`groups`** — nestable session containers.
|
|
139
139
|
- **`sessions`** — named workspaces, status-tracked.
|
|
140
140
|
- **`conversations`** — Claude conversations within a session (claude_id UUIDs).
|
|
141
|
-
- **`events`** — every hook firing and lifecycle moment (`session.waiting`, `session.answered`, `tool.applied`, `context.snapshot`,
|
|
141
|
+
- **`events`** — every hook firing and lifecycle moment (`session.waiting`, `session.answered`, `tool.applied`, `context.snapshot`, etc.). Free-form `meta` JSON column.
|
|
142
142
|
- **`session_stats`** — materialized stats, refreshed at session end.
|
|
143
143
|
- **`worktree_associations`** — tracked worktree branches per session.
|
|
144
144
|
|
package/dist/bertrand.js
CHANGED
|
@@ -504,6 +504,48 @@ var init_trigger = __esm(() => {
|
|
|
504
504
|
init_config2();
|
|
505
505
|
});
|
|
506
506
|
|
|
507
|
+
// src/cli/help.ts
|
|
508
|
+
function helpText(opts = {}) {
|
|
509
|
+
const header = opts.agent ? AGENT_HEADER : HUMAN_HEADER;
|
|
510
|
+
return `${header}
|
|
511
|
+
|
|
512
|
+
${COMMAND_REFERENCE}`;
|
|
513
|
+
}
|
|
514
|
+
var COMMAND_REFERENCE = `Usage:
|
|
515
|
+
bertrand Launch the interactive TUI; start or resume a session.
|
|
516
|
+
bertrand init First-time setup: install hooks, settings, completions.
|
|
517
|
+
|
|
518
|
+
Inspect sessions (read-only):
|
|
519
|
+
bertrand list [--json] List sessions in the active project with status + activity.
|
|
520
|
+
bertrand log <session> [--json]
|
|
521
|
+
Full record for one session: metadata, stats,
|
|
522
|
+
conversations, and the complete event timeline.
|
|
523
|
+
<session> is "<category>/<slug>" (see \`list\`). Use this
|
|
524
|
+
to see what was decided or tried in another session.
|
|
525
|
+
bertrand stats <session> [--json]
|
|
526
|
+
Aggregate statistics (durations, interactions, diff metrics).
|
|
527
|
+
|
|
528
|
+
Manage sessions & projects:
|
|
529
|
+
bertrand archive <session> Archive or unarchive a session.
|
|
530
|
+
bertrand project <op> list | create | switch | current | rename | remove | import
|
|
531
|
+
(bertrand project --help)
|
|
532
|
+
bertrand sync <op> onboard | push | pull | status | invite | enable | disable
|
|
533
|
+
(bertrand sync --help)
|
|
534
|
+
bertrand serve Start the local dashboard HTTP server.
|
|
535
|
+
|
|
536
|
+
Add --json to list/log/stats for machine-readable output. Most commands accept
|
|
537
|
+
--project <slug> to target a project other than the active one.`, HUMAN_HEADER = `bertrand \u2014 multi-session workflow manager for Claude Code
|
|
538
|
+
|
|
539
|
+
bertrand wraps each Claude Code conversation in a tracked "session": it records the
|
|
540
|
+
full event timeline (prompts, answers, tool use, PRs, deploys), groups sessions by
|
|
541
|
+
project, and can replicate that history across machines.`, AGENT_HEADER = `## bertrand CLI
|
|
542
|
+
|
|
543
|
+
You are running inside a bertrand session. bertrand wraps each Claude Code
|
|
544
|
+
conversation in a tracked "session" and records the full event timeline (prompts,
|
|
545
|
+
answers, tool use, PRs, deploys), grouped by project and replicable across machines.
|
|
546
|
+
The subcommands below inspect and manage that data \u2014 reach for them (e.g.
|
|
547
|
+
\`bertrand log <session>\`) instead of assuming sessions are isolated.`;
|
|
548
|
+
|
|
507
549
|
// src/cli/router.ts
|
|
508
550
|
import { existsSync as existsSync4 } from "fs";
|
|
509
551
|
function register(name, handler) {
|
|
@@ -544,6 +586,10 @@ async function autoInitIfFirstRun() {
|
|
|
544
586
|
async function route(argv) {
|
|
545
587
|
const args = argv.slice(2);
|
|
546
588
|
const command = args[0];
|
|
589
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
590
|
+
console.log(helpText({ agent: args.includes("--agent") }));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
547
593
|
if (!command || !HOOK_COMMANDS.has(command)) {
|
|
548
594
|
migrateOrAbort();
|
|
549
595
|
}
|
|
@@ -573,21 +619,7 @@ function resolveCommand(name) {
|
|
|
573
619
|
return;
|
|
574
620
|
}
|
|
575
621
|
function printUsage() {
|
|
576
|
-
console.log(
|
|
577
|
-
bertrand \u2014 multi-session workflow manager for Claude Code
|
|
578
|
-
|
|
579
|
-
Usage:
|
|
580
|
-
bertrand Launch TUI (or resume named session)
|
|
581
|
-
bertrand init Setup wizard
|
|
582
|
-
bertrand list Session picker
|
|
583
|
-
bertrand log <session> View session log
|
|
584
|
-
bertrand stats <session> Session statistics
|
|
585
|
-
bertrand archive <name> Archive/unarchive a session
|
|
586
|
-
bertrand project <op> list|create|switch|current|rename|remove (see: bertrand project --help)
|
|
587
|
-
bertrand update Hook-facing state writer (internal)
|
|
588
|
-
bertrand serve Start dashboard HTTP server
|
|
589
|
-
bertrand sync <op> push|pull|status|onboard (see: bertrand sync --help)
|
|
590
|
-
`.trim());
|
|
622
|
+
console.log(helpText());
|
|
591
623
|
}
|
|
592
624
|
var commands, aliases, HOOK_COMMANDS;
|
|
593
625
|
var init_router = __esm(() => {
|
|
@@ -602,7 +634,8 @@ var init_router = __esm(() => {
|
|
|
602
634
|
"assistant-message",
|
|
603
635
|
"contract",
|
|
604
636
|
"notify",
|
|
605
|
-
"badge"
|
|
637
|
+
"badge",
|
|
638
|
+
"ensure-server"
|
|
606
639
|
]);
|
|
607
640
|
});
|
|
608
641
|
|
|
@@ -955,8 +988,6 @@ function normalizeEventMeta(eventName, meta) {
|
|
|
955
988
|
if (!meta)
|
|
956
989
|
return meta;
|
|
957
990
|
switch (eventName) {
|
|
958
|
-
case "session.recap":
|
|
959
|
-
return mapStringField(meta, "recap", normalizeMarkdown);
|
|
960
991
|
case "assistant.message":
|
|
961
992
|
return mapStringField(meta, "text", normalizeMarkdown);
|
|
962
993
|
case "session.answered":
|
|
@@ -1021,24 +1052,6 @@ function getLatestEventOfType(sessionId, eventType, conversationId) {
|
|
|
1021
1052
|
}
|
|
1022
1053
|
return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
|
|
1023
1054
|
}
|
|
1024
|
-
function getLatestRecaps() {
|
|
1025
|
-
const rows = getDb().select({
|
|
1026
|
-
sessionId: events.sessionId,
|
|
1027
|
-
meta: events.meta,
|
|
1028
|
-
createdAt: events.createdAt
|
|
1029
|
-
}).from(events).where(eq4(events.event, "session.recap")).orderBy(desc2(events.createdAt)).all();
|
|
1030
|
-
const result = {};
|
|
1031
|
-
for (const row of rows) {
|
|
1032
|
-
if (result[row.sessionId])
|
|
1033
|
-
continue;
|
|
1034
|
-
const meta = row.meta;
|
|
1035
|
-
const recap = typeof meta?.recap === "string" ? meta.recap : null;
|
|
1036
|
-
if (!recap)
|
|
1037
|
-
continue;
|
|
1038
|
-
result[row.sessionId] = { recap, createdAt: row.createdAt };
|
|
1039
|
-
}
|
|
1040
|
-
return result;
|
|
1041
|
-
}
|
|
1042
1055
|
var init_events = __esm(() => {
|
|
1043
1056
|
init_client();
|
|
1044
1057
|
init_schema();
|
|
@@ -1096,15 +1109,6 @@ function emitSessionAnswered(args) {
|
|
|
1096
1109
|
}
|
|
1097
1110
|
});
|
|
1098
1111
|
}
|
|
1099
|
-
function emitSessionRecap(args) {
|
|
1100
|
-
return insertEvent({
|
|
1101
|
-
sessionId: args.sessionId,
|
|
1102
|
-
conversationId: args.conversationId,
|
|
1103
|
-
event: "session.recap",
|
|
1104
|
-
summary: args.recap.slice(0, 200),
|
|
1105
|
-
meta: { recap: args.recap, claude_id: args.conversationId }
|
|
1106
|
-
});
|
|
1107
|
-
}
|
|
1108
1112
|
function emitToolUsed(args) {
|
|
1109
1113
|
const summary = formatToolSummary(args.tool, args.detail);
|
|
1110
1114
|
return insertEvent({
|
|
@@ -1169,15 +1173,6 @@ function emitAssistantMessage(args) {
|
|
|
1169
1173
|
}
|
|
1170
1174
|
});
|
|
1171
1175
|
}
|
|
1172
|
-
function emitAssistantRecap(args) {
|
|
1173
|
-
return insertEvent({
|
|
1174
|
-
sessionId: args.sessionId,
|
|
1175
|
-
conversationId: args.conversationId,
|
|
1176
|
-
event: "assistant.recap",
|
|
1177
|
-
summary: args.recap.length > 80 ? `${args.recap.slice(0, 77)}...` : args.recap,
|
|
1178
|
-
meta: { recap: args.recap, claude_id: args.conversationId }
|
|
1179
|
-
});
|
|
1180
|
-
}
|
|
1181
1176
|
function emitWorktreeEntered(args) {
|
|
1182
1177
|
return insertEvent({
|
|
1183
1178
|
sessionId: args.sessionId,
|
|
@@ -1239,13 +1234,6 @@ function dispatchHookEvent(event, ctx) {
|
|
|
1239
1234
|
questions: meta.questions ?? []
|
|
1240
1235
|
});
|
|
1241
1236
|
return true;
|
|
1242
|
-
case "session.recap":
|
|
1243
|
-
emitSessionRecap({
|
|
1244
|
-
sessionId,
|
|
1245
|
-
conversationId,
|
|
1246
|
-
recap: String(meta.recap ?? "")
|
|
1247
|
-
});
|
|
1248
|
-
return true;
|
|
1249
1237
|
case "tool.applied":
|
|
1250
1238
|
emitToolApplied({
|
|
1251
1239
|
sessionId,
|
|
@@ -1434,7 +1422,6 @@ function summarize(text2) {
|
|
|
1434
1422
|
const trimmed = firstLine.trim();
|
|
1435
1423
|
return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
|
|
1436
1424
|
}
|
|
1437
|
-
var RECAP_RE, RECAP_TAG_GLOBAL;
|
|
1438
1425
|
var init_assistant_message = __esm(() => {
|
|
1439
1426
|
init_router();
|
|
1440
1427
|
init_sessions();
|
|
@@ -1442,8 +1429,6 @@ var init_assistant_message = __esm(() => {
|
|
|
1442
1429
|
init_events();
|
|
1443
1430
|
init_emit();
|
|
1444
1431
|
init_transcript();
|
|
1445
|
-
RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
|
|
1446
|
-
RECAP_TAG_GLOBAL = /<recap>[\s\S]*?<\/recap>/gi;
|
|
1447
1432
|
register("assistant-message", async (args) => {
|
|
1448
1433
|
let sessionId = "";
|
|
1449
1434
|
let transcriptPath = "";
|
|
@@ -1474,34 +1459,20 @@ var init_assistant_message = __esm(() => {
|
|
|
1474
1459
|
const turn = getLatestAssistantTurn(transcriptPath);
|
|
1475
1460
|
if (!turn)
|
|
1476
1461
|
return;
|
|
1477
|
-
const
|
|
1478
|
-
const textSansRecap = fullText.replace(RECAP_TAG_GLOBAL, "").trim();
|
|
1462
|
+
const text2 = turn.text.trim();
|
|
1479
1463
|
const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
|
|
1480
|
-
if (
|
|
1464
|
+
if (text2 || turn.thinkingBlocks > 0) {
|
|
1481
1465
|
const latestMsg = getLatestEventOfType(sessionId, "assistant.message", convoId);
|
|
1482
1466
|
const latestText = latestMsg?.meta?.text;
|
|
1483
|
-
if (latestText !==
|
|
1467
|
+
if (latestText !== text2) {
|
|
1484
1468
|
emitAssistantMessage({
|
|
1485
1469
|
sessionId,
|
|
1486
1470
|
conversationId: convoId,
|
|
1487
|
-
text:
|
|
1471
|
+
text: text2,
|
|
1488
1472
|
model: turn.model,
|
|
1489
1473
|
thinkingBlocks: turn.thinkingBlocks,
|
|
1490
1474
|
thinkingBytes: turn.thinkingBytes,
|
|
1491
|
-
summary:
|
|
1492
|
-
});
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
const recapMatch = fullText.match(RECAP_RE);
|
|
1496
|
-
const recap = recapMatch?.[1]?.trim();
|
|
1497
|
-
if (recap) {
|
|
1498
|
-
const latestRecap = getLatestEventOfType(sessionId, "assistant.recap", convoId);
|
|
1499
|
-
const latestRecapText = latestRecap?.meta?.recap;
|
|
1500
|
-
if (latestRecapText !== recap) {
|
|
1501
|
-
emitAssistantRecap({
|
|
1502
|
-
sessionId,
|
|
1503
|
-
conversationId: convoId,
|
|
1504
|
-
recap
|
|
1475
|
+
summary: text2 ? summarize(text2) : "thinking only"
|
|
1505
1476
|
});
|
|
1506
1477
|
}
|
|
1507
1478
|
}
|
|
@@ -1511,11 +1482,9 @@ var init_assistant_message = __esm(() => {
|
|
|
1511
1482
|
// src/contract/template.md
|
|
1512
1483
|
var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
|
|
1513
1484
|
|
|
1514
|
-
After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include an option labeled exactly \`Done for now\` (this exact wording is required \u2014 the session-exit hook greps for it).
|
|
1485
|
+
After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include an option labeled exactly \`Done for now\` (this exact wording is required \u2014 the session-exit hook greps for it).
|
|
1515
1486
|
|
|
1516
1487
|
If the user's most recent answer to AskUserQuestion was "Done for now" (or contains it), this turn is the FINAL turn. Respond briefly to acknowledge and do NOT call AskUserQuestion again \u2014 the loop is over.
|
|
1517
|
-
|
|
1518
|
-
Before each AskUserQuestion call, emit a \`<recap>...</recap>\` block in your text output. Use markdown \u2014 a short bullet list is usually the most scannable shape; a single short paragraph is fine when the turn was one cohesive thing. Keep it concise. The recap covers what happened since the previous AskUserQuestion (or session start) \u2014 what you found, decided, or did. Write the gist for someone reading the session timeline, not a process log. The dashboard renders these between AskUserQuestion events; do not use this tag for any other purpose.
|
|
1519
1488
|
`;
|
|
1520
1489
|
var init_template = () => {};
|
|
1521
1490
|
|
|
@@ -1650,11 +1619,11 @@ var init_contract = __esm(() => {
|
|
|
1650
1619
|
const categoryPath = category?.path ?? "";
|
|
1651
1620
|
const sessionName = categoryPath ? `${categoryPath}/${session.slug}` : session.slug;
|
|
1652
1621
|
if (short) {
|
|
1653
|
-
process.stdout.write(`Reminder \u2014 you are in bertrand session ${sessionName}: end this turn with an AskUserQuestion call (multiSelect:true on every question, plus a "Done for now" option)
|
|
1622
|
+
process.stdout.write(`Reminder \u2014 you are in bertrand session ${sessionName}: end this turn with an AskUserQuestion call (multiSelect:true on every question, plus a "Done for now" option).`);
|
|
1654
1623
|
return;
|
|
1655
1624
|
}
|
|
1656
1625
|
const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
|
|
1657
|
-
process.stdout.write(buildContract(sessionName, siblingContext));
|
|
1626
|
+
process.stdout.write(buildContract(sessionName, helpText({ agent: true }), siblingContext));
|
|
1658
1627
|
});
|
|
1659
1628
|
});
|
|
1660
1629
|
|
|
@@ -2218,7 +2187,7 @@ var PORT, listSessions = (_params, url) => {
|
|
|
2218
2187
|
return getSessionStats(sessionId) ?? liveStats(sessionId);
|
|
2219
2188
|
}, getEngagement = ({
|
|
2220
2189
|
sessionId
|
|
2221
|
-
}) => computeEngagementStats(sessionId),
|
|
2190
|
+
}) => computeEngagementStats(sessionId), listAllProjects = () => {
|
|
2222
2191
|
const active = resolveActiveProject();
|
|
2223
2192
|
return listProjects().map((p) => ({
|
|
2224
2193
|
slug: p.slug,
|
|
@@ -2249,7 +2218,6 @@ var init_server = __esm(() => {
|
|
|
2249
2218
|
[/^\/api\/stats$/, listAllStats],
|
|
2250
2219
|
[/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
|
|
2251
2220
|
[/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
|
|
2252
|
-
[/^\/api\/recaps$/, listRecaps],
|
|
2253
2221
|
[/^\/api\/projects$/, listAllProjects],
|
|
2254
2222
|
[/^\/api\/active-project$/, getActiveProjectMeta]
|
|
2255
2223
|
];
|
|
@@ -2273,9 +2241,110 @@ var init_serve = __esm(() => {
|
|
|
2273
2241
|
});
|
|
2274
2242
|
});
|
|
2275
2243
|
|
|
2244
|
+
// src/lib/server-lifecycle.ts
|
|
2245
|
+
import { spawn as spawn2 } from "child_process";
|
|
2246
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, unlinkSync } from "fs";
|
|
2247
|
+
import { join as join9 } from "path";
|
|
2248
|
+
function readPidFile() {
|
|
2249
|
+
try {
|
|
2250
|
+
const pid = Number(readFileSync6(deps.pidFile, "utf-8").trim());
|
|
2251
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
2252
|
+
} catch {
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
function isProcessAlive(pid) {
|
|
2257
|
+
try {
|
|
2258
|
+
process.kill(pid, 0);
|
|
2259
|
+
return true;
|
|
2260
|
+
} catch {
|
|
2261
|
+
return false;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
async function isPortListening(port) {
|
|
2265
|
+
try {
|
|
2266
|
+
await fetch(`http://localhost:${port}/api/sessions`, {
|
|
2267
|
+
signal: AbortSignal.timeout(500)
|
|
2268
|
+
});
|
|
2269
|
+
return true;
|
|
2270
|
+
} catch {
|
|
2271
|
+
return false;
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
function removePidFile() {
|
|
2275
|
+
try {
|
|
2276
|
+
unlinkSync(deps.pidFile);
|
|
2277
|
+
} catch {}
|
|
2278
|
+
}
|
|
2279
|
+
async function ensureServerStarted() {
|
|
2280
|
+
const existingPid = readPidFile();
|
|
2281
|
+
if (existingPid && isProcessAlive(existingPid))
|
|
2282
|
+
return;
|
|
2283
|
+
if (existingPid)
|
|
2284
|
+
removePidFile();
|
|
2285
|
+
if (await isPortListening(deps.port))
|
|
2286
|
+
return;
|
|
2287
|
+
const bin = deps.resolveBin();
|
|
2288
|
+
if (!bin)
|
|
2289
|
+
return;
|
|
2290
|
+
const child = spawn2(bin, ["serve"], {
|
|
2291
|
+
detached: true,
|
|
2292
|
+
stdio: "ignore",
|
|
2293
|
+
env: { ...process.env, BERTRAND_PORT: String(deps.port) }
|
|
2294
|
+
});
|
|
2295
|
+
child.unref();
|
|
2296
|
+
if (child.pid)
|
|
2297
|
+
writeFileSync4(deps.pidFile, String(child.pid));
|
|
2298
|
+
}
|
|
2299
|
+
async function ensureServerForActiveSessions() {
|
|
2300
|
+
if (deps.getActiveCount() === 0)
|
|
2301
|
+
return;
|
|
2302
|
+
await ensureServerStarted();
|
|
2303
|
+
}
|
|
2304
|
+
function stopServerIfIdle() {
|
|
2305
|
+
if (deps.getActiveCount() > 0)
|
|
2306
|
+
return;
|
|
2307
|
+
const pid = readPidFile();
|
|
2308
|
+
if (!pid)
|
|
2309
|
+
return;
|
|
2310
|
+
try {
|
|
2311
|
+
process.kill(pid, "SIGTERM");
|
|
2312
|
+
} catch {}
|
|
2313
|
+
removePidFile();
|
|
2314
|
+
}
|
|
2315
|
+
var defaultDeps, deps;
|
|
2316
|
+
var init_server_lifecycle = __esm(() => {
|
|
2317
|
+
init_paths();
|
|
2318
|
+
init_sessions();
|
|
2319
|
+
defaultDeps = {
|
|
2320
|
+
pidFile: join9(paths.root, "server.pid"),
|
|
2321
|
+
port: Number(process.env.BERTRAND_PORT ?? 5200),
|
|
2322
|
+
resolveBin() {
|
|
2323
|
+
try {
|
|
2324
|
+
const config = JSON.parse(readFileSync6(join9(paths.root, "config.json"), "utf-8"));
|
|
2325
|
+
return typeof config?.bin === "string" ? config.bin : null;
|
|
2326
|
+
} catch {
|
|
2327
|
+
return null;
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
getActiveCount: () => getActiveSessions().length
|
|
2331
|
+
};
|
|
2332
|
+
deps = defaultDeps;
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
// src/cli/commands/ensure-server.ts
|
|
2336
|
+
var exports_ensure_server = {};
|
|
2337
|
+
var init_ensure_server = __esm(() => {
|
|
2338
|
+
init_router();
|
|
2339
|
+
init_server_lifecycle();
|
|
2340
|
+
register("ensure-server", async () => {
|
|
2341
|
+
await ensureServerForActiveSessions();
|
|
2342
|
+
});
|
|
2343
|
+
});
|
|
2344
|
+
|
|
2276
2345
|
// src/sync/snapshot.ts
|
|
2277
2346
|
import { Database as Database2 } from "bun:sqlite";
|
|
2278
|
-
import { existsSync as existsSync7, unlinkSync } from "fs";
|
|
2347
|
+
import { existsSync as existsSync7, unlinkSync as unlinkSync2 } from "fs";
|
|
2279
2348
|
function snapshotPathFor(dbPath) {
|
|
2280
2349
|
return `${dbPath}.sync-snapshot`;
|
|
2281
2350
|
}
|
|
@@ -2297,7 +2366,7 @@ function cleanupSnapshot() {
|
|
|
2297
2366
|
const p = base + suffix;
|
|
2298
2367
|
if (existsSync7(p)) {
|
|
2299
2368
|
try {
|
|
2300
|
-
|
|
2369
|
+
unlinkSync2(p);
|
|
2301
2370
|
} catch {}
|
|
2302
2371
|
}
|
|
2303
2372
|
}
|
|
@@ -2350,7 +2419,7 @@ var init_crypto = __esm(() => {
|
|
|
2350
2419
|
|
|
2351
2420
|
// src/sync/engine.ts
|
|
2352
2421
|
import { createClient } from "@supabase/supabase-js";
|
|
2353
|
-
import { readFileSync as
|
|
2422
|
+
import { readFileSync as readFileSync7, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
|
|
2354
2423
|
import { dirname as dirname2 } from "path";
|
|
2355
2424
|
function client(cfg) {
|
|
2356
2425
|
if (!cfg)
|
|
@@ -2380,7 +2449,7 @@ async function push() {
|
|
|
2380
2449
|
error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
|
|
2381
2450
|
};
|
|
2382
2451
|
}
|
|
2383
|
-
const plaintext =
|
|
2452
|
+
const plaintext = readFileSync7(snapshotPath);
|
|
2384
2453
|
const ciphertext = encrypt(plaintext, cfg.encryptionKey);
|
|
2385
2454
|
const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
|
|
2386
2455
|
contentType: "application/octet-stream",
|
|
@@ -3008,7 +3077,7 @@ var init_labels = __esm(() => {
|
|
|
3008
3077
|
});
|
|
3009
3078
|
|
|
3010
3079
|
// src/engine/process.ts
|
|
3011
|
-
import { spawn as
|
|
3080
|
+
import { spawn as spawn3 } from "child_process";
|
|
3012
3081
|
function launchClaude(opts) {
|
|
3013
3082
|
const args = [];
|
|
3014
3083
|
if (opts.resume) {
|
|
@@ -3029,7 +3098,7 @@ function launchClaude(opts) {
|
|
|
3029
3098
|
BERTRAND_PROJECT_DB: active.db
|
|
3030
3099
|
};
|
|
3031
3100
|
return new Promise((resolve, reject) => {
|
|
3032
|
-
const child =
|
|
3101
|
+
const child = spawn3("claude", args, {
|
|
3033
3102
|
env,
|
|
3034
3103
|
stdio: "inherit",
|
|
3035
3104
|
shell: false
|
|
@@ -3064,92 +3133,6 @@ var init_process = __esm(() => {
|
|
|
3064
3133
|
init_resolve();
|
|
3065
3134
|
});
|
|
3066
3135
|
|
|
3067
|
-
// src/lib/server-lifecycle.ts
|
|
3068
|
-
import { spawn as spawn3 } from "child_process";
|
|
3069
|
-
import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2 } from "fs";
|
|
3070
|
-
import { join as join9 } from "path";
|
|
3071
|
-
function readPidFile() {
|
|
3072
|
-
try {
|
|
3073
|
-
const pid = Number(readFileSync7(deps.pidFile, "utf-8").trim());
|
|
3074
|
-
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
3075
|
-
} catch {
|
|
3076
|
-
return null;
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
function isProcessAlive(pid) {
|
|
3080
|
-
try {
|
|
3081
|
-
process.kill(pid, 0);
|
|
3082
|
-
return true;
|
|
3083
|
-
} catch {
|
|
3084
|
-
return false;
|
|
3085
|
-
}
|
|
3086
|
-
}
|
|
3087
|
-
async function isPortListening(port) {
|
|
3088
|
-
try {
|
|
3089
|
-
await fetch(`http://localhost:${port}/api/sessions`, {
|
|
3090
|
-
signal: AbortSignal.timeout(500)
|
|
3091
|
-
});
|
|
3092
|
-
return true;
|
|
3093
|
-
} catch {
|
|
3094
|
-
return false;
|
|
3095
|
-
}
|
|
3096
|
-
}
|
|
3097
|
-
function removePidFile() {
|
|
3098
|
-
try {
|
|
3099
|
-
unlinkSync2(deps.pidFile);
|
|
3100
|
-
} catch {}
|
|
3101
|
-
}
|
|
3102
|
-
async function ensureServerStarted() {
|
|
3103
|
-
const existingPid = readPidFile();
|
|
3104
|
-
if (existingPid && isProcessAlive(existingPid))
|
|
3105
|
-
return;
|
|
3106
|
-
if (existingPid)
|
|
3107
|
-
removePidFile();
|
|
3108
|
-
if (await isPortListening(deps.port))
|
|
3109
|
-
return;
|
|
3110
|
-
const bin = deps.resolveBin();
|
|
3111
|
-
if (!bin)
|
|
3112
|
-
return;
|
|
3113
|
-
const child = spawn3(bin, ["serve"], {
|
|
3114
|
-
detached: true,
|
|
3115
|
-
stdio: "ignore",
|
|
3116
|
-
env: { ...process.env, BERTRAND_PORT: String(deps.port) }
|
|
3117
|
-
});
|
|
3118
|
-
child.unref();
|
|
3119
|
-
if (child.pid)
|
|
3120
|
-
writeFileSync4(deps.pidFile, String(child.pid));
|
|
3121
|
-
}
|
|
3122
|
-
function stopServerIfIdle() {
|
|
3123
|
-
if (deps.getActiveCount() > 0)
|
|
3124
|
-
return;
|
|
3125
|
-
const pid = readPidFile();
|
|
3126
|
-
if (!pid)
|
|
3127
|
-
return;
|
|
3128
|
-
try {
|
|
3129
|
-
process.kill(pid, "SIGTERM");
|
|
3130
|
-
} catch {}
|
|
3131
|
-
removePidFile();
|
|
3132
|
-
}
|
|
3133
|
-
var defaultDeps, deps;
|
|
3134
|
-
var init_server_lifecycle = __esm(() => {
|
|
3135
|
-
init_paths();
|
|
3136
|
-
init_sessions();
|
|
3137
|
-
defaultDeps = {
|
|
3138
|
-
pidFile: join9(paths.root, "server.pid"),
|
|
3139
|
-
port: Number(process.env.BERTRAND_PORT ?? 5200),
|
|
3140
|
-
resolveBin() {
|
|
3141
|
-
try {
|
|
3142
|
-
const config = JSON.parse(readFileSync7(join9(paths.root, "config.json"), "utf-8"));
|
|
3143
|
-
return typeof config?.bin === "string" ? config.bin : null;
|
|
3144
|
-
} catch {
|
|
3145
|
-
return null;
|
|
3146
|
-
}
|
|
3147
|
-
},
|
|
3148
|
-
getActiveCount: () => getActiveSessions().length
|
|
3149
|
-
};
|
|
3150
|
-
deps = defaultDeps;
|
|
3151
|
-
});
|
|
3152
|
-
|
|
3153
3136
|
// src/hooks/runtime.ts
|
|
3154
3137
|
import { readdirSync as readdirSync2, rmSync, statSync as statSync4 } from "fs";
|
|
3155
3138
|
import { join as join10 } from "path";
|
|
@@ -3258,7 +3241,7 @@ async function launch(opts) {
|
|
|
3258
3241
|
cwd: process.cwd()
|
|
3259
3242
|
});
|
|
3260
3243
|
const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
|
|
3261
|
-
const contract = buildContract(sessionName, siblingContext);
|
|
3244
|
+
const contract = buildContract(sessionName, helpText({ agent: true }), siblingContext);
|
|
3262
3245
|
const exitCode = await launchClaude({
|
|
3263
3246
|
sessionId: session.id,
|
|
3264
3247
|
claudeId,
|
|
@@ -3290,7 +3273,7 @@ async function resume(opts) {
|
|
|
3290
3273
|
}
|
|
3291
3274
|
const categoryPath = category?.path ?? "";
|
|
3292
3275
|
const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
|
|
3293
|
-
const contract = buildContract(sessionName, siblingContext);
|
|
3276
|
+
const contract = buildContract(sessionName, helpText({ agent: true }), siblingContext);
|
|
3294
3277
|
const exitCode = await launchClaude({
|
|
3295
3278
|
sessionId: session.id,
|
|
3296
3279
|
claudeId: opts.conversationId,
|
|
@@ -3606,7 +3589,7 @@ rm -f "${runtimeDir2}/working-$sid"
|
|
|
3606
3589
|
|
|
3607
3590
|
bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
|
|
3608
3591
|
|
|
3609
|
-
# Capture the latest assistant turn's text
|
|
3592
|
+
# Capture the latest assistant turn's text. Dedup inside the
|
|
3610
3593
|
# command makes it idempotent vs the matching Stop-time capture so the same
|
|
3611
3594
|
# turn never lands twice.
|
|
3612
3595
|
tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
|
|
@@ -3666,17 +3649,6 @@ if printf '%s' "$done_check" | grep -q "Done for now"; then
|
|
|
3666
3649
|
# so it pauses normally instead of forcing the loop to continue.
|
|
3667
3650
|
touch "${runtimeDir2}/done-$sid"
|
|
3668
3651
|
|
|
3669
|
-
# Promote the picked Done-for-now option's description into a session.recap
|
|
3670
|
-
# event so the timeline has a dedicated end-of-session summary row. Bertrand
|
|
3671
|
-
# forces session exit before Claude can write a closing message, so this
|
|
3672
|
-
# reuses the agent-authored recap that already lives on the option.
|
|
3673
|
-
recap="$(printf '%s' "$meta" | jq -r '
|
|
3674
|
-
[.questions[]?.options[]? | select(.label == "Done for now") | .description] | first // empty
|
|
3675
|
-
' 2>/dev/null)"
|
|
3676
|
-
if [ -n "$recap" ]; then
|
|
3677
|
-
bq update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
|
|
3678
|
-
fi
|
|
3679
|
-
|
|
3680
3652
|
printf '{"continue": false, "stopReason": "User selected Done for now"}\\n'
|
|
3681
3653
|
fi
|
|
3682
3654
|
|
|
@@ -3850,7 +3822,7 @@ if [ ! -f "$done_marker" ]; then
|
|
|
3850
3822
|
case "$count" in ''|*[!0-9]*) count=0 ;; esac
|
|
3851
3823
|
if [ "$count" -lt 3 ]; then
|
|
3852
3824
|
printf '%s' "$((count + 1))" > "$nudge_marker"
|
|
3853
|
-
reason='This is a bertrand session: every turn must end with an AskUserQuestion call (multiSelect:true on every question) that includes a "Done for now" option
|
|
3825
|
+
reason='This is a bertrand session: every turn must end with an AskUserQuestion call (multiSelect:true on every question) that includes a "Done for now" option. You ended a turn without calling AskUserQuestion. Call it now to continue the loop, or \u2014 if the work is finished \u2014 present it so the user can pick "Done for now" to end the session.'
|
|
3854
3826
|
wait
|
|
3855
3827
|
jq -n --arg r "$reason" '{decision:"block", reason:$r}'
|
|
3856
3828
|
exit 0
|
|
@@ -4297,8 +4269,6 @@ function extractSummary(row) {
|
|
|
4297
4269
|
}
|
|
4298
4270
|
case "user.prompt":
|
|
4299
4271
|
return meta.prompt ?? "";
|
|
4300
|
-
case "session.recap":
|
|
4301
|
-
return meta.recap ?? "";
|
|
4302
4272
|
case "worktree.entered":
|
|
4303
4273
|
case "worktree.exited":
|
|
4304
4274
|
return meta.branch ?? meta.path ?? "";
|
|
@@ -4337,9 +4307,7 @@ var init_catalog = __esm(() => {
|
|
|
4337
4307
|
"user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
|
|
4338
4308
|
"tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4339
4309
|
"tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4340
|
-
"session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
|
|
4341
4310
|
"assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
|
|
4342
|
-
"assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false },
|
|
4343
4311
|
"worktree.entered": { label: "worktree entered", category: "lifecycle", color: 78, detailColor: 245, skip: false },
|
|
4344
4312
|
"worktree.exited": { label: "worktree exited", category: "lifecycle", color: 245, detailColor: 245, skip: false }
|
|
4345
4313
|
};
|
|
@@ -5226,6 +5194,7 @@ var hotPath = {
|
|
|
5226
5194
|
badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
5227
5195
|
notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
|
|
5228
5196
|
serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
|
|
5197
|
+
"ensure-server": () => Promise.resolve().then(() => (init_ensure_server(), exports_ensure_server)),
|
|
5229
5198
|
sync: () => Promise.resolve().then(() => (init_sync(), exports_sync))
|
|
5230
5199
|
};
|
|
5231
5200
|
if (command && command in hotPath) {
|