bertrand 0.23.0 → 0.25.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 +245 -245
- package/dist/dashboard/assets/index-BDGI5s-V.js +710 -0
- package/dist/dashboard/assets/index-BXNZ0sJ0.css +1 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/run-screen.js +6 -4
- 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/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
|
|
|
@@ -886,8 +919,8 @@ function createSession(opts) {
|
|
|
886
919
|
const id = createId();
|
|
887
920
|
return db.insert(sessions).values({ id, ...opts }).returning().get();
|
|
888
921
|
}
|
|
889
|
-
function getSession(id) {
|
|
890
|
-
return
|
|
922
|
+
function getSession(id, db = getDb()) {
|
|
923
|
+
return db.select().from(sessions).where(eq2(sessions.id, id)).get();
|
|
891
924
|
}
|
|
892
925
|
function getSessionByCategorySlug(categoryId, slug) {
|
|
893
926
|
return getDb().select().from(sessions).where(and(eq2(sessions.categoryId, categoryId), eq2(sessions.slug, slug))).get();
|
|
@@ -898,8 +931,11 @@ function getSessionsByCategory(categoryId) {
|
|
|
898
931
|
function getActiveSessions() {
|
|
899
932
|
return getDb().select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id)).where(inArray(sessions.status, ["active", "waiting"])).all();
|
|
900
933
|
}
|
|
901
|
-
function
|
|
902
|
-
const
|
|
934
|
+
function countLiveSessions(db = getDb()) {
|
|
935
|
+
const row = db.select({ n: sql2`count(*)` }).from(sessions).where(inArray(sessions.status, ["active", "waiting"])).get();
|
|
936
|
+
return row?.n ?? 0;
|
|
937
|
+
}
|
|
938
|
+
function selectSessions(db, opts) {
|
|
903
939
|
const query = db.select({ session: sessions, categoryPath: categories.path }).from(sessions).innerJoin(categories, eq2(sessions.categoryId, categories.id));
|
|
904
940
|
if (opts?.excludeArchived) {
|
|
905
941
|
return query.where(inArray(sessions.status, [
|
|
@@ -910,6 +946,15 @@ function getAllSessions(opts) {
|
|
|
910
946
|
}
|
|
911
947
|
return query.all();
|
|
912
948
|
}
|
|
949
|
+
function getAllSessions(opts) {
|
|
950
|
+
return selectSessions(getDb(), opts);
|
|
951
|
+
}
|
|
952
|
+
function getAllSessionsForProject(project, opts) {
|
|
953
|
+
return selectSessions(getDbForProject(project.slug), opts).map((s) => ({
|
|
954
|
+
...s,
|
|
955
|
+
project
|
|
956
|
+
}));
|
|
957
|
+
}
|
|
913
958
|
function updateSessionStatus(id, status) {
|
|
914
959
|
return getDb().update(sessions).set({ status, updatedAt: sql2`(datetime('now'))` }).where(eq2(sessions.id, id)).returning().get();
|
|
915
960
|
}
|
|
@@ -955,8 +1000,6 @@ function normalizeEventMeta(eventName, meta) {
|
|
|
955
1000
|
if (!meta)
|
|
956
1001
|
return meta;
|
|
957
1002
|
switch (eventName) {
|
|
958
|
-
case "session.recap":
|
|
959
|
-
return mapStringField(meta, "recap", normalizeMarkdown);
|
|
960
1003
|
case "assistant.message":
|
|
961
1004
|
return mapStringField(meta, "text", normalizeMarkdown);
|
|
962
1005
|
case "session.answered":
|
|
@@ -1005,11 +1048,11 @@ function insertEvent(opts) {
|
|
|
1005
1048
|
meta: normalizeEventMeta(opts.event, opts.meta)
|
|
1006
1049
|
}).returning().get();
|
|
1007
1050
|
}
|
|
1008
|
-
function getEventsBySession(sessionId) {
|
|
1009
|
-
return
|
|
1051
|
+
function getEventsBySession(sessionId, db = getDb()) {
|
|
1052
|
+
return db.select().from(events).where(eq4(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
|
|
1010
1053
|
}
|
|
1011
|
-
function getEventsByType(sessionId, eventType) {
|
|
1012
|
-
return
|
|
1054
|
+
function getEventsByType(sessionId, eventType, db = getDb()) {
|
|
1055
|
+
return db.select().from(events).where(and3(eq4(events.sessionId, sessionId), eq4(events.event, eventType))).orderBy(events.createdAt).all();
|
|
1013
1056
|
}
|
|
1014
1057
|
function getLatestEventOfType(sessionId, eventType, conversationId) {
|
|
1015
1058
|
const conditions = [
|
|
@@ -1021,24 +1064,6 @@ function getLatestEventOfType(sessionId, eventType, conversationId) {
|
|
|
1021
1064
|
}
|
|
1022
1065
|
return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
|
|
1023
1066
|
}
|
|
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
1067
|
var init_events = __esm(() => {
|
|
1043
1068
|
init_client();
|
|
1044
1069
|
init_schema();
|
|
@@ -1096,15 +1121,6 @@ function emitSessionAnswered(args) {
|
|
|
1096
1121
|
}
|
|
1097
1122
|
});
|
|
1098
1123
|
}
|
|
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
1124
|
function emitToolUsed(args) {
|
|
1109
1125
|
const summary = formatToolSummary(args.tool, args.detail);
|
|
1110
1126
|
return insertEvent({
|
|
@@ -1169,15 +1185,6 @@ function emitAssistantMessage(args) {
|
|
|
1169
1185
|
}
|
|
1170
1186
|
});
|
|
1171
1187
|
}
|
|
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
1188
|
function emitWorktreeEntered(args) {
|
|
1182
1189
|
return insertEvent({
|
|
1183
1190
|
sessionId: args.sessionId,
|
|
@@ -1239,13 +1246,6 @@ function dispatchHookEvent(event, ctx) {
|
|
|
1239
1246
|
questions: meta.questions ?? []
|
|
1240
1247
|
});
|
|
1241
1248
|
return true;
|
|
1242
|
-
case "session.recap":
|
|
1243
|
-
emitSessionRecap({
|
|
1244
|
-
sessionId,
|
|
1245
|
-
conversationId,
|
|
1246
|
-
recap: String(meta.recap ?? "")
|
|
1247
|
-
});
|
|
1248
|
-
return true;
|
|
1249
1249
|
case "tool.applied":
|
|
1250
1250
|
emitToolApplied({
|
|
1251
1251
|
sessionId,
|
|
@@ -1434,7 +1434,6 @@ function summarize(text2) {
|
|
|
1434
1434
|
const trimmed = firstLine.trim();
|
|
1435
1435
|
return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
|
|
1436
1436
|
}
|
|
1437
|
-
var RECAP_RE, RECAP_TAG_GLOBAL;
|
|
1438
1437
|
var init_assistant_message = __esm(() => {
|
|
1439
1438
|
init_router();
|
|
1440
1439
|
init_sessions();
|
|
@@ -1442,8 +1441,6 @@ var init_assistant_message = __esm(() => {
|
|
|
1442
1441
|
init_events();
|
|
1443
1442
|
init_emit();
|
|
1444
1443
|
init_transcript();
|
|
1445
|
-
RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
|
|
1446
|
-
RECAP_TAG_GLOBAL = /<recap>[\s\S]*?<\/recap>/gi;
|
|
1447
1444
|
register("assistant-message", async (args) => {
|
|
1448
1445
|
let sessionId = "";
|
|
1449
1446
|
let transcriptPath = "";
|
|
@@ -1474,34 +1471,20 @@ var init_assistant_message = __esm(() => {
|
|
|
1474
1471
|
const turn = getLatestAssistantTurn(transcriptPath);
|
|
1475
1472
|
if (!turn)
|
|
1476
1473
|
return;
|
|
1477
|
-
const
|
|
1478
|
-
const textSansRecap = fullText.replace(RECAP_TAG_GLOBAL, "").trim();
|
|
1474
|
+
const text2 = turn.text.trim();
|
|
1479
1475
|
const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
|
|
1480
|
-
if (
|
|
1476
|
+
if (text2 || turn.thinkingBlocks > 0) {
|
|
1481
1477
|
const latestMsg = getLatestEventOfType(sessionId, "assistant.message", convoId);
|
|
1482
1478
|
const latestText = latestMsg?.meta?.text;
|
|
1483
|
-
if (latestText !==
|
|
1479
|
+
if (latestText !== text2) {
|
|
1484
1480
|
emitAssistantMessage({
|
|
1485
1481
|
sessionId,
|
|
1486
1482
|
conversationId: convoId,
|
|
1487
|
-
text:
|
|
1483
|
+
text: text2,
|
|
1488
1484
|
model: turn.model,
|
|
1489
1485
|
thinkingBlocks: turn.thinkingBlocks,
|
|
1490
1486
|
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
|
|
1487
|
+
summary: text2 ? summarize(text2) : "thinking only"
|
|
1505
1488
|
});
|
|
1506
1489
|
}
|
|
1507
1490
|
}
|
|
@@ -1511,11 +1494,9 @@ var init_assistant_message = __esm(() => {
|
|
|
1511
1494
|
// src/contract/template.md
|
|
1512
1495
|
var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
|
|
1513
1496
|
|
|
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).
|
|
1497
|
+
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
1498
|
|
|
1516
1499
|
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
1500
|
`;
|
|
1520
1501
|
var init_template = () => {};
|
|
1521
1502
|
|
|
@@ -1650,11 +1631,11 @@ var init_contract = __esm(() => {
|
|
|
1650
1631
|
const categoryPath = category?.path ?? "";
|
|
1651
1632
|
const sessionName = categoryPath ? `${categoryPath}/${session.slug}` : session.slug;
|
|
1652
1633
|
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)
|
|
1634
|
+
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
1635
|
return;
|
|
1655
1636
|
}
|
|
1656
1637
|
const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
|
|
1657
|
-
process.stdout.write(buildContract(sessionName, siblingContext));
|
|
1638
|
+
process.stdout.write(buildContract(sessionName, helpText({ agent: true }), siblingContext));
|
|
1658
1639
|
});
|
|
1659
1640
|
});
|
|
1660
1641
|
|
|
@@ -1776,8 +1757,8 @@ var init_notify = __esm(() => {
|
|
|
1776
1757
|
|
|
1777
1758
|
// src/db/queries/stats.ts
|
|
1778
1759
|
import { eq as eq5, sql as sql4 } from "drizzle-orm";
|
|
1779
|
-
function getSessionStats(sessionId) {
|
|
1780
|
-
return
|
|
1760
|
+
function getSessionStats(sessionId, db = getDb()) {
|
|
1761
|
+
return db.select().from(sessionStats).where(eq5(sessionStats.sessionId, sessionId)).get();
|
|
1781
1762
|
}
|
|
1782
1763
|
function upsertSessionStats(sessionId, data) {
|
|
1783
1764
|
return getDb().insert(sessionStats).values({
|
|
@@ -1801,8 +1782,8 @@ function lineCount(s) {
|
|
|
1801
1782
|
return s.split(`
|
|
1802
1783
|
`).length;
|
|
1803
1784
|
}
|
|
1804
|
-
function computeDiffStats(sessionId) {
|
|
1805
|
-
const applied = getEventsByType(sessionId, "tool.applied");
|
|
1785
|
+
function computeDiffStats(sessionId, db = getDb()) {
|
|
1786
|
+
const applied = getEventsByType(sessionId, "tool.applied", db);
|
|
1806
1787
|
const files = new Set;
|
|
1807
1788
|
let linesAdded = 0;
|
|
1808
1789
|
let linesRemoved = 0;
|
|
@@ -1827,6 +1808,7 @@ function computeDiffStats(sessionId) {
|
|
|
1827
1808
|
}
|
|
1828
1809
|
var init_diff_stats = __esm(() => {
|
|
1829
1810
|
init_events();
|
|
1811
|
+
init_client();
|
|
1830
1812
|
});
|
|
1831
1813
|
|
|
1832
1814
|
// src/lib/timing.ts
|
|
@@ -1916,12 +1898,12 @@ function computeTimings(events2) {
|
|
|
1916
1898
|
}
|
|
1917
1899
|
return { totalClaudeWorkMs, totalUserWaitMs, activePct, durationS, segments };
|
|
1918
1900
|
}
|
|
1919
|
-
function computeSessionStats(sessionId) {
|
|
1920
|
-
const events2 = getEventsBySession(sessionId);
|
|
1901
|
+
function computeSessionStats(sessionId, db = getDb()) {
|
|
1902
|
+
const events2 = getEventsBySession(sessionId, db);
|
|
1921
1903
|
const summary = computeTimings(events2);
|
|
1922
1904
|
const conversationIds = new Set(events2.filter((e) => e.conversationId).map((e) => e.conversationId));
|
|
1923
1905
|
const interactionCount = events2.filter((e) => e.event === "session.waiting" || e.event === "session.answered").length;
|
|
1924
|
-
const diff = computeDiffStats(sessionId);
|
|
1906
|
+
const diff = computeDiffStats(sessionId, db);
|
|
1925
1907
|
return {
|
|
1926
1908
|
eventCount: events2.length,
|
|
1927
1909
|
conversationCount: conversationIds.size,
|
|
@@ -1947,13 +1929,14 @@ var init_timing = __esm(() => {
|
|
|
1947
1929
|
init_events();
|
|
1948
1930
|
init_stats();
|
|
1949
1931
|
init_diff_stats();
|
|
1932
|
+
init_client();
|
|
1950
1933
|
});
|
|
1951
1934
|
|
|
1952
1935
|
// src/lib/engagement_stats.ts
|
|
1953
1936
|
import { eq as eq6, sql as sql5 } from "drizzle-orm";
|
|
1954
|
-
function aggregateToolUsage(sessionId) {
|
|
1937
|
+
function aggregateToolUsage(sessionId, db) {
|
|
1955
1938
|
const counts = {};
|
|
1956
|
-
const applied = getEventsByType(sessionId, "tool.applied");
|
|
1939
|
+
const applied = getEventsByType(sessionId, "tool.applied", db);
|
|
1957
1940
|
for (const ev of applied) {
|
|
1958
1941
|
const meta = ev.meta;
|
|
1959
1942
|
const permissions = meta?.permissions ?? [];
|
|
@@ -1963,7 +1946,7 @@ function aggregateToolUsage(sessionId) {
|
|
|
1963
1946
|
counts[p.tool] = (counts[p.tool] ?? 0) + (p.count ?? 1);
|
|
1964
1947
|
}
|
|
1965
1948
|
}
|
|
1966
|
-
const used = getEventsByType(sessionId, "tool.used");
|
|
1949
|
+
const used = getEventsByType(sessionId, "tool.used", db);
|
|
1967
1950
|
for (const ev of used) {
|
|
1968
1951
|
const meta = ev.meta;
|
|
1969
1952
|
const tool = meta?.tool;
|
|
@@ -1973,8 +1956,8 @@ function aggregateToolUsage(sessionId) {
|
|
|
1973
1956
|
}
|
|
1974
1957
|
return counts;
|
|
1975
1958
|
}
|
|
1976
|
-
function discardRate(sessionId) {
|
|
1977
|
-
const row =
|
|
1959
|
+
function discardRate(sessionId, db) {
|
|
1960
|
+
const row = db.select({
|
|
1978
1961
|
total: sql5`count(*)`,
|
|
1979
1962
|
discarded: sql5`sum(case when ${conversations.discarded} then 1 else 0 end)`
|
|
1980
1963
|
}).from(conversations).where(eq6(conversations.sessionId, sessionId)).get();
|
|
@@ -1983,10 +1966,10 @@ function discardRate(sessionId) {
|
|
|
1983
1966
|
discarded: row?.discarded ?? 0
|
|
1984
1967
|
};
|
|
1985
1968
|
}
|
|
1986
|
-
function computeEngagementStats(sessionId) {
|
|
1969
|
+
function computeEngagementStats(sessionId, db = getDb()) {
|
|
1987
1970
|
return {
|
|
1988
|
-
toolUsage: aggregateToolUsage(sessionId),
|
|
1989
|
-
discardRate: discardRate(sessionId)
|
|
1971
|
+
toolUsage: aggregateToolUsage(sessionId, db),
|
|
1972
|
+
discardRate: discardRate(sessionId, db)
|
|
1990
1973
|
};
|
|
1991
1974
|
}
|
|
1992
1975
|
var init_engagement_stats = __esm(() => {
|
|
@@ -2039,13 +2022,28 @@ var init_session_archive = __esm(() => {
|
|
|
2039
2022
|
import { execFile } from "child_process";
|
|
2040
2023
|
import { existsSync as existsSync6 } from "fs";
|
|
2041
2024
|
import { join as join8 } from "path";
|
|
2042
|
-
function liveStats(sessionId) {
|
|
2025
|
+
function liveStats(sessionId, db) {
|
|
2043
2026
|
return {
|
|
2044
2027
|
sessionId,
|
|
2045
|
-
...computeSessionStats(sessionId),
|
|
2028
|
+
...computeSessionStats(sessionId, db),
|
|
2046
2029
|
updatedAt: new Date().toISOString()
|
|
2047
2030
|
};
|
|
2048
2031
|
}
|
|
2032
|
+
function resolveProjectScope(url) {
|
|
2033
|
+
const nameBySlug = new Map(listProjects().map((p) => [p.slug, p.name]));
|
|
2034
|
+
const param = url.searchParams.get("projects");
|
|
2035
|
+
if (param === null) {
|
|
2036
|
+
const active = resolveActiveProject();
|
|
2037
|
+
return [{ slug: active.slug, name: active.name }];
|
|
2038
|
+
}
|
|
2039
|
+
return param.split(",").map((s) => s.trim()).filter((slug) => nameBySlug.has(slug)).map((slug) => ({ slug, name: nameBySlug.get(slug) }));
|
|
2040
|
+
}
|
|
2041
|
+
function resolveDb(url) {
|
|
2042
|
+
const slug = url.searchParams.get("project");
|
|
2043
|
+
if (slug && projectExists(slug))
|
|
2044
|
+
return getDbForProject(slug);
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2049
2047
|
function archiveResponse(result) {
|
|
2050
2048
|
if (result.ok)
|
|
2051
2049
|
return Response.json(result.session);
|
|
@@ -2189,42 +2187,44 @@ function startServer(port = PORT) {
|
|
|
2189
2187
|
}
|
|
2190
2188
|
var PORT, listSessions = (_params, url) => {
|
|
2191
2189
|
const excludeArchived = url.searchParams.get("excludeArchived") !== "false";
|
|
2192
|
-
return
|
|
2190
|
+
return resolveProjectScope(url).flatMap((project) => getAllSessionsForProject(project, { excludeArchived }));
|
|
2193
2191
|
}, getSessionById = ({ id }) => getSession(id), listEvents = ({ sessionId }, url) => {
|
|
2192
|
+
const db = resolveDb(url);
|
|
2194
2193
|
const eventType = url.searchParams.get("type");
|
|
2195
2194
|
if (eventType)
|
|
2196
|
-
return getEventsByType(sessionId, eventType);
|
|
2197
|
-
return getEventsBySession(sessionId);
|
|
2198
|
-
}, listAllStats = () => {
|
|
2195
|
+
return getEventsByType(sessionId, eventType, db);
|
|
2196
|
+
return getEventsBySession(sessionId, db);
|
|
2197
|
+
}, listAllStats = (_params, url) => {
|
|
2199
2198
|
const result = {};
|
|
2200
|
-
for (const
|
|
2201
|
-
const
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2199
|
+
for (const project of resolveProjectScope(url)) {
|
|
2200
|
+
const db = getDbForProject(project.slug);
|
|
2201
|
+
for (const { session } of getAllSessionsForProject(project)) {
|
|
2202
|
+
const isLive = session.status === "active" || session.status === "waiting";
|
|
2203
|
+
if (isLive) {
|
|
2204
|
+
result[session.id] = liveStats(session.id, db);
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
result[session.id] = getSessionStats(session.id, db) ?? liveStats(session.id, db);
|
|
2205
2208
|
}
|
|
2206
|
-
result[session.id] = getSessionStats(session.id) ?? liveStats(session.id);
|
|
2207
2209
|
}
|
|
2208
2210
|
return result;
|
|
2209
|
-
}, getStatsBySession = ({
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
const session = getSession(sessionId);
|
|
2211
|
+
}, getStatsBySession = ({ sessionId }, url) => {
|
|
2212
|
+
const db = resolveDb(url);
|
|
2213
|
+
const session = getSession(sessionId, db);
|
|
2213
2214
|
if (!session)
|
|
2214
2215
|
return null;
|
|
2215
2216
|
const isLive = session.status === "active" || session.status === "waiting";
|
|
2216
2217
|
if (isLive)
|
|
2217
|
-
return liveStats(sessionId);
|
|
2218
|
-
return getSessionStats(sessionId) ?? liveStats(sessionId);
|
|
2219
|
-
}, getEngagement = ({
|
|
2220
|
-
sessionId
|
|
2221
|
-
}) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), listAllProjects = () => {
|
|
2218
|
+
return liveStats(sessionId, db);
|
|
2219
|
+
return getSessionStats(sessionId, db) ?? liveStats(sessionId, db);
|
|
2220
|
+
}, getEngagement = ({ sessionId }, url) => computeEngagementStats(sessionId, resolveDb(url)), listAllProjects = () => {
|
|
2222
2221
|
const active = resolveActiveProject();
|
|
2223
2222
|
return listProjects().map((p) => ({
|
|
2224
2223
|
slug: p.slug,
|
|
2225
2224
|
name: p.name,
|
|
2226
2225
|
active: p.slug === active.slug,
|
|
2227
|
-
lastUsedAt: p.lastUsedAt
|
|
2226
|
+
lastUsedAt: p.lastUsedAt,
|
|
2227
|
+
liveCount: countLiveSessions(getDbForProject(p.slug))
|
|
2228
2228
|
}));
|
|
2229
2229
|
}, getActiveProjectMeta = () => {
|
|
2230
2230
|
const active = resolveActiveProject();
|
|
@@ -2249,7 +2249,6 @@ var init_server = __esm(() => {
|
|
|
2249
2249
|
[/^\/api\/stats$/, listAllStats],
|
|
2250
2250
|
[/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
|
|
2251
2251
|
[/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
|
|
2252
|
-
[/^\/api\/recaps$/, listRecaps],
|
|
2253
2252
|
[/^\/api\/projects$/, listAllProjects],
|
|
2254
2253
|
[/^\/api\/active-project$/, getActiveProjectMeta]
|
|
2255
2254
|
];
|
|
@@ -2273,9 +2272,110 @@ var init_serve = __esm(() => {
|
|
|
2273
2272
|
});
|
|
2274
2273
|
});
|
|
2275
2274
|
|
|
2275
|
+
// src/lib/server-lifecycle.ts
|
|
2276
|
+
import { spawn as spawn2 } from "child_process";
|
|
2277
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, unlinkSync } from "fs";
|
|
2278
|
+
import { join as join9 } from "path";
|
|
2279
|
+
function readPidFile() {
|
|
2280
|
+
try {
|
|
2281
|
+
const pid = Number(readFileSync6(deps.pidFile, "utf-8").trim());
|
|
2282
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
2283
|
+
} catch {
|
|
2284
|
+
return null;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
function isProcessAlive(pid) {
|
|
2288
|
+
try {
|
|
2289
|
+
process.kill(pid, 0);
|
|
2290
|
+
return true;
|
|
2291
|
+
} catch {
|
|
2292
|
+
return false;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
async function isPortListening(port) {
|
|
2296
|
+
try {
|
|
2297
|
+
await fetch(`http://localhost:${port}/api/sessions`, {
|
|
2298
|
+
signal: AbortSignal.timeout(500)
|
|
2299
|
+
});
|
|
2300
|
+
return true;
|
|
2301
|
+
} catch {
|
|
2302
|
+
return false;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
function removePidFile() {
|
|
2306
|
+
try {
|
|
2307
|
+
unlinkSync(deps.pidFile);
|
|
2308
|
+
} catch {}
|
|
2309
|
+
}
|
|
2310
|
+
async function ensureServerStarted() {
|
|
2311
|
+
const existingPid = readPidFile();
|
|
2312
|
+
if (existingPid && isProcessAlive(existingPid))
|
|
2313
|
+
return;
|
|
2314
|
+
if (existingPid)
|
|
2315
|
+
removePidFile();
|
|
2316
|
+
if (await isPortListening(deps.port))
|
|
2317
|
+
return;
|
|
2318
|
+
const bin = deps.resolveBin();
|
|
2319
|
+
if (!bin)
|
|
2320
|
+
return;
|
|
2321
|
+
const child = spawn2(bin, ["serve"], {
|
|
2322
|
+
detached: true,
|
|
2323
|
+
stdio: "ignore",
|
|
2324
|
+
env: { ...process.env, BERTRAND_PORT: String(deps.port) }
|
|
2325
|
+
});
|
|
2326
|
+
child.unref();
|
|
2327
|
+
if (child.pid)
|
|
2328
|
+
writeFileSync4(deps.pidFile, String(child.pid));
|
|
2329
|
+
}
|
|
2330
|
+
async function ensureServerForActiveSessions() {
|
|
2331
|
+
if (deps.getActiveCount() === 0)
|
|
2332
|
+
return;
|
|
2333
|
+
await ensureServerStarted();
|
|
2334
|
+
}
|
|
2335
|
+
function stopServerIfIdle() {
|
|
2336
|
+
if (deps.getActiveCount() > 0)
|
|
2337
|
+
return;
|
|
2338
|
+
const pid = readPidFile();
|
|
2339
|
+
if (!pid)
|
|
2340
|
+
return;
|
|
2341
|
+
try {
|
|
2342
|
+
process.kill(pid, "SIGTERM");
|
|
2343
|
+
} catch {}
|
|
2344
|
+
removePidFile();
|
|
2345
|
+
}
|
|
2346
|
+
var defaultDeps, deps;
|
|
2347
|
+
var init_server_lifecycle = __esm(() => {
|
|
2348
|
+
init_paths();
|
|
2349
|
+
init_sessions();
|
|
2350
|
+
defaultDeps = {
|
|
2351
|
+
pidFile: join9(paths.root, "server.pid"),
|
|
2352
|
+
port: Number(process.env.BERTRAND_PORT ?? 5200),
|
|
2353
|
+
resolveBin() {
|
|
2354
|
+
try {
|
|
2355
|
+
const config = JSON.parse(readFileSync6(join9(paths.root, "config.json"), "utf-8"));
|
|
2356
|
+
return typeof config?.bin === "string" ? config.bin : null;
|
|
2357
|
+
} catch {
|
|
2358
|
+
return null;
|
|
2359
|
+
}
|
|
2360
|
+
},
|
|
2361
|
+
getActiveCount: () => getActiveSessions().length
|
|
2362
|
+
};
|
|
2363
|
+
deps = defaultDeps;
|
|
2364
|
+
});
|
|
2365
|
+
|
|
2366
|
+
// src/cli/commands/ensure-server.ts
|
|
2367
|
+
var exports_ensure_server = {};
|
|
2368
|
+
var init_ensure_server = __esm(() => {
|
|
2369
|
+
init_router();
|
|
2370
|
+
init_server_lifecycle();
|
|
2371
|
+
register("ensure-server", async () => {
|
|
2372
|
+
await ensureServerForActiveSessions();
|
|
2373
|
+
});
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2276
2376
|
// src/sync/snapshot.ts
|
|
2277
2377
|
import { Database as Database2 } from "bun:sqlite";
|
|
2278
|
-
import { existsSync as existsSync7, unlinkSync } from "fs";
|
|
2378
|
+
import { existsSync as existsSync7, unlinkSync as unlinkSync2 } from "fs";
|
|
2279
2379
|
function snapshotPathFor(dbPath) {
|
|
2280
2380
|
return `${dbPath}.sync-snapshot`;
|
|
2281
2381
|
}
|
|
@@ -2297,7 +2397,7 @@ function cleanupSnapshot() {
|
|
|
2297
2397
|
const p = base + suffix;
|
|
2298
2398
|
if (existsSync7(p)) {
|
|
2299
2399
|
try {
|
|
2300
|
-
|
|
2400
|
+
unlinkSync2(p);
|
|
2301
2401
|
} catch {}
|
|
2302
2402
|
}
|
|
2303
2403
|
}
|
|
@@ -2350,7 +2450,7 @@ var init_crypto = __esm(() => {
|
|
|
2350
2450
|
|
|
2351
2451
|
// src/sync/engine.ts
|
|
2352
2452
|
import { createClient } from "@supabase/supabase-js";
|
|
2353
|
-
import { readFileSync as
|
|
2453
|
+
import { readFileSync as readFileSync7, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
|
|
2354
2454
|
import { dirname as dirname2 } from "path";
|
|
2355
2455
|
function client(cfg) {
|
|
2356
2456
|
if (!cfg)
|
|
@@ -2380,7 +2480,7 @@ async function push() {
|
|
|
2380
2480
|
error: `snapshot failed: ${e instanceof Error ? e.message : String(e)}`
|
|
2381
2481
|
};
|
|
2382
2482
|
}
|
|
2383
|
-
const plaintext =
|
|
2483
|
+
const plaintext = readFileSync7(snapshotPath);
|
|
2384
2484
|
const ciphertext = encrypt(plaintext, cfg.encryptionKey);
|
|
2385
2485
|
const { error } = await supabase.storage.from(cfg.bucket).upload(cfg.objectKey, ciphertext, {
|
|
2386
2486
|
contentType: "application/octet-stream",
|
|
@@ -3008,7 +3108,7 @@ var init_labels = __esm(() => {
|
|
|
3008
3108
|
});
|
|
3009
3109
|
|
|
3010
3110
|
// src/engine/process.ts
|
|
3011
|
-
import { spawn as
|
|
3111
|
+
import { spawn as spawn3 } from "child_process";
|
|
3012
3112
|
function launchClaude(opts) {
|
|
3013
3113
|
const args = [];
|
|
3014
3114
|
if (opts.resume) {
|
|
@@ -3029,7 +3129,7 @@ function launchClaude(opts) {
|
|
|
3029
3129
|
BERTRAND_PROJECT_DB: active.db
|
|
3030
3130
|
};
|
|
3031
3131
|
return new Promise((resolve, reject) => {
|
|
3032
|
-
const child =
|
|
3132
|
+
const child = spawn3("claude", args, {
|
|
3033
3133
|
env,
|
|
3034
3134
|
stdio: "inherit",
|
|
3035
3135
|
shell: false
|
|
@@ -3064,92 +3164,6 @@ var init_process = __esm(() => {
|
|
|
3064
3164
|
init_resolve();
|
|
3065
3165
|
});
|
|
3066
3166
|
|
|
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
3167
|
// src/hooks/runtime.ts
|
|
3154
3168
|
import { readdirSync as readdirSync2, rmSync, statSync as statSync4 } from "fs";
|
|
3155
3169
|
import { join as join10 } from "path";
|
|
@@ -3258,7 +3272,7 @@ async function launch(opts) {
|
|
|
3258
3272
|
cwd: process.cwd()
|
|
3259
3273
|
});
|
|
3260
3274
|
const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
|
|
3261
|
-
const contract = buildContract(sessionName, siblingContext);
|
|
3275
|
+
const contract = buildContract(sessionName, helpText({ agent: true }), siblingContext);
|
|
3262
3276
|
const exitCode = await launchClaude({
|
|
3263
3277
|
sessionId: session.id,
|
|
3264
3278
|
claudeId,
|
|
@@ -3290,7 +3304,7 @@ async function resume(opts) {
|
|
|
3290
3304
|
}
|
|
3291
3305
|
const categoryPath = category?.path ?? "";
|
|
3292
3306
|
const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
|
|
3293
|
-
const contract = buildContract(sessionName, siblingContext);
|
|
3307
|
+
const contract = buildContract(sessionName, helpText({ agent: true }), siblingContext);
|
|
3294
3308
|
const exitCode = await launchClaude({
|
|
3295
3309
|
sessionId: session.id,
|
|
3296
3310
|
claudeId: opts.conversationId,
|
|
@@ -3606,7 +3620,7 @@ rm -f "${runtimeDir2}/working-$sid"
|
|
|
3606
3620
|
|
|
3607
3621
|
bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
|
|
3608
3622
|
|
|
3609
|
-
# Capture the latest assistant turn's text
|
|
3623
|
+
# Capture the latest assistant turn's text. Dedup inside the
|
|
3610
3624
|
# command makes it idempotent vs the matching Stop-time capture so the same
|
|
3611
3625
|
# turn never lands twice.
|
|
3612
3626
|
tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
|
|
@@ -3666,17 +3680,6 @@ if printf '%s' "$done_check" | grep -q "Done for now"; then
|
|
|
3666
3680
|
# so it pauses normally instead of forcing the loop to continue.
|
|
3667
3681
|
touch "${runtimeDir2}/done-$sid"
|
|
3668
3682
|
|
|
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
3683
|
printf '{"continue": false, "stopReason": "User selected Done for now"}\\n'
|
|
3681
3684
|
fi
|
|
3682
3685
|
|
|
@@ -3850,7 +3853,7 @@ if [ ! -f "$done_marker" ]; then
|
|
|
3850
3853
|
case "$count" in ''|*[!0-9]*) count=0 ;; esac
|
|
3851
3854
|
if [ "$count" -lt 3 ]; then
|
|
3852
3855
|
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
|
|
3856
|
+
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
3857
|
wait
|
|
3855
3858
|
jq -n --arg r "$reason" '{decision:"block", reason:$r}'
|
|
3856
3859
|
exit 0
|
|
@@ -4297,8 +4300,6 @@ function extractSummary(row) {
|
|
|
4297
4300
|
}
|
|
4298
4301
|
case "user.prompt":
|
|
4299
4302
|
return meta.prompt ?? "";
|
|
4300
|
-
case "session.recap":
|
|
4301
|
-
return meta.recap ?? "";
|
|
4302
4303
|
case "worktree.entered":
|
|
4303
4304
|
case "worktree.exited":
|
|
4304
4305
|
return meta.branch ?? meta.path ?? "";
|
|
@@ -4337,9 +4338,7 @@ var init_catalog = __esm(() => {
|
|
|
4337
4338
|
"user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
|
|
4338
4339
|
"tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: false },
|
|
4339
4340
|
"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
4341
|
"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
4342
|
"worktree.entered": { label: "worktree entered", category: "lifecycle", color: 78, detailColor: 245, skip: false },
|
|
4344
4343
|
"worktree.exited": { label: "worktree exited", category: "lifecycle", color: 245, detailColor: 245, skip: false }
|
|
4345
4344
|
};
|
|
@@ -5226,6 +5225,7 @@ var hotPath = {
|
|
|
5226
5225
|
badge: () => Promise.resolve().then(() => (init_badge(), exports_badge)),
|
|
5227
5226
|
notify: () => Promise.resolve().then(() => (init_notify(), exports_notify)),
|
|
5228
5227
|
serve: () => Promise.resolve().then(() => (init_serve(), exports_serve)),
|
|
5228
|
+
"ensure-server": () => Promise.resolve().then(() => (init_ensure_server(), exports_ensure_server)),
|
|
5229
5229
|
sync: () => Promise.resolve().then(() => (init_sync(), exports_sync))
|
|
5230
5230
|
};
|
|
5231
5231
|
if (command && command in hotPath) {
|