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 +322 -139
- package/dist/dashboard/assets/bash-Yzrsuije.js +1 -0
- package/dist/dashboard/assets/css-DPfMkruS.js +1 -0
- package/dist/dashboard/assets/html-GMplVEZG.js +1 -0
- package/dist/dashboard/assets/index-e53khqNa.js +673 -0
- package/dist/dashboard/assets/index-qVHXGTGL.css +1 -0
- package/dist/dashboard/assets/javascript-wDzz0qaB.js +1 -0
- package/dist/dashboard/assets/json-Cp-IABpG.js +1 -0
- package/dist/dashboard/assets/jsx-g9-lgVsj.js +1 -0
- package/dist/dashboard/assets/markdown-Cvjx9yec.js +1 -0
- package/dist/dashboard/assets/python-B6aJPvgy.js +1 -0
- package/dist/dashboard/assets/tsx-COt5Ahok.js +1 -0
- package/dist/dashboard/assets/typescript-BPQ3VLAy.js +1 -0
- package/dist/dashboard/assets/vitesse-black-Bkuqu6BP.js +1 -0
- package/dist/dashboard/assets/vitesse-light-CVO1_9PV.js +1 -0
- package/dist/dashboard/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
- package/dist/dashboard/icon.svg +13 -0
- package/dist/dashboard/index.html +28 -0
- package/dist/dashboard/manifest.webmanifest +1 -0
- package/dist/dashboard/sw.js +1 -0
- package/dist/dashboard/workbox-e4022e15.js +1 -0
- package/dist/run-screen.js +357 -285
- package/package.json +1 -1
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
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,
|
|
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$/,
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
[/^\/api\/
|
|
1235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1734
|
-
import { existsSync as
|
|
1735
|
-
import { join as
|
|
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 =
|
|
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(
|
|
1749
|
-
|
|
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
|
-
|
|
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 =
|
|
1838
|
-
return
|
|
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
|
|
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 && !
|
|
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
|
|
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 =
|
|
2207
|
-
|
|
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
|
|
2219
|
-
import { join as
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2289
|
-
import { join as
|
|
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 =
|
|
2497
|
+
const dir = join8(paths.root, "completions");
|
|
2320
2498
|
mkdirSync5(dir, { recursive: true });
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2540
|
+
writeFileSync5(CONFIG_PATH, JSON.stringify(config, null, 2) + `
|
|
2363
2541
|
`);
|
|
2364
2542
|
}
|
|
2365
2543
|
function readConfig() {
|
|
2366
2544
|
try {
|
|
2367
|
-
return JSON.parse(
|
|
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 =
|
|
2379
|
-
const launcher =
|
|
2556
|
+
const launcherDir = join9(paths.root, "bin");
|
|
2557
|
+
const launcher = join9(launcherDir, "bertrand-dev");
|
|
2380
2558
|
mkdirSync6(launcherDir, { recursive: true });
|
|
2381
|
-
|
|
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 =
|
|
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 + (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3155
|
-
|
|
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
|
-
|
|
3161
|
-
|
|
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
|
-
|
|
3180
|
-
|
|
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
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
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
|
});
|