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/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 getDb().select().from(sessions).where(eq2(sessions.id, id)).get();
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 getAllSessions(opts) {
902
- const db = getDb();
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 getDb().select().from(events).where(eq4(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
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 getDb().select().from(events).where(and3(eq4(events.sessionId, sessionId), eq4(events.event, eventType))).orderBy(events.createdAt).all();
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 fullText = turn.text;
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 (textSansRecap || turn.thinkingBlocks > 0) {
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 !== textSansRecap) {
1479
+ if (latestText !== text2) {
1484
1480
  emitAssistantMessage({
1485
1481
  sessionId,
1486
1482
  conversationId: convoId,
1487
- text: textSansRecap,
1483
+ text: text2,
1488
1484
  model: turn.model,
1489
1485
  thinkingBlocks: turn.thinkingBlocks,
1490
1486
  thinkingBytes: turn.thinkingBytes,
1491
- summary: textSansRecap ? summarize(textSansRecap) : "thinking only"
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). The description for "Done for now" must be a 1-2 sentence summary of what was accomplished so far. Describe outcomes (what was built, fixed, decided), not process.
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) preceded by a <recap> block.`);
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 getDb().select().from(sessionStats).where(eq5(sessionStats.sessionId, sessionId)).get();
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 = getDb().select({
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 getAllSessions({ excludeArchived });
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 { session } of getAllSessions()) {
2201
- const isLive = session.status === "active" || session.status === "waiting";
2202
- if (isLive) {
2203
- result[session.id] = liveStats(session.id);
2204
- continue;
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
- sessionId
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
- unlinkSync(p);
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 readFileSync6, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
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 = readFileSync6(snapshotPath);
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 spawn2 } from "child_process";
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 = spawn2("claude", args, {
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 + recap tag. Dedup inside the
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, preceded by a <recap> block. 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.'
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) {