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 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`, `session.recap`, etc.). Free-form `meta` JSON column.
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 fullText = turn.text;
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 (textSansRecap || turn.thinkingBlocks > 0) {
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 !== textSansRecap) {
1467
+ if (latestText !== text2) {
1484
1468
  emitAssistantMessage({
1485
1469
  sessionId,
1486
1470
  conversationId: convoId,
1487
- text: textSansRecap,
1471
+ text: text2,
1488
1472
  model: turn.model,
1489
1473
  thinkingBlocks: turn.thinkingBlocks,
1490
1474
  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
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). 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.
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) preceded by a <recap> block.`);
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), listRecaps = () => getLatestRecaps(), listAllProjects = () => {
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
- unlinkSync(p);
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 readFileSync6, renameSync as renameSync3, statSync as statSync3, openSync, writeSync, fsyncSync, closeSync } from "fs";
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 = readFileSync6(snapshotPath);
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 spawn2 } from "child_process";
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 = spawn2("claude", args, {
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 + recap tag. Dedup inside the
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, 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.'
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) {