bertrand 0.14.1 → 0.16.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
@@ -1151,8 +1151,63 @@ var init_engagement_stats = __esm(() => {
1151
1151
  init_events();
1152
1152
  });
1153
1153
 
1154
+ // src/lib/session-archive.ts
1155
+ function archiveSession(id) {
1156
+ const session = getSession(id);
1157
+ if (!session)
1158
+ return { ok: false, reason: "not-found" };
1159
+ if (ACTIVE_STATUSES.includes(session.status)) {
1160
+ return { ok: false, reason: "active" };
1161
+ }
1162
+ if (session.status === "archived") {
1163
+ return { ok: false, reason: "already-archived" };
1164
+ }
1165
+ const updated = updateSessionStatus(id, "archived");
1166
+ return { ok: true, session: updated };
1167
+ }
1168
+ function unarchiveSession(id) {
1169
+ const session = getSession(id);
1170
+ if (!session)
1171
+ return { ok: false, reason: "not-found" };
1172
+ if (session.status !== "archived") {
1173
+ return { ok: false, reason: "not-archived" };
1174
+ }
1175
+ const updated = updateSessionStatus(id, "paused");
1176
+ return { ok: true, session: updated };
1177
+ }
1178
+ function archiveAllPaused() {
1179
+ const rows = getAllSessions({ excludeArchived: true });
1180
+ const paused = rows.filter((r) => r.session.status === "paused");
1181
+ const archived = [];
1182
+ for (const row of paused) {
1183
+ const updated = updateSessionStatus(row.session.id, "archived");
1184
+ archived.push({ session: updated, groupPath: row.groupPath });
1185
+ }
1186
+ return { archived };
1187
+ }
1188
+ var ACTIVE_STATUSES;
1189
+ var init_session_archive = __esm(() => {
1190
+ init_sessions();
1191
+ ACTIVE_STATUSES = ["active", "waiting"];
1192
+ });
1193
+
1154
1194
  // src/server/index.ts
1155
1195
  import { execFile } from "child_process";
1196
+ import { existsSync as existsSync3 } from "fs";
1197
+ import { join as join3 } from "path";
1198
+ function liveStats(sessionId) {
1199
+ return {
1200
+ sessionId,
1201
+ ...computeSessionStats(sessionId),
1202
+ updatedAt: new Date().toISOString()
1203
+ };
1204
+ }
1205
+ function archiveResponse(result) {
1206
+ if (result.ok)
1207
+ return Response.json(result.session);
1208
+ const meta = ARCHIVE_ERROR[result.reason] ?? { status: 400, message: "Operation failed" };
1209
+ return Response.json({ error: meta.message, reason: result.reason }, { status: meta.status });
1210
+ }
1156
1211
  async function handleOpen(req) {
1157
1212
  let body;
1158
1213
  try {
@@ -1190,10 +1245,33 @@ function match(pathname, url) {
1190
1245
  }
1191
1246
  return Response.json({ error: "Not found" }, { status: 404 });
1192
1247
  }
1248
+ function findDashboardDir() {
1249
+ const candidates = [
1250
+ join3(import.meta.dir, "dashboard"),
1251
+ join3(import.meta.dir, "..", "dashboard")
1252
+ ];
1253
+ for (const dir of candidates) {
1254
+ if (existsSync3(join3(dir, "index.html")))
1255
+ return dir;
1256
+ }
1257
+ return null;
1258
+ }
1259
+ async function serveDashboard(pathname) {
1260
+ if (!DASHBOARD_DIR)
1261
+ return null;
1262
+ const requested = pathname === "/" ? "/index.html" : pathname;
1263
+ const filePath = join3(DASHBOARD_DIR, requested);
1264
+ if (!filePath.startsWith(DASHBOARD_DIR))
1265
+ return null;
1266
+ const file = Bun.file(filePath);
1267
+ if (await file.exists())
1268
+ return new Response(file);
1269
+ return new Response(Bun.file(join3(DASHBOARD_DIR, "index.html")));
1270
+ }
1193
1271
  function startServer(port = PORT) {
1194
1272
  const server = Bun.serve({
1195
1273
  port,
1196
- fetch(req) {
1274
+ async fetch(req) {
1197
1275
  const url = new URL(req.url);
1198
1276
  if (req.method === "OPTIONS") {
1199
1277
  return new Response(null, {
@@ -1205,76 +1283,97 @@ function startServer(port = PORT) {
1205
1283
  });
1206
1284
  }
1207
1285
  if (req.method === "POST" && url.pathname === "/api/open") {
1208
- return handleOpen(req).then((r) => {
1209
- r.headers.set("Access-Control-Allow-Origin", "*");
1210
- return r;
1211
- });
1286
+ const r = await handleOpen(req);
1287
+ r.headers.set("Access-Control-Allow-Origin", "*");
1288
+ return r;
1289
+ }
1290
+ if (req.method === "POST") {
1291
+ const archiveMatch = /^\/api\/sessions\/([^/]+)\/archive$/.exec(url.pathname);
1292
+ if (archiveMatch) {
1293
+ const response2 = archiveResponse(archiveSession(archiveMatch[1]));
1294
+ response2.headers.set("Access-Control-Allow-Origin", "*");
1295
+ return response2;
1296
+ }
1297
+ const unarchiveMatch = /^\/api\/sessions\/([^/]+)\/unarchive$/.exec(url.pathname);
1298
+ if (unarchiveMatch) {
1299
+ const response2 = archiveResponse(unarchiveSession(unarchiveMatch[1]));
1300
+ response2.headers.set("Access-Control-Allow-Origin", "*");
1301
+ return response2;
1302
+ }
1303
+ }
1304
+ if (url.pathname.startsWith("/api/")) {
1305
+ const response2 = match(url.pathname, url);
1306
+ response2.headers.set("Access-Control-Allow-Origin", "*");
1307
+ return response2;
1212
1308
  }
1309
+ const dashboardResponse = await serveDashboard(url.pathname);
1310
+ if (dashboardResponse)
1311
+ return dashboardResponse;
1213
1312
  const response = match(url.pathname, url);
1214
1313
  response.headers.set("Access-Control-Allow-Origin", "*");
1215
1314
  return response;
1216
1315
  }
1217
1316
  });
1218
- console.log(`bertrand API server listening on http://localhost:${server.port}`);
1317
+ const dashboardNote = DASHBOARD_DIR ? " (with bundled dashboard)" : "";
1318
+ console.log(`bertrand API server listening on http://localhost:${server.port}${dashboardNote}`);
1219
1319
  return server;
1220
1320
  }
1221
- var PORT, routes;
1321
+ var PORT, listSessions = (_params, url) => {
1322
+ const excludeArchived = url.searchParams.get("excludeArchived") !== "false";
1323
+ return getAllSessions({ excludeArchived });
1324
+ }, getSessionById = ({ id }) => getSession(id), listEvents = ({ sessionId }, url) => {
1325
+ const eventType = url.searchParams.get("type");
1326
+ if (eventType)
1327
+ return getEventsByType(sessionId, eventType);
1328
+ return getEventsBySession(sessionId);
1329
+ }, listAllStats = () => {
1330
+ const result = {};
1331
+ for (const { session } of getAllSessions()) {
1332
+ const isLive = session.status === "active" || session.status === "waiting";
1333
+ if (isLive) {
1334
+ result[session.id] = liveStats(session.id);
1335
+ continue;
1336
+ }
1337
+ result[session.id] = getSessionStats(session.id) ?? liveStats(session.id);
1338
+ }
1339
+ return result;
1340
+ }, getStatsBySession = ({
1341
+ sessionId
1342
+ }) => {
1343
+ const session = getSession(sessionId);
1344
+ if (!session)
1345
+ return null;
1346
+ const isLive = session.status === "active" || session.status === "waiting";
1347
+ if (isLive)
1348
+ return liveStats(sessionId);
1349
+ return getSessionStats(sessionId) ?? liveStats(sessionId);
1350
+ }, getEngagement = ({
1351
+ sessionId
1352
+ }) => computeEngagementStats(sessionId), listRecaps = () => getLatestRecaps(), routes, ARCHIVE_ERROR, DASHBOARD_DIR;
1222
1353
  var init_server = __esm(() => {
1223
1354
  init_sessions();
1224
1355
  init_events();
1225
1356
  init_stats();
1226
1357
  init_timing();
1227
1358
  init_engagement_stats();
1359
+ init_session_archive();
1228
1360
  PORT = Number(process.env.BERTRAND_PORT ?? 5200);
1229
1361
  routes = [
1230
- [/^\/api\/sessions$/, (_params, url) => {
1231
- const excludeArchived = url.searchParams.get("excludeArchived") !== "false";
1232
- return getAllSessions({ excludeArchived });
1233
- }],
1234
- [/^\/api\/sessions\/(?<id>[^/]+)$/, ({ id }) => {
1235
- return getSession(id);
1236
- }],
1237
- [/^\/api\/events\/(?<sessionId>[^/]+)$/, ({ sessionId }, url) => {
1238
- const eventType = url.searchParams.get("type");
1239
- if (eventType)
1240
- return getEventsByType(sessionId, eventType);
1241
- return getEventsBySession(sessionId);
1242
- }],
1243
- [/^\/api\/stats$/, () => {
1244
- const all = getAllSessions();
1245
- const now = new Date().toISOString();
1246
- const result = {};
1247
- for (const { session } of all) {
1248
- const isLive = session.status === "active" || session.status === "waiting";
1249
- if (isLive) {
1250
- result[session.id] = { sessionId: session.id, ...computeSessionStats(session.id), updatedAt: now };
1251
- continue;
1252
- }
1253
- const stored = getSessionStats(session.id);
1254
- result[session.id] = stored ?? { sessionId: session.id, ...computeSessionStats(session.id), updatedAt: now };
1255
- }
1256
- return result;
1257
- }],
1258
- [/^\/api\/stats\/(?<sessionId>[^/]+)$/, ({ sessionId }) => {
1259
- const session = getSession(sessionId);
1260
- if (!session)
1261
- return null;
1262
- const isLive = session.status === "active" || session.status === "waiting";
1263
- if (isLive) {
1264
- return { sessionId, ...computeSessionStats(sessionId), updatedAt: new Date().toISOString() };
1265
- }
1266
- const stored = getSessionStats(sessionId);
1267
- if (stored)
1268
- return stored;
1269
- return { sessionId, ...computeSessionStats(sessionId), updatedAt: new Date().toISOString() };
1270
- }],
1271
- [/^\/api\/engagement\/(?<sessionId>[^/]+)$/, ({ sessionId }) => {
1272
- return computeEngagementStats(sessionId);
1273
- }],
1274
- [/^\/api\/recaps$/, () => {
1275
- return getLatestRecaps();
1276
- }]
1362
+ [/^\/api\/sessions$/, listSessions],
1363
+ [/^\/api\/sessions\/(?<id>[^/]+)$/, getSessionById],
1364
+ [/^\/api\/events\/(?<sessionId>[^/]+)$/, listEvents],
1365
+ [/^\/api\/stats$/, listAllStats],
1366
+ [/^\/api\/stats\/(?<sessionId>[^/]+)$/, getStatsBySession],
1367
+ [/^\/api\/engagement\/(?<sessionId>[^/]+)$/, getEngagement],
1368
+ [/^\/api\/recaps$/, listRecaps]
1277
1369
  ];
1370
+ ARCHIVE_ERROR = {
1371
+ "not-found": { status: 404, message: "Session not found" },
1372
+ active: { status: 409, message: "Cannot archive an active session" },
1373
+ "already-archived": { status: 409, message: "Session is already archived" },
1374
+ "not-archived": { status: 409, message: "Session is not archived" }
1375
+ };
1376
+ DASHBOARD_DIR = findDashboardDir();
1278
1377
  });
1279
1378
 
1280
1379
  // src/cli/commands/serve.ts
@@ -1361,16 +1460,10 @@ var init_labels = __esm(() => {
1361
1460
  // src/contract/template.md
1362
1461
  var template_default = `You are running inside bertrand, session: {sessionName}. Follow these rules strictly:
1363
1462
 
1364
- At session start, run: ToolSearch with query "select:AskUserQuestion" to load the tool.
1365
-
1366
- After every response, you MUST call AskUserQuestion. This is a continuous loop \u2014 every turn ends with AskUserQuestion. Always include a "Done for now" option so the user can exit the loop when ready. 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.
1463
+ 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.
1367
1464
 
1368
1465
  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.
1369
1466
 
1370
- Each question must encapsulate the moment \u2014 name the specific decision, tradeoff, or next step being asked about. Avoid generic phrasing like "What next?", "How should we proceed?", or "What would you like to do?". A reader who sees only the question (no recap, no preceding text) should understand what is being decided. Prefer "Which gap should we tackle first \u2014 bundle analysis, end-to-end dry-run, or registry?" over "What's next?"
1371
-
1372
- Every option must be a concrete, actionable next step. No filler like "Have questions?" or "Want to learn more?" \u2014 if clarification is needed, phrase it as a specific action: "Discuss tradeoffs of X vs Y".
1373
-
1374
1467
  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.
1375
1468
  `;
1376
1469
  var init_template = () => {};
@@ -1379,25 +1472,14 @@ var init_template = () => {};
1379
1472
  function buildContract(sessionName, ...contextLayers) {
1380
1473
  const base = template_default.replace("{sessionName}", sessionName);
1381
1474
  const layers = contextLayers.map((c) => c.trim()).filter((c) => c.length > 0);
1382
- const middle = layers.length === 0 ? "" : `
1475
+ if (layers.length === 0)
1476
+ return base;
1477
+ return base + `
1383
1478
 
1384
1479
  ` + layers.join(`
1385
1480
 
1386
1481
  `);
1387
- return base + middle + `
1388
-
1389
- ` + TRAILING_CRITICAL_RULES;
1390
1482
  }
1391
- var TRAILING_CRITICAL_RULES = `## CRITICAL RULE \u2014 applies to every AskUserQuestion call
1392
-
1393
- Every AskUserQuestion call MUST set \`multiSelect: true\` on every question. No exceptions.
1394
-
1395
- The AskUserQuestion tool schema describes multiSelect as "allow multiple answers to be selected instead of just one." **Ignore that framing.** In bertrand, multiSelect is a UX-safety mechanism, not a cardinality signal \u2014 single-select submits on Enter the moment a question block gains focus, causing accidental selections. multiSelect requires explicit confirmation before submitting.
1396
-
1397
- Apply this rule even when options are mutually exclusive (e.g., "Which library: A, B, C, or D?"). The mechanic is about preventing accidental submissions, not about how many answers the user picks.
1398
-
1399
- WRONG: \`{ "multiSelect": false, "options": [...] }\`
1400
- RIGHT: \`{ "multiSelect": true, "options": [...] }\``;
1401
1483
  var init_template2 = __esm(() => {
1402
1484
  init_template();
1403
1485
  });
@@ -1600,6 +1682,92 @@ var init_spawn_context = __esm(() => {
1600
1682
  execFileAsync = promisify(execFile2);
1601
1683
  });
1602
1684
 
1685
+ // src/lib/server-lifecycle.ts
1686
+ import { spawn as spawn2 } from "child_process";
1687
+ import { readFileSync as readFileSync3, writeFileSync, unlinkSync } from "fs";
1688
+ import { join as join4 } from "path";
1689
+ function readPidFile() {
1690
+ try {
1691
+ const pid = Number(readFileSync3(deps.pidFile, "utf-8").trim());
1692
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
1693
+ } catch {
1694
+ return null;
1695
+ }
1696
+ }
1697
+ function isProcessAlive(pid) {
1698
+ try {
1699
+ process.kill(pid, 0);
1700
+ return true;
1701
+ } catch {
1702
+ return false;
1703
+ }
1704
+ }
1705
+ async function isPortListening(port) {
1706
+ try {
1707
+ await fetch(`http://localhost:${port}/api/sessions`, {
1708
+ signal: AbortSignal.timeout(500)
1709
+ });
1710
+ return true;
1711
+ } catch {
1712
+ return false;
1713
+ }
1714
+ }
1715
+ function removePidFile() {
1716
+ try {
1717
+ unlinkSync(deps.pidFile);
1718
+ } catch {}
1719
+ }
1720
+ async function ensureServerStarted() {
1721
+ const existingPid = readPidFile();
1722
+ if (existingPid && isProcessAlive(existingPid))
1723
+ return;
1724
+ if (existingPid)
1725
+ removePidFile();
1726
+ if (await isPortListening(deps.port))
1727
+ return;
1728
+ const bin = deps.resolveBin();
1729
+ if (!bin)
1730
+ return;
1731
+ const child = spawn2(bin, ["serve"], {
1732
+ detached: true,
1733
+ stdio: "ignore",
1734
+ env: { ...process.env, BERTRAND_PORT: String(deps.port) }
1735
+ });
1736
+ child.unref();
1737
+ if (child.pid)
1738
+ writeFileSync(deps.pidFile, String(child.pid));
1739
+ }
1740
+ function stopServerIfIdle() {
1741
+ if (deps.getActiveCount() > 0)
1742
+ return;
1743
+ const pid = readPidFile();
1744
+ if (!pid)
1745
+ return;
1746
+ try {
1747
+ process.kill(pid, "SIGTERM");
1748
+ } catch {}
1749
+ removePidFile();
1750
+ }
1751
+ var defaultDeps, deps;
1752
+ var init_server_lifecycle = __esm(() => {
1753
+ init_paths();
1754
+ init_sessions();
1755
+ defaultDeps = {
1756
+ pidFile: join4(paths.root, "server.pid"),
1757
+ port: Number(process.env.BERTRAND_PORT ?? 5200),
1758
+ resolveBin() {
1759
+ try {
1760
+ const config = JSON.parse(readFileSync3(join4(paths.root, "config.json"), "utf-8"));
1761
+ return typeof config?.bin === "string" ? config.bin : null;
1762
+ } catch {
1763
+ return null;
1764
+ }
1765
+ },
1766
+ getActiveCount: () => getActiveSessions().length
1767
+ };
1768
+ deps = defaultDeps;
1769
+ });
1770
+
1603
1771
  // src/engine/session.ts
1604
1772
  import { randomUUID } from "crypto";
1605
1773
  async function launch(opts) {
@@ -1626,6 +1794,7 @@ async function launch(opts) {
1626
1794
  sessionId: session.id
1627
1795
  });
1628
1796
  updateSession(session.id, { status: "active", pid: process.pid });
1797
+ await ensureServerStarted();
1629
1798
  const spawnContext = await captureSpawnContext();
1630
1799
  const sessionName = `${opts.groupPath}/${opts.slug}`;
1631
1800
  insertEvent({
@@ -1671,6 +1840,7 @@ async function resume(opts) {
1671
1840
  const group = getGroup(session.groupId);
1672
1841
  const sessionName = group ? `${group.path}/${session.slug}` : session.name;
1673
1842
  updateSession(session.id, { status: "active", pid: process.pid });
1843
+ await ensureServerStarted();
1674
1844
  insertEvent({
1675
1845
  sessionId: session.id,
1676
1846
  conversationId: opts.conversationId,
@@ -1715,6 +1885,7 @@ function finalizeSession(sessionId, conversationId, exitCode) {
1715
1885
  event: "session.end"
1716
1886
  });
1717
1887
  computeAndPersist(sessionId);
1888
+ stopServerIfIdle();
1718
1889
  }
1719
1890
  var init_session = __esm(() => {
1720
1891
  init_sessions();
@@ -1727,16 +1898,17 @@ var init_session = __esm(() => {
1727
1898
  init_process();
1728
1899
  init_spawn_context();
1729
1900
  init_timing();
1901
+ init_server_lifecycle();
1730
1902
  });
1731
1903
 
1732
1904
  // src/tui/app.tsx
1733
- import { spawn as spawn2 } from "child_process";
1734
- import { existsSync as existsSync3, readFileSync as readFileSync3, unlinkSync } from "fs";
1735
- import { join as join3 } from "path";
1905
+ import { spawn as spawn3 } from "child_process";
1906
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
1907
+ import { join as join5 } from "path";
1736
1908
  import { randomUUID as randomUUID2 } from "crypto";
1737
1909
  async function runScreen(screen, ...args) {
1738
1910
  const tmpFile = `/tmp/bertrand-tui-${process.pid}-${Date.now()}.json`;
1739
- const child = spawn2("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
1911
+ const child = spawn3("bun", ["run", SCREEN_ENTRY, screen, tmpFile, ...args], {
1740
1912
  stdio: "inherit"
1741
1913
  });
1742
1914
  const exitCode = await new Promise((resolve) => {
@@ -1745,8 +1917,8 @@ async function runScreen(screen, ...args) {
1745
1917
  if (exitCode !== 0) {
1746
1918
  throw new Error(`TUI screen "${screen}" exited with code ${exitCode}`);
1747
1919
  }
1748
- const result = JSON.parse(readFileSync3(tmpFile, "utf-8"));
1749
- unlinkSync(tmpFile);
1920
+ const result = JSON.parse(readFileSync4(tmpFile, "utf-8"));
1921
+ unlinkSync2(tmpFile);
1750
1922
  return result;
1751
1923
  }
1752
1924
  async function startLaunchTui() {
@@ -1785,7 +1957,7 @@ async function runSessionLoop(sessionId) {
1785
1957
  case "save":
1786
1958
  break;
1787
1959
  case "archive":
1788
- updateSessionStatus(sessionId, "archived");
1960
+ archiveSession(sessionId);
1789
1961
  break;
1790
1962
  case "discard":
1791
1963
  deleteSession(sessionId);
@@ -1810,11 +1982,6 @@ async function startTui() {
1810
1982
  await runSessionLoop(sessionId);
1811
1983
  break;
1812
1984
  }
1813
- case "resume": {
1814
- const sessionId = await resume(selection);
1815
- await runSessionLoop(sessionId);
1816
- break;
1817
- }
1818
1985
  case "pick": {
1819
1986
  const conversationId = await resolveConversationForResume(selection.sessionId);
1820
1987
  if (!conversationId)
@@ -1832,10 +1999,11 @@ var SCREEN_ENTRY;
1832
1999
  var init_app = __esm(() => {
1833
2000
  init_sessions();
1834
2001
  init_conversations();
2002
+ init_session_archive();
1835
2003
  init_session();
1836
2004
  SCREEN_ENTRY = (() => {
1837
- const built = join3(import.meta.dir, "run-screen.js");
1838
- return existsSync3(built) ? built : join3(import.meta.dir, "run-screen.tsx");
2005
+ const built = join5(import.meta.dir, "run-screen.js");
2006
+ return existsSync4(built) ? built : join5(import.meta.dir, "run-screen.tsx");
1839
2007
  })();
1840
2008
  });
1841
2009
 
@@ -1860,7 +2028,7 @@ function parseSessionName(input) {
1860
2028
  }
1861
2029
 
1862
2030
  // src/engine/recovery.ts
1863
- function isProcessAlive(pid) {
2031
+ function isProcessAlive2(pid) {
1864
2032
  try {
1865
2033
  process.kill(pid, 0);
1866
2034
  return true;
@@ -1872,7 +2040,7 @@ function recoverStaleSessions() {
1872
2040
  const active = getActiveSessions();
1873
2041
  let recovered = 0;
1874
2042
  for (const { session } of active) {
1875
- if (session.pid && !isProcessAlive(session.pid)) {
2043
+ if (session.pid && !isProcessAlive2(session.pid)) {
1876
2044
  updateSession(session.id, {
1877
2045
  status: "paused",
1878
2046
  pid: null
@@ -1939,11 +2107,21 @@ var init_migrate = __esm(() => {
1939
2107
  function waitingScript(bin) {
1940
2108
  const BIN = bin;
1941
2109
  return `#!/usr/bin/env bash
1942
- # Hook: PreToolUse AskUserQuestion \u2192 mark session as waiting
2110
+ # Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
1943
2111
  sid="\${BERTRAND_SESSION:-}"
1944
2112
  [ -z "$sid" ] && exit 0
1945
2113
 
1946
2114
  input="$(cat)"
2115
+
2116
+ # Block AUQ calls that omit multiSelect:true on any question. multiSelect is a
2117
+ # UX-safety mechanism in bertrand (prevents submit-on-focus), not a cardinality
2118
+ # signal. Enforce mechanically so the rule sticks in subagent / job contexts
2119
+ # where the system-prompt contract never reaches the agent.
2120
+ if printf '%s' "$input" | jq -e '.tool_input.questions[]? | select(.multiSelect != true)' > /dev/null 2>&1; then
2121
+ printf 'All AskUserQuestion questions must set multiSelect:true. This is a UX-safety mechanism in bertrand (prevents submit-on-focus when the question block gains focus), not a cardinality signal. Retry with multiSelect:true on every question.\\n' >&2
2122
+ exit 2
2123
+ fi
2124
+
1947
2125
  cid="\${BERTRAND_CLAUDE_ID:-}"
1948
2126
 
1949
2127
  # Extract question \u2014 grep for simple field extraction (~1ms vs jq ~15ms)
@@ -2198,13 +2376,13 @@ var init_scripts = __esm(() => {
2198
2376
  });
2199
2377
 
2200
2378
  // src/hooks/install.ts
2201
- import { mkdirSync as mkdirSync3, writeFileSync, chmodSync } from "fs";
2202
- import { join as join4 } from "path";
2379
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, chmodSync } from "fs";
2380
+ import { join as join6 } from "path";
2203
2381
  function installHookScripts(bin) {
2204
2382
  mkdirSync3(paths.hooks, { recursive: true });
2205
2383
  for (const [filename, scriptFn] of Object.entries(HOOK_SCRIPTS)) {
2206
- const filePath = join4(paths.hooks, filename);
2207
- writeFileSync(filePath, scriptFn(bin));
2384
+ const filePath = join6(paths.hooks, filename);
2385
+ writeFileSync2(filePath, scriptFn(bin));
2208
2386
  chmodSync(filePath, 493);
2209
2387
  }
2210
2388
  console.log(`Installed ${Object.keys(HOOK_SCRIPTS).length} hook scripts to ${paths.hooks}`);
@@ -2215,8 +2393,8 @@ var init_install = __esm(() => {
2215
2393
  });
2216
2394
 
2217
2395
  // src/hooks/settings.ts
2218
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync4 } from "fs";
2219
- import { join as join5, dirname as dirname3 } from "path";
2396
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
2397
+ import { join as join7, dirname as dirname3 } from "path";
2220
2398
  import { homedir as homedir2 } from "os";
2221
2399
  function isBertrandGroup(group) {
2222
2400
  return group.hooks?.some((h) => h.command?.includes(".bertrand/hooks/")) ?? false;
@@ -2224,7 +2402,7 @@ function isBertrandGroup(group) {
2224
2402
  function installHookSettings() {
2225
2403
  let settings = {};
2226
2404
  try {
2227
- settings = JSON.parse(readFileSync4(SETTINGS_PATH, "utf-8"));
2405
+ settings = JSON.parse(readFileSync5(SETTINGS_PATH, "utf-8"));
2228
2406
  } catch {}
2229
2407
  const existingHooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
2230
2408
  const merged = { ...existingHooks };
@@ -2234,14 +2412,14 @@ function installHookSettings() {
2234
2412
  }
2235
2413
  settings.hooks = merged;
2236
2414
  mkdirSync4(dirname3(SETTINGS_PATH), { recursive: true });
2237
- writeFileSync2(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
2415
+ writeFileSync3(SETTINGS_PATH, JSON.stringify(settings, null, 2) + `
2238
2416
  `);
2239
2417
  console.log(`Updated ${SETTINGS_PATH} with bertrand hooks`);
2240
2418
  }
2241
2419
  var SETTINGS_PATH, BERTRAND_HOOKS;
2242
2420
  var init_settings = __esm(() => {
2243
2421
  init_paths();
2244
- SETTINGS_PATH = join5(homedir2(), ".claude", "settings.json");
2422
+ SETTINGS_PATH = join7(homedir2(), ".claude", "settings.json");
2245
2423
  BERTRAND_HOOKS = {
2246
2424
  PreToolUse: [
2247
2425
  {
@@ -2285,8 +2463,8 @@ var init_settings = __esm(() => {
2285
2463
  });
2286
2464
 
2287
2465
  // src/lib/completions.ts
2288
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync5 } from "fs";
2289
- import { join as join6 } from "path";
2466
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync5 } from "fs";
2467
+ import { join as join8 } from "path";
2290
2468
  function bashCompletion() {
2291
2469
  return `# bertrand bash completion
2292
2470
  _bertrand() {
@@ -2316,11 +2494,11 @@ function fishCompletion() {
2316
2494
  `;
2317
2495
  }
2318
2496
  function generateCompletions() {
2319
- const dir = join6(paths.root, "completions");
2497
+ const dir = join8(paths.root, "completions");
2320
2498
  mkdirSync5(dir, { recursive: true });
2321
- writeFileSync3(join6(dir, "bertrand.bash"), bashCompletion());
2322
- writeFileSync3(join6(dir, "_bertrand"), zshCompletion());
2323
- writeFileSync3(join6(dir, "bertrand.fish"), fishCompletion());
2499
+ writeFileSync4(join8(dir, "bertrand.bash"), bashCompletion());
2500
+ writeFileSync4(join8(dir, "_bertrand"), zshCompletion());
2501
+ writeFileSync4(join8(dir, "bertrand.fish"), fishCompletion());
2324
2502
  console.log(`Shell completions written to ${dir}`);
2325
2503
  console.log(" Add to your shell config:");
2326
2504
  console.log(` bash: source ${dir}/bertrand.bash`);
@@ -2347,9 +2525,9 @@ var init_completions = __esm(() => {
2347
2525
 
2348
2526
  // src/cli/commands/init.ts
2349
2527
  var exports_init = {};
2350
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4, chmodSync as chmodSync2 } from "fs";
2528
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5, chmodSync as chmodSync2 } from "fs";
2351
2529
  import { execSync as execSync2 } from "child_process";
2352
- import { join as join7 } from "path";
2530
+ import { join as join9 } from "path";
2353
2531
  function detectTerminal() {
2354
2532
  try {
2355
2533
  execSync2("which wsh", { stdio: "ignore" });
@@ -2359,12 +2537,12 @@ function detectTerminal() {
2359
2537
  }
2360
2538
  }
2361
2539
  function writeConfig(config) {
2362
- writeFileSync4(CONFIG_PATH, JSON.stringify(config, null, 2) + `
2540
+ writeFileSync5(CONFIG_PATH, JSON.stringify(config, null, 2) + `
2363
2541
  `);
2364
2542
  }
2365
2543
  function readConfig() {
2366
2544
  try {
2367
- return JSON.parse(readFileSync5(CONFIG_PATH, "utf-8"));
2545
+ return JSON.parse(readFileSync6(CONFIG_PATH, "utf-8"));
2368
2546
  } catch {
2369
2547
  return null;
2370
2548
  }
@@ -2375,10 +2553,10 @@ function resolveBin() {
2375
2553
  return onPath;
2376
2554
  const entry = process.argv[1];
2377
2555
  if (entry && SOURCE_ENTRY.test(entry)) {
2378
- const launcherDir = join7(paths.root, "bin");
2379
- const launcher = join7(launcherDir, "bertrand-dev");
2556
+ const launcherDir = join9(paths.root, "bin");
2557
+ const launcher = join9(launcherDir, "bertrand-dev");
2380
2558
  mkdirSync6(launcherDir, { recursive: true });
2381
- writeFileSync4(launcher, `#!/usr/bin/env bash
2559
+ writeFileSync5(launcher, `#!/usr/bin/env bash
2382
2560
  exec ${process.execPath} ${JSON.stringify(entry)} "$@"
2383
2561
  `);
2384
2562
  chmodSync2(launcher, 493);
@@ -2394,7 +2572,7 @@ var init_init = __esm(() => {
2394
2572
  init_settings();
2395
2573
  init_paths();
2396
2574
  init_completions();
2397
- CONFIG_PATH = join7(paths.root, "config.json");
2575
+ CONFIG_PATH = join9(paths.root, "config.json");
2398
2576
  SOURCE_ENTRY = /\/src\/index\.tsx?$/;
2399
2577
  register("init", async () => {
2400
2578
  console.log(`bertrand init
@@ -2984,7 +3162,7 @@ function dur(seconds) {
2984
3162
  function renderGlobal(metrics, isJson) {
2985
3163
  const totals = metrics.reduce((acc, m) => ({
2986
3164
  sessions: acc.sessions + 1,
2987
- active: acc.active + (ACTIVE_STATUSES.includes(m.status) ? 1 : 0),
3165
+ active: acc.active + (ACTIVE_STATUSES2.includes(m.status) ? 1 : 0),
2988
3166
  durationS: acc.durationS + m.durationS,
2989
3167
  claudeWorkS: acc.claudeWorkS + m.claudeWorkS,
2990
3168
  userWaitS: acc.userWaitS + m.userWaitS,
@@ -3057,7 +3235,7 @@ function renderSession(m, isJson) {
3057
3235
  console.log(` Interactions: ${m.interactionCount}`);
3058
3236
  console.log(` PRs: ${m.prCount}`);
3059
3237
  }
3060
- var ACTIVE_STATUSES;
3238
+ var ACTIVE_STATUSES2;
3061
3239
  var init_stats2 = __esm(() => {
3062
3240
  init_router();
3063
3241
  init_sessions();
@@ -3066,7 +3244,7 @@ var init_stats2 = __esm(() => {
3066
3244
  init_events();
3067
3245
  init_timing();
3068
3246
  init_format();
3069
- ACTIVE_STATUSES = ["active", "waiting"];
3247
+ ACTIVE_STATUSES2 = ["active", "waiting"];
3070
3248
  register("stats", async (args) => {
3071
3249
  const isJson = args.includes("--json");
3072
3250
  const filteredArgs = args.filter((a) => !a.startsWith("--"));
@@ -3139,32 +3317,27 @@ function resolveSession(name) {
3139
3317
  }
3140
3318
  return { session, groupPath };
3141
3319
  }
3142
- var ACTIVE_STATUSES2;
3143
3320
  var init_archive = __esm(() => {
3144
3321
  init_router();
3145
3322
  init_sessions();
3146
3323
  init_groups();
3147
- ACTIVE_STATUSES2 = ["active", "waiting"];
3324
+ init_session_archive();
3148
3325
  register("archive", async (args) => {
3149
3326
  const isUndo = args.includes("--undo");
3150
3327
  const isAllPaused = args.includes("--all-paused");
3151
3328
  const filteredArgs = args.filter((a) => !a.startsWith("--"));
3152
3329
  const sessionName = filteredArgs[0];
3153
3330
  if (isAllPaused) {
3154
- const rows = getAllSessions({ excludeArchived: true });
3155
- const paused = rows.filter((r) => r.session.status === "paused");
3156
- if (paused.length === 0) {
3331
+ const { archived } = archiveAllPaused();
3332
+ if (archived.length === 0) {
3157
3333
  console.log("No paused sessions to archive.");
3158
3334
  return;
3159
3335
  }
3160
- let archived = 0;
3161
- for (const row of paused) {
3162
- updateSessionStatus(row.session.id, "archived");
3163
- console.log(` archived ${row.groupPath}/${row.session.slug}`);
3164
- archived++;
3336
+ for (const { session: session2, groupPath: groupPath2 } of archived) {
3337
+ console.log(` archived ${groupPath2}/${session2.slug}`);
3165
3338
  }
3166
3339
  console.log(`
3167
- Archived ${archived} session${archived === 1 ? "" : "s"}.`);
3340
+ Archived ${archived.length} session${archived.length === 1 ? "" : "s"}.`);
3168
3341
  return;
3169
3342
  }
3170
3343
  if (!sessionName) {
@@ -3176,23 +3349,33 @@ Archived ${archived} session${archived === 1 ? "" : "s"}.`);
3176
3349
  const { session, groupPath } = resolveSession(sessionName);
3177
3350
  const fullName = `${groupPath}/${session.slug}`;
3178
3351
  if (isUndo) {
3179
- if (session.status !== "archived") {
3180
- console.error(`${fullName} is not archived (status: ${session.status})`);
3352
+ const result2 = unarchiveSession(session.id);
3353
+ if (!result2.ok) {
3354
+ if (result2.reason === "not-archived") {
3355
+ console.error(`${fullName} is not archived (status: ${session.status})`);
3356
+ } else {
3357
+ console.error(`Session not found: ${fullName}`);
3358
+ }
3181
3359
  process.exit(1);
3182
3360
  }
3183
- updateSessionStatus(session.id, "paused");
3184
3361
  console.log(`Unarchived ${fullName}`);
3185
3362
  return;
3186
3363
  }
3187
- if (ACTIVE_STATUSES2.includes(session.status)) {
3188
- console.error(`Cannot archive active session ${fullName} (status: ${session.status})`);
3189
- process.exit(1);
3190
- }
3191
- if (session.status === "archived") {
3192
- console.error(`${fullName} is already archived`);
3364
+ const result = archiveSession(session.id);
3365
+ if (!result.ok) {
3366
+ switch (result.reason) {
3367
+ case "active":
3368
+ console.error(`Cannot archive active session ${fullName} (status: ${session.status})`);
3369
+ break;
3370
+ case "already-archived":
3371
+ console.error(`${fullName} is already archived`);
3372
+ break;
3373
+ case "not-found":
3374
+ console.error(`Session not found: ${fullName}`);
3375
+ break;
3376
+ }
3193
3377
  process.exit(1);
3194
3378
  }
3195
- updateSessionStatus(session.id, "archived");
3196
3379
  console.log(`Archived ${fullName}`);
3197
3380
  });
3198
3381
  });