bertrand 0.19.0 → 0.20.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
@@ -752,6 +752,7 @@ function openDb(dbPath) {
752
752
  return cached;
753
753
  mkdirSync4(dirname(dbPath), { recursive: true });
754
754
  const sqlite = new Database(dbPath);
755
+ sqlite.exec("PRAGMA busy_timeout = 5000");
755
756
  sqlite.exec("PRAGMA journal_mode = WAL");
756
757
  sqlite.exec("PRAGMA foreign_keys = ON");
757
758
  sqlite.exec("PRAGMA synchronous = NORMAL");
@@ -761,6 +762,10 @@ function openDb(dbPath) {
761
762
  if (!_migrated.has(dbPath)) {
762
763
  try {
763
764
  migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
765
+ if (!hasSessionsTable(sqlite)) {
766
+ sqlite.exec("DROP TABLE IF EXISTS __drizzle_migrations");
767
+ migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
768
+ }
764
769
  } catch (err) {
765
770
  sqlite.close();
766
771
  throw err;
@@ -770,6 +775,10 @@ function openDb(dbPath) {
770
775
  _cache.set(dbPath, db);
771
776
  return db;
772
777
  }
778
+ function hasSessionsTable(sqlite) {
779
+ const row = sqlite.query("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'").get();
780
+ return row !== null;
781
+ }
773
782
  var MIGRATIONS_FOLDER, _cache, _migrated, _testDb = null;
774
783
  var init_client = __esm(() => {
775
784
  init_resolve();
@@ -833,6 +842,28 @@ var init_sessions = __esm(() => {
833
842
  init_id();
834
843
  });
835
844
 
845
+ // src/db/queries/conversations.ts
846
+ import { eq as eq2, and as and2, desc, sql as sql3 } from "drizzle-orm";
847
+ function createConversation(opts) {
848
+ return getDb().insert(conversations).values(opts).returning().get();
849
+ }
850
+ function getConversation(id) {
851
+ return getDb().select().from(conversations).where(eq2(conversations.id, id)).get();
852
+ }
853
+ function getConversationsBySession(sessionId) {
854
+ return getDb().select().from(conversations).where(and2(eq2(conversations.sessionId, sessionId), eq2(conversations.discarded, false))).orderBy(desc(conversations.startedAt)).all();
855
+ }
856
+ function endConversation(id) {
857
+ return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq2(conversations.id, id)).returning().get();
858
+ }
859
+ function updateLastQuestion(id, question) {
860
+ return getDb().update(conversations).set({ lastQuestion: question }).where(eq2(conversations.id, id)).returning().get();
861
+ }
862
+ var init_conversations = __esm(() => {
863
+ init_client();
864
+ init_schema();
865
+ });
866
+
836
867
  // src/lib/markdown.ts
837
868
  function normalizeMarkdown(input) {
838
869
  return input.replace(/\r\n?/g, `
@@ -882,7 +913,7 @@ function normalizeAnsweredMeta(meta) {
882
913
  }
883
914
 
884
915
  // src/db/queries/events.ts
885
- import { eq as eq2, and as and2, desc } from "drizzle-orm";
916
+ import { eq as eq3, and as and3, desc as desc2 } from "drizzle-orm";
886
917
  function insertEvent(opts) {
887
918
  return getDb().insert(events).values({
888
919
  sessionId: opts.sessionId,
@@ -893,17 +924,27 @@ function insertEvent(opts) {
893
924
  }).returning().get();
894
925
  }
895
926
  function getEventsBySession(sessionId) {
896
- return getDb().select().from(events).where(eq2(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
927
+ return getDb().select().from(events).where(eq3(events.sessionId, sessionId)).orderBy(events.createdAt, events.id).all();
897
928
  }
898
929
  function getEventsByType(sessionId, eventType) {
899
- return getDb().select().from(events).where(and2(eq2(events.sessionId, sessionId), eq2(events.event, eventType))).orderBy(events.createdAt).all();
930
+ return getDb().select().from(events).where(and3(eq3(events.sessionId, sessionId), eq3(events.event, eventType))).orderBy(events.createdAt).all();
931
+ }
932
+ function getLatestEventOfType(sessionId, eventType, conversationId) {
933
+ const conditions = [
934
+ eq3(events.sessionId, sessionId),
935
+ eq3(events.event, eventType)
936
+ ];
937
+ if (conversationId) {
938
+ conditions.push(eq3(events.conversationId, conversationId));
939
+ }
940
+ return getDb().select().from(events).where(and3(...conditions)).orderBy(desc2(events.createdAt)).limit(1).get();
900
941
  }
901
942
  function getLatestRecaps() {
902
943
  const rows = getDb().select({
903
944
  sessionId: events.sessionId,
904
945
  meta: events.meta,
905
946
  createdAt: events.createdAt
906
- }).from(events).where(eq2(events.event, "session.recap")).orderBy(desc(events.createdAt)).all();
947
+ }).from(events).where(eq3(events.event, "session.recap")).orderBy(desc2(events.createdAt)).all();
907
948
  const result = {};
908
949
  for (const row of rows) {
909
950
  if (result[row.sessionId])
@@ -921,26 +962,231 @@ var init_events = __esm(() => {
921
962
  init_schema();
922
963
  });
923
964
 
924
- // src/db/queries/conversations.ts
925
- import { eq as eq3, and as and3, desc as desc2, sql as sql3 } from "drizzle-orm";
926
- function createConversation(opts) {
927
- return getDb().insert(conversations).values(opts).returning().get();
965
+ // src/db/events/emit.ts
966
+ function emitSessionStarted(args) {
967
+ return insertEvent({
968
+ sessionId: args.sessionId,
969
+ conversationId: args.conversationId,
970
+ event: "session.started",
971
+ meta: {
972
+ category_path: args.categoryPath,
973
+ session_name: args.sessionName,
974
+ session_slug: args.sessionSlug,
975
+ labels: args.labels,
976
+ summary: args.summary ?? null
977
+ }
978
+ });
928
979
  }
929
- function getConversation(id) {
930
- return getDb().select().from(conversations).where(eq3(conversations.id, id)).get();
980
+ function emitSessionResumed(args) {
981
+ return insertEvent({
982
+ sessionId: args.sessionId,
983
+ conversationId: args.conversationId,
984
+ event: "session.resumed",
985
+ meta: { claude_id: args.conversationId }
986
+ });
931
987
  }
932
- function getConversationsBySession(sessionId) {
933
- return getDb().select().from(conversations).where(and3(eq3(conversations.sessionId, sessionId), eq3(conversations.discarded, false))).orderBy(desc2(conversations.startedAt)).all();
988
+ function emitSessionPaused(args) {
989
+ return insertEvent({
990
+ sessionId: args.sessionId,
991
+ conversationId: args.conversationId,
992
+ event: "session.paused",
993
+ meta: { claude_id: args.conversationId }
994
+ });
934
995
  }
935
- function endConversation(id) {
936
- return getDb().update(conversations).set({ endedAt: sql3`(datetime('now'))` }).where(eq3(conversations.id, id)).returning().get();
996
+ function emitSessionPausedByRecovery(args) {
997
+ return insertEvent({
998
+ sessionId: args.sessionId,
999
+ event: "session.paused",
1000
+ summary: "Recovered from stale state (process not found)",
1001
+ meta: { stale_pid: args.stalePid }
1002
+ });
937
1003
  }
938
- function updateLastQuestion(id, question) {
939
- return getDb().update(conversations).set({ lastQuestion: question }).where(eq3(conversations.id, id)).returning().get();
1004
+ function emitSessionEnded(args) {
1005
+ return insertEvent({
1006
+ sessionId: args.sessionId,
1007
+ event: "session.end"
1008
+ });
940
1009
  }
941
- var init_conversations = __esm(() => {
942
- init_client();
943
- init_schema();
1010
+ function emitClaudeStarted(args) {
1011
+ return insertEvent({
1012
+ sessionId: args.sessionId,
1013
+ conversationId: args.conversationId,
1014
+ event: "claude.started",
1015
+ meta: {
1016
+ claude_id: args.conversationId,
1017
+ model: args.model,
1018
+ claude_version: args.claudeVersion,
1019
+ git: args.git,
1020
+ cwd: args.cwd
1021
+ }
1022
+ });
1023
+ }
1024
+ function emitClaudeEnded(args) {
1025
+ return insertEvent({
1026
+ sessionId: args.sessionId,
1027
+ conversationId: args.conversationId,
1028
+ event: "claude.ended",
1029
+ meta: { claude_id: args.conversationId, exit_code: args.exitCode }
1030
+ });
1031
+ }
1032
+ function emitUserPrompted(args) {
1033
+ return insertEvent({
1034
+ sessionId: args.sessionId,
1035
+ conversationId: args.conversationId,
1036
+ event: "user.prompt",
1037
+ meta: { prompt: args.prompt, claude_id: args.conversationId }
1038
+ });
1039
+ }
1040
+ function emitSessionWaiting(args) {
1041
+ return insertEvent({
1042
+ sessionId: args.sessionId,
1043
+ conversationId: args.conversationId,
1044
+ event: "session.waiting",
1045
+ summary: args.question,
1046
+ meta: { question: args.question, claude_id: args.conversationId }
1047
+ });
1048
+ }
1049
+ function emitSessionAnswered(args) {
1050
+ const joinedAnswers = Object.values(args.answers).map((v) => String(v)).join(", ") || undefined;
1051
+ return insertEvent({
1052
+ sessionId: args.sessionId,
1053
+ conversationId: args.conversationId,
1054
+ event: "session.answered",
1055
+ summary: joinedAnswers,
1056
+ meta: {
1057
+ answers: args.answers,
1058
+ annotations: args.annotations ?? {},
1059
+ questions: args.questions ?? [],
1060
+ claude_id: args.conversationId
1061
+ }
1062
+ });
1063
+ }
1064
+ function emitSessionRecap(args) {
1065
+ return insertEvent({
1066
+ sessionId: args.sessionId,
1067
+ conversationId: args.conversationId,
1068
+ event: "session.recap",
1069
+ summary: args.recap.slice(0, 200),
1070
+ meta: { recap: args.recap, claude_id: args.conversationId }
1071
+ });
1072
+ }
1073
+ function emitPermissionRequested(args) {
1074
+ return insertEvent({
1075
+ sessionId: args.sessionId,
1076
+ conversationId: args.conversationId,
1077
+ event: "permission.request",
1078
+ meta: {
1079
+ tool: args.tool,
1080
+ detail: args.detail,
1081
+ claude_id: args.conversationId
1082
+ }
1083
+ });
1084
+ }
1085
+ function emitPermissionResolved(args) {
1086
+ return insertEvent({
1087
+ sessionId: args.sessionId,
1088
+ conversationId: args.conversationId,
1089
+ event: "permission.resolve",
1090
+ meta: {
1091
+ tool: args.tool,
1092
+ detail: args.detail,
1093
+ outcome: args.outcome,
1094
+ claude_id: args.conversationId
1095
+ }
1096
+ });
1097
+ }
1098
+ function emitToolUsed(args) {
1099
+ const summary = formatToolSummary(args.tool, args.detail);
1100
+ return insertEvent({
1101
+ sessionId: args.sessionId,
1102
+ conversationId: args.conversationId,
1103
+ event: "tool.used",
1104
+ summary,
1105
+ meta: {
1106
+ tool: args.tool,
1107
+ detail: args.detail,
1108
+ outcome: args.outcome,
1109
+ claude_id: args.conversationId
1110
+ }
1111
+ });
1112
+ }
1113
+ function formatToolSummary(tool, detail) {
1114
+ if (!detail)
1115
+ return tool;
1116
+ switch (tool) {
1117
+ case "Bash":
1118
+ return `ran \`${detail.slice(0, 120)}\``;
1119
+ case "Read":
1120
+ return `read ${detail}`;
1121
+ case "Glob":
1122
+ case "Grep":
1123
+ return `${tool.toLowerCase()} ${detail}`;
1124
+ case "TodoWrite":
1125
+ return "updated todos";
1126
+ case "WebFetch":
1127
+ return `fetched ${detail}`;
1128
+ case "WebSearch":
1129
+ return `searched: ${detail}`;
1130
+ default:
1131
+ return `${tool}: ${detail}`;
1132
+ }
1133
+ }
1134
+ function emitToolApplied(args) {
1135
+ return insertEvent({
1136
+ sessionId: args.sessionId,
1137
+ conversationId: args.conversationId,
1138
+ event: "tool.applied",
1139
+ summary: args.summary,
1140
+ meta: {
1141
+ permissions: args.permissions,
1142
+ outcome: "applied",
1143
+ claude_id: args.conversationId
1144
+ }
1145
+ });
1146
+ }
1147
+ function emitAssistantMessage(args) {
1148
+ return insertEvent({
1149
+ sessionId: args.sessionId,
1150
+ conversationId: args.conversationId,
1151
+ event: "assistant.message",
1152
+ summary: args.summary,
1153
+ meta: {
1154
+ model: args.model,
1155
+ text: args.text,
1156
+ thinkingBlocks: args.thinkingBlocks,
1157
+ thinkingBytes: args.thinkingBytes,
1158
+ claude_id: args.conversationId
1159
+ }
1160
+ });
1161
+ }
1162
+ function emitAssistantRecap(args) {
1163
+ return insertEvent({
1164
+ sessionId: args.sessionId,
1165
+ conversationId: args.conversationId,
1166
+ event: "assistant.recap",
1167
+ summary: args.recap.length > 80 ? `${args.recap.slice(0, 77)}...` : args.recap,
1168
+ meta: { recap: args.recap, claude_id: args.conversationId }
1169
+ });
1170
+ }
1171
+ function emitContextSnapshot(args) {
1172
+ return insertEvent({
1173
+ sessionId: args.sessionId,
1174
+ conversationId: args.conversationId,
1175
+ event: "context.snapshot",
1176
+ summary: `${args.remainingPct}% remaining`,
1177
+ meta: {
1178
+ model: args.model,
1179
+ input_tokens: String(args.inputTokens),
1180
+ cache_creation_tokens: String(args.cacheCreationTokens),
1181
+ cache_read_tokens: String(args.cacheReadTokens),
1182
+ context_window_tokens: String(args.totalContextTokens),
1183
+ remaining_pct: String(args.remainingPct),
1184
+ claude_id: args.conversationId
1185
+ }
1186
+ });
1187
+ }
1188
+ var init_emit = __esm(() => {
1189
+ init_events();
944
1190
  });
945
1191
 
946
1192
  // src/cli/commands/update.ts
@@ -955,13 +1201,87 @@ function shouldIgnoreStatusFlip(newStatus, sessionPid) {
955
1201
  return false;
956
1202
  return sessionPid === null;
957
1203
  }
1204
+ function dispatchHookEvent(event, ctx) {
1205
+ const { sessionId, conversationId, meta, summary } = ctx;
1206
+ switch (event) {
1207
+ case "user.prompt":
1208
+ emitUserPrompted({
1209
+ sessionId,
1210
+ conversationId,
1211
+ prompt: String(meta.prompt ?? "")
1212
+ });
1213
+ return true;
1214
+ case "session.waiting":
1215
+ emitSessionWaiting({
1216
+ sessionId,
1217
+ conversationId,
1218
+ question: String(meta.question ?? "Waiting for input")
1219
+ });
1220
+ return true;
1221
+ case "session.answered":
1222
+ emitSessionAnswered({
1223
+ sessionId,
1224
+ conversationId,
1225
+ answers: meta.answers ?? {},
1226
+ annotations: meta.annotations ?? {},
1227
+ questions: meta.questions ?? []
1228
+ });
1229
+ return true;
1230
+ case "session.recap":
1231
+ emitSessionRecap({
1232
+ sessionId,
1233
+ conversationId,
1234
+ recap: String(meta.recap ?? "")
1235
+ });
1236
+ return true;
1237
+ case "session.paused":
1238
+ emitSessionPaused({ sessionId, conversationId });
1239
+ return true;
1240
+ case "permission.request":
1241
+ emitPermissionRequested({
1242
+ sessionId,
1243
+ conversationId,
1244
+ tool: String(meta.tool ?? ""),
1245
+ detail: String(meta.detail ?? "")
1246
+ });
1247
+ return true;
1248
+ case "permission.resolve":
1249
+ emitPermissionResolved({
1250
+ sessionId,
1251
+ conversationId,
1252
+ tool: String(meta.tool ?? ""),
1253
+ detail: String(meta.detail ?? ""),
1254
+ outcome: meta.outcome === "denied" ? "denied" : "approved"
1255
+ });
1256
+ return true;
1257
+ case "tool.applied":
1258
+ emitToolApplied({
1259
+ sessionId,
1260
+ conversationId,
1261
+ summary: summary ?? "edited a file",
1262
+ permissions: meta.permissions ?? []
1263
+ });
1264
+ return true;
1265
+ case "tool.used":
1266
+ emitToolUsed({
1267
+ sessionId,
1268
+ conversationId,
1269
+ tool: String(meta.tool ?? "Unknown"),
1270
+ detail: String(meta.detail ?? ""),
1271
+ outcome: meta.outcome === "approved" ? "approved" : "auto"
1272
+ });
1273
+ return true;
1274
+ default:
1275
+ return false;
1276
+ }
1277
+ }
958
1278
  var EVENT_STATUS_MAP;
959
1279
  var init_update = __esm(() => {
960
1280
  init_router();
961
1281
  init_sessions();
962
- init_events();
963
1282
  init_conversations();
964
1283
  init_trigger();
1284
+ init_emit();
965
1285
  EVENT_STATUS_MAP = {
966
1286
  "session.waiting": "waiting",
967
1287
  "session.answered": "active",
@@ -1006,7 +1326,7 @@ var init_update = __esm(() => {
1006
1326
  return;
1007
1327
  }
1008
1328
  const ignoreStatusFlip = shouldIgnoreStatusFlip(newStatus, session.pid);
1009
- let meta;
1329
+ let meta = {};
1010
1330
  if (metaJson) {
1011
1331
  try {
1012
1332
  meta = JSON.parse(metaJson);
@@ -1017,15 +1337,15 @@ var init_update = __esm(() => {
1017
1337
  }
1018
1338
  const rawConvoId = meta?.claude_id || process.env.BERTRAND_CLAUDE_ID || undefined;
1019
1339
  const conversationId = rawConvoId && getConversation(rawConvoId) ? rawConvoId : undefined;
1020
- const answersObj = meta?.answers;
1021
- const joinedAnswers = answersObj ? Object.values(answersObj).join(", ") || undefined : undefined;
1022
- insertEvent({
1340
+ const dispatched = dispatchHookEvent(event, {
1023
1341
  sessionId,
1024
1342
  conversationId,
1025
- event,
1026
- summary: summaryArg || meta?.question || joinedAnswers || undefined,
1027
- meta
1343
+ meta,
1344
+ summary: summaryArg
1028
1345
  });
1346
+ if (!dispatched) {
1347
+ return;
1348
+ }
1029
1349
  if (newStatus && !ignoreStatusFlip) {
1030
1350
  updateSessionStatus(sessionId, newStatus);
1031
1351
  }
@@ -1164,7 +1484,7 @@ var init_snapshot = __esm(() => {
1164
1484
  init_router();
1165
1485
  init_sessions();
1166
1486
  init_conversations();
1167
- init_events();
1487
+ init_emit();
1168
1488
  init_transcript();
1169
1489
  register("snapshot", async (args) => {
1170
1490
  let sessionId = "";
@@ -1197,20 +1517,15 @@ var init_snapshot = __esm(() => {
1197
1517
  if (!snapshot)
1198
1518
  return;
1199
1519
  const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
1200
- insertEvent({
1520
+ emitContextSnapshot({
1201
1521
  sessionId,
1202
1522
  conversationId: convoId,
1203
- event: "context.snapshot",
1204
- summary: `${snapshot.remainingPct}% remaining`,
1205
- meta: {
1206
- model: snapshot.model,
1207
- input_tokens: String(snapshot.inputTokens),
1208
- cache_creation_tokens: String(snapshot.cacheCreationTokens),
1209
- cache_read_tokens: String(snapshot.cacheReadTokens),
1210
- context_window_tokens: String(snapshot.totalContextTokens),
1211
- remaining_pct: String(snapshot.remainingPct),
1212
- claude_id: convoId
1213
- }
1523
+ model: snapshot.model,
1524
+ inputTokens: snapshot.inputTokens,
1525
+ cacheCreationTokens: snapshot.cacheCreationTokens,
1526
+ cacheReadTokens: snapshot.cacheReadTokens,
1527
+ totalContextTokens: snapshot.totalContextTokens,
1528
+ remainingPct: snapshot.remainingPct
1214
1529
  });
1215
1530
  });
1216
1531
  });
@@ -1223,14 +1538,16 @@ function summarize(text2) {
1223
1538
  const trimmed = firstLine.trim();
1224
1539
  return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
1225
1540
  }
1226
- var RECAP_TAG_RE;
1541
+ var RECAP_RE, RECAP_TAG_GLOBAL;
1227
1542
  var init_assistant_message = __esm(() => {
1228
1543
  init_router();
1229
1544
  init_sessions();
1230
1545
  init_conversations();
1231
1546
  init_events();
1547
+ init_emit();
1232
1548
  init_transcript();
1233
- RECAP_TAG_RE = /<recap>[\s\S]*?<\/recap>/gi;
1549
+ RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
1550
+ RECAP_TAG_GLOBAL = /<recap>[\s\S]*?<\/recap>/gi;
1234
1551
  register("assistant-message", async (args) => {
1235
1552
  let sessionId = "";
1236
1553
  let transcriptPath = "";
@@ -1261,34 +1578,50 @@ var init_assistant_message = __esm(() => {
1261
1578
  const turn = getLatestAssistantTurn(transcriptPath);
1262
1579
  if (!turn)
1263
1580
  return;
1264
- const text2 = turn.text.replace(RECAP_TAG_RE, "").trim();
1581
+ const fullText = turn.text;
1582
+ const textSansRecap = fullText.replace(RECAP_TAG_GLOBAL, "").trim();
1265
1583
  const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
1266
- insertEvent({
1267
- sessionId,
1268
- conversationId: convoId,
1269
- event: "assistant.message",
1270
- summary: text2 ? summarize(text2) : "thinking only",
1271
- meta: {
1272
- model: turn.model,
1273
- text: text2,
1274
- thinkingBlocks: turn.thinkingBlocks,
1275
- thinkingBytes: turn.thinkingBytes,
1276
- claude_id: convoId
1584
+ if (textSansRecap || turn.thinkingBlocks > 0) {
1585
+ const latestMsg = getLatestEventOfType(sessionId, "assistant.message", convoId);
1586
+ const latestText = latestMsg?.meta?.text;
1587
+ if (latestText !== textSansRecap) {
1588
+ emitAssistantMessage({
1589
+ sessionId,
1590
+ conversationId: convoId,
1591
+ text: textSansRecap,
1592
+ model: turn.model,
1593
+ thinkingBlocks: turn.thinkingBlocks,
1594
+ thinkingBytes: turn.thinkingBytes,
1595
+ summary: textSansRecap ? summarize(textSansRecap) : "thinking only"
1596
+ });
1277
1597
  }
1278
- });
1598
+ }
1599
+ const recapMatch = fullText.match(RECAP_RE);
1600
+ const recap = recapMatch?.[1]?.trim();
1601
+ if (recap) {
1602
+ const latestRecap = getLatestEventOfType(sessionId, "assistant.recap", convoId);
1603
+ const latestRecapText = latestRecap?.meta?.recap;
1604
+ if (latestRecapText !== recap) {
1605
+ emitAssistantRecap({
1606
+ sessionId,
1607
+ conversationId: convoId,
1608
+ recap
1609
+ });
1610
+ }
1611
+ }
1279
1612
  });
1280
1613
  });
1281
1614
 
1282
1615
  // src/cli/commands/recap-thinking.ts
1283
1616
  var exports_recap_thinking = {};
1284
- var RECAP_RE;
1617
+ var RECAP_RE2;
1285
1618
  var init_recap_thinking = __esm(() => {
1286
1619
  init_router();
1287
1620
  init_sessions();
1288
1621
  init_conversations();
1289
- init_events();
1622
+ init_emit();
1290
1623
  init_transcript();
1291
- RECAP_RE = /<recap>([\s\S]*?)<\/recap>/i;
1624
+ RECAP_RE2 = /<recap>([\s\S]*?)<\/recap>/i;
1292
1625
  register("recap-thinking", async (args) => {
1293
1626
  let sessionId = "";
1294
1627
  let transcriptPath = "";
@@ -1316,17 +1649,15 @@ var init_recap_thinking = __esm(() => {
1316
1649
  const turn = getLatestAssistantTurn(transcriptPath);
1317
1650
  if (!turn?.text)
1318
1651
  return;
1319
- const match = turn.text.match(RECAP_RE);
1652
+ const match = turn.text.match(RECAP_RE2);
1320
1653
  const recap = match?.[1]?.trim();
1321
1654
  if (!recap)
1322
1655
  return;
1323
1656
  const convoId = conversationId && getConversation(conversationId) ? conversationId : undefined;
1324
- insertEvent({
1657
+ emitAssistantRecap({
1325
1658
  sessionId,
1326
1659
  conversationId: convoId,
1327
- event: "assistant.recap",
1328
- summary: recap.length > 80 ? `${recap.slice(0, 77)}...` : recap,
1329
- meta: { recap, claude_id: convoId }
1660
+ recap
1330
1661
  });
1331
1662
  });
1332
1663
  });
@@ -1646,6 +1977,14 @@ function aggregateToolUsage(sessionId) {
1646
1977
  continue;
1647
1978
  counts[tool] = (counts[tool] ?? 0) + 1;
1648
1979
  }
1980
+ const used = getEventsByType(sessionId, "tool.used");
1981
+ for (const ev of used) {
1982
+ const meta = ev.meta;
1983
+ const tool = meta?.tool;
1984
+ if (!tool)
1985
+ continue;
1986
+ counts[tool] = (counts[tool] ?? 0) + 1;
1987
+ }
1649
1988
  return counts;
1650
1989
  }
1651
1990
  function contextTokenStats(sessionId) {
@@ -2280,6 +2619,7 @@ function runMigrations(dbPath) {
2280
2619
  const target = dbPath ?? resolveActiveProject().db;
2281
2620
  mkdirSync5(dirname3(target), { recursive: true });
2282
2621
  const sqlite = new Database3(target);
2622
+ sqlite.exec("PRAGMA busy_timeout = 5000");
2283
2623
  sqlite.exec("PRAGMA journal_mode = WAL");
2284
2624
  sqlite.exec("PRAGMA foreign_keys = ON");
2285
2625
  const db = drizzle2(sqlite);
@@ -3145,29 +3485,22 @@ async function launch(opts) {
3145
3485
  await ensureServerStarted();
3146
3486
  const spawnContext = await captureSpawnContext();
3147
3487
  const sessionName = `${opts.categoryPath}/${opts.slug}`;
3148
- insertEvent({
3488
+ emitSessionStarted({
3149
3489
  sessionId: session.id,
3150
3490
  conversationId: claudeId,
3151
- event: "session.started",
3152
- meta: {
3153
- category_path: opts.categoryPath,
3154
- session_name: opts.name ?? opts.slug,
3155
- session_slug: opts.slug,
3156
- labels: opts.labelNames ?? [],
3157
- summary: session.summary ?? null
3158
- }
3491
+ categoryPath: opts.categoryPath,
3492
+ sessionName: opts.name ?? opts.slug,
3493
+ sessionSlug: opts.slug,
3494
+ labels: opts.labelNames ?? [],
3495
+ summary: session.summary ?? null
3159
3496
  });
3160
- insertEvent({
3497
+ emitClaudeStarted({
3161
3498
  sessionId: session.id,
3162
3499
  conversationId: claudeId,
3163
- event: "claude.started",
3164
- meta: {
3165
- claude_id: claudeId,
3166
- model: spawnContext.model,
3167
- claude_version: spawnContext.claudeVersion,
3168
- git: spawnContext.git,
3169
- cwd: spawnContext.cwd
3170
- }
3500
+ model: spawnContext.model,
3501
+ claudeVersion: spawnContext.claudeVersion,
3502
+ git: spawnContext.git,
3503
+ cwd: spawnContext.cwd
3171
3504
  });
3172
3505
  const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
3173
3506
  const contract = buildContract(sessionName, siblingContext);
@@ -3191,11 +3524,9 @@ async function resume(opts) {
3191
3524
  liveSession = { sessionId: session.id, claudeId: opts.conversationId };
3192
3525
  installExitHandlers();
3193
3526
  await ensureServerStarted();
3194
- insertEvent({
3527
+ emitSessionResumed({
3195
3528
  sessionId: session.id,
3196
- conversationId: opts.conversationId,
3197
- event: "session.resumed",
3198
- meta: { claude_id: opts.conversationId }
3529
+ conversationId: opts.conversationId
3199
3530
  });
3200
3531
  const categoryPath = category?.path ?? "";
3201
3532
  const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
@@ -3219,11 +3550,10 @@ function finalizeSession(sessionId, conversationId, exitCode) {
3219
3550
  if (conversationExists) {
3220
3551
  endConversation(conversationId);
3221
3552
  }
3222
- insertEvent({
3553
+ emitClaudeEnded({
3223
3554
  sessionId,
3224
3555
  conversationId: safeConversationId,
3225
- event: "claude.ended",
3226
- meta: { claude_id: conversationId, exit_code: exitCode }
3556
+ exitCode
3227
3557
  });
3228
3558
  updateSession(sessionId, {
3229
3559
  status: "paused",
@@ -3232,10 +3562,7 @@ function finalizeSession(sessionId, conversationId, exitCode) {
3232
3562
  });
3233
3563
  if (liveSession?.sessionId === sessionId)
3234
3564
  liveSession = null;
3235
- insertEvent({
3236
- sessionId,
3237
- event: "session.end"
3238
- });
3565
+ emitSessionEnded({ sessionId });
3239
3566
  computeAndPersist(sessionId);
3240
3567
  stopServerIfIdle();
3241
3568
  }
@@ -3243,7 +3570,7 @@ var liveSession = null, exitHandlersInstalled = false;
3243
3570
  var init_session = __esm(() => {
3244
3571
  init_sessions();
3245
3572
  init_conversations();
3246
- init_events();
3573
+ init_emit();
3247
3574
  init_categories();
3248
3575
  init_labels();
3249
3576
  init_template2();
@@ -3274,11 +3601,8 @@ async function runScreen(screen, ...args) {
3274
3601
  unlinkSync3(tmpFile);
3275
3602
  return result;
3276
3603
  }
3277
- async function startLaunchTui() {
3278
- return runScreen("launch");
3279
- }
3280
- async function startProjectPickerTui() {
3281
- return runScreen("project-picker");
3604
+ async function startStartupTui(skipProjectPicker, initialProjectSlug) {
3605
+ return runScreen("startup", String(skipProjectPicker), initialProjectSlug);
3282
3606
  }
3283
3607
  async function startExitTui(sessionId) {
3284
3608
  return runScreen("exit", sessionId);
@@ -3334,17 +3658,19 @@ function shouldShowProjectPicker() {
3334
3658
  const projects = listProjects();
3335
3659
  return projects.length !== 1;
3336
3660
  }
3337
- function activateProject(slug) {
3338
- setActiveProjectSlug(slug);
3661
+ async function startTui() {
3662
+ const skipProjectPicker = !shouldShowProjectPicker();
3663
+ const initialProjectSlug = getActiveProjectSlug();
3664
+ const selection = await startStartupTui(skipProjectPicker, initialProjectSlug);
3339
3665
  _resetActiveProjectCache();
3340
- }
3341
- async function runLaunchCycle() {
3342
- const selection = await startLaunchTui();
3343
3666
  switch (selection.type) {
3344
3667
  case "quit":
3345
3668
  return;
3346
3669
  case "create": {
3347
- const sessionId = await launch(selection);
3670
+ const sessionId = await launch({
3671
+ categoryPath: selection.categoryPath,
3672
+ slug: selection.slug
3673
+ });
3348
3674
  await runSessionLoop(sessionId);
3349
3675
  return;
3350
3676
  }
@@ -3361,28 +3687,6 @@ async function runLaunchCycle() {
3361
3687
  }
3362
3688
  }
3363
3689
  }
3364
- async function startTui() {
3365
- if (!shouldShowProjectPicker()) {
3366
- await runLaunchCycle();
3367
- return;
3368
- }
3369
- const projectSelection = await startProjectPickerTui();
3370
- switch (projectSelection.type) {
3371
- case "quit":
3372
- return;
3373
- case "select": {
3374
- activateProject(projectSelection.slug);
3375
- await runLaunchCycle();
3376
- return;
3377
- }
3378
- case "create": {
3379
- createProject({ slug: projectSelection.slug });
3380
- activateProject(projectSelection.slug);
3381
- await runLaunchCycle();
3382
- return;
3383
- }
3384
- }
3385
- }
3386
3690
  var SCREEN_ENTRY;
3387
3691
  var init_app = __esm(() => {
3388
3692
  init_sessions();
@@ -3390,7 +3694,6 @@ var init_app = __esm(() => {
3390
3694
  init_session_archive();
3391
3695
  init_session();
3392
3696
  init_registry();
3393
- init_create();
3394
3697
  init_resolve();
3395
3698
  SCREEN_ENTRY = (() => {
3396
3699
  const built = join9(import.meta.dir, "run-screen.js");
@@ -3436,11 +3739,9 @@ function recoverStaleSessions() {
3436
3739
  status: "paused",
3437
3740
  pid: null
3438
3741
  });
3439
- insertEvent({
3742
+ emitSessionPausedByRecovery({
3440
3743
  sessionId: session.id,
3441
- event: "session.paused",
3442
- summary: "Recovered from stale state (process not found)",
3443
- meta: { stale_pid: session.pid }
3744
+ stalePid: session.pid
3444
3745
  });
3445
3746
  recovered++;
3446
3747
  }
@@ -3449,7 +3750,7 @@ function recoverStaleSessions() {
3449
3750
  }
3450
3751
  var init_recovery = __esm(() => {
3451
3752
  init_sessions();
3452
- init_events();
3753
+ init_emit();
3453
3754
  });
3454
3755
 
3455
3756
  // src/cli/commands/launch.ts
@@ -3473,10 +3774,13 @@ var init_launch = __esm(() => {
3473
3774
  });
3474
3775
 
3475
3776
  // src/hooks/scripts.ts
3777
+ function quietHelper(bin) {
3778
+ return `bq() { ${bin} "$@" 2>/dev/null || true; }`;
3779
+ }
3476
3780
  function waitingScript(bin) {
3477
- const BIN = bin;
3478
3781
  return `#!/usr/bin/env bash
3479
3782
  # Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
3783
+ ${quietHelper(bin)}
3480
3784
  sid="\${BERTRAND_SESSION:-}"
3481
3785
  [ -z "$sid" ] && exit 0
3482
3786
 
@@ -3500,23 +3804,26 @@ question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut
3500
3804
  # Clear working debounce marker so next resume\u2192working transition fires
3501
3805
  rm -f "/tmp/bertrand-working-$sid"
3502
3806
 
3503
- ${BIN} update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
3807
+ bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
3504
3808
 
3505
- # Context snapshot \u2014 extract transcript path and capture token usage
3809
+ # Context snapshot \u2014 extract transcript path and capture token usage.
3810
+ # assistant-message captures the latest turn's text + recap tag in one pass;
3811
+ # replaces the older recap-thinking call which only grabbed the recap. Dedup
3812
+ # inside the command makes it idempotent vs the matching Stop-time capture,
3813
+ # so the same turn never lands twice.
3506
3814
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
3507
3815
  if [ -n "$tpath" ]; then
3508
- ${BIN} snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3509
- ${BIN} recap-thinking --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3816
+ bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3817
+ bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3510
3818
  fi
3511
3819
 
3512
3820
  # Badge + notify in background \u2014 terminal UI doesn't need to block Claude
3513
- ${BIN} badge message-question --color '#e0b956' --priority 20 --beep &
3514
- ${BIN} notify bertrand "$question" &
3821
+ bq badge message-question --color '#e0b956' --priority 20 --beep &
3822
+ bq notify bertrand "$question" &
3515
3823
  wait
3516
3824
  `;
3517
3825
  }
3518
3826
  function answeredScript(bin) {
3519
- const BIN = bin;
3520
3827
  return `#!/usr/bin/env bash
3521
3828
  # Hook: PostToolUse AskUserQuestion \u2192 mark session as active
3522
3829
  #
@@ -3524,6 +3831,7 @@ function answeredScript(bin) {
3524
3831
  # Claude Code so the agent halts immediately instead of taking another turn.
3525
3832
  # This is the mechanical enforcement of the contract's loop-exit rule \u2014 the
3526
3833
  # contract prose is a soft hint, this JSON is the guarantee.
3834
+ ${quietHelper(bin)}
3527
3835
  sid="\${BERTRAND_SESSION:-}"
3528
3836
  [ -z "$sid" ] && exit 0
3529
3837
 
@@ -3545,9 +3853,9 @@ meta="$(printf '%s' "$input" | jq --arg cid "$cid" '
3545
3853
  # Concatenate all answer values into a single string for the Done-for-now check.
3546
3854
  done_check="$(printf '%s' "$meta" | jq -r '.answers | to_entries | map(.value | tostring) | join(" ")' 2>/dev/null)"
3547
3855
 
3548
- ${BIN} update --session-id "$sid" --event session.answered --meta "$meta"
3856
+ bq update --session-id "$sid" --event session.answered --meta "$meta"
3549
3857
 
3550
- ${BIN} badge --clear &
3858
+ bq badge --clear &
3551
3859
 
3552
3860
  # Halt the agent loop if the user signaled Done for now. The Stop hook
3553
3861
  # (on-done.sh) will fire afterwards and mark the session as paused.
@@ -3560,7 +3868,7 @@ if printf '%s' "$done_check" | grep -q "Done for now"; then
3560
3868
  [.questions[]?.options[]? | select(.label == "Done for now") | .description] | first // empty
3561
3869
  ' 2>/dev/null)"
3562
3870
  if [ -n "$recap" ]; then
3563
- ${BIN} update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
3871
+ bq update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
3564
3872
  fi
3565
3873
 
3566
3874
  printf '{"continue": false, "stopReason": "User selected Done for now"}\\n'
@@ -3570,9 +3878,9 @@ wait
3570
3878
  `;
3571
3879
  }
3572
3880
  function activeScript(bin) {
3573
- const BIN = bin;
3574
3881
  return `#!/usr/bin/env bash
3575
3882
  # Hook: PreToolUse (catch-all) \u2192 flip waiting to active
3883
+ ${quietHelper(bin)}
3576
3884
  sid="\${BERTRAND_SESSION:-}"
3577
3885
  [ -z "$sid" ] && exit 0
3578
3886
 
@@ -3592,13 +3900,13 @@ ${EXTRACT_TOOL}
3592
3900
 
3593
3901
  touch "$marker"
3594
3902
  cid="\${BERTRAND_CLAUDE_ID:-}"
3595
- ${BIN} update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3903
+ bq update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3596
3904
  `;
3597
3905
  }
3598
3906
  function permissionWaitScript(bin) {
3599
- const BIN = bin;
3600
3907
  return `#!/usr/bin/env bash
3601
3908
  # Hook: PermissionRequest \u2192 mark pending, emit permission.request
3909
+ ${quietHelper(bin)}
3602
3910
  sid="\${BERTRAND_SESSION:-}"
3603
3911
  [ -z "$sid" ] && exit 0
3604
3912
 
@@ -3617,38 +3925,45 @@ case "$tool" in
3617
3925
  esac
3618
3926
 
3619
3927
  cid="\${BERTRAND_CLAUDE_ID:-}"
3620
- ${BIN} update --session-id "$sid" --event permission.request --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, claude_id:$cid}')"
3928
+ bq update --session-id "$sid" --event permission.request --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, claude_id:$cid}')"
3621
3929
 
3622
3930
  # Badge + notify in background
3623
- ${BIN} badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
3624
- ${BIN} notify bertrand "Needs permission: $tool" &
3931
+ bq badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
3932
+ bq notify bertrand "Needs permission: $tool" &
3625
3933
  wait
3626
3934
  `;
3627
3935
  }
3628
3936
  function permissionDoneScript(bin) {
3629
- const BIN = bin;
3630
3937
  return `#!/usr/bin/env bash
3631
3938
  # Hook: PostToolUse (catch-all)
3632
3939
  #
3633
- # Two flows:
3634
- # 1. Edit/Write/MultiEdit: ALWAYS emit tool.applied with diff, regardless of permission
3635
- # flow. This is the only way to capture diffs for auto-approved edits \u2014 bertrand
3636
- # must never require disabling auto-approve to gather data.
3637
- # 2. Other tools: emit permission.resolve only if a PermissionRequest preceded this
3638
- # (marker exists). Rejected tools never reach PostToolUse, so a request without a
3639
- # resolve = rejected.
3940
+ # Captures every tool call Claude makes. Three event flows:
3941
+ # 1. Edit/Write/MultiEdit \u2192 tool.applied with diff payload. Keeps the
3942
+ # existing dashboard diff-renderer happy and is the only place we get
3943
+ # old_string/new_string on auto-approved edits.
3944
+ # 2. Tools that went through a permission prompt \u2192 permission.resolve.
3945
+ # The PermissionRequest hook set a marker; we clear it and log the
3946
+ # approval. (Denials never reach PostToolUse, so absence-of-resolve
3947
+ # means the user said no.)
3948
+ # 3. Everything else (auto-approved Bash/Read/Grep/Glob/etc.) \u2192 tool.used
3949
+ # with outcome:"auto". Previously this case dropped the call entirely;
3950
+ # now Claude's read-only / shell activity shows up in the timeline.
3951
+ ${quietHelper(bin)}
3640
3952
  sid="\${BERTRAND_SESSION:-}"
3641
3953
  [ -z "$sid" ] && exit 0
3642
3954
 
3643
3955
  input="$(cat)"
3644
3956
  ${EXTRACT_TOOL}
3645
3957
 
3958
+ # Don't double-log: AskUserQuestion has its own waiting/answered events
3959
+ [ "$tool" = "AskUserQuestion" ] && exit 0
3960
+
3646
3961
  marker="/tmp/bertrand-perm-pending-$sid"
3647
3962
  had_marker=0
3648
3963
  if [ -f "$marker" ]; then
3649
3964
  had_marker=1
3650
3965
  rm -f "$marker"
3651
- ${BIN} badge --clear &
3966
+ bq badge --clear &
3652
3967
  fi
3653
3968
 
3654
3969
  cid="\${BERTRAND_CLAUDE_ID:-}"
@@ -3660,10 +3975,6 @@ case "$tool" in
3660
3975
  Write) summary="wrote a file" ;;
3661
3976
  *) summary="edited a file" ;;
3662
3977
  esac
3663
- # Single jq pass: build meta.permissions[] with diff data so the dashboard renders
3664
- # via the existing WorkContent path (same shape as collapsed permission events).
3665
- # Emit camelCase keys (oldStr/newStr/edits) directly so WorkContent reads
3666
- # meta.permissions[] without going through transforms.ts's snake\u2192camel adapter.
3667
3978
  meta="$(printf '%s' "$input" | jq --arg t "$tool" --arg d "$detail" --arg cid "$cid" '
3668
3979
  {
3669
3980
  permissions: [
@@ -3676,28 +3987,40 @@ case "$tool" in
3676
3987
  claude_id: $cid
3677
3988
  }
3678
3989
  ')"
3679
- ${BIN} update --session-id "$sid" --event tool.applied --summary "$summary" --meta "$meta"
3990
+ bq update --session-id "$sid" --event tool.applied --summary "$summary" --meta "$meta"
3680
3991
  wait
3681
3992
  exit 0
3682
3993
  ;;
3683
3994
  esac
3684
3995
 
3685
- # Other tools: only emit permission.resolve if there was a real prompt
3686
- [ "$had_marker" = "0" ] && exit 0
3687
-
3996
+ # Extract a tool-shaped detail for the timeline summary. Bash gets the
3997
+ # command, file tools get the path; everything else falls back to a generic
3998
+ # label inside the emit helper.
3688
3999
  detail=""
3689
4000
  case "$tool" in
3690
4001
  Bash) detail="$(printf '%s' "$input" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
4002
+ Read|NotebookRead) detail="$(printf '%s' "$input" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
4003
+ Glob) detail="$(printf '%s' "$input" | grep -o '"pattern":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
4004
+ Grep) detail="$(printf '%s' "$input" | grep -o '"pattern":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
4005
+ WebFetch) detail="$(printf '%s' "$input" | grep -o '"url":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-300)" ;;
4006
+ WebSearch) detail="$(printf '%s' "$input" | grep -o '"query":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
3691
4007
  esac
3692
4008
 
3693
- ${BIN} update --session-id "$sid" --event permission.resolve --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"approved", claude_id:$cid}')"
4009
+ if [ "$had_marker" = "1" ]; then
4010
+ # Prompted-then-approved path. Keep permission.resolve for back-compat
4011
+ # rendering; downstream consumers can migrate to tool.used at their pace.
4012
+ bq update --session-id "$sid" --event permission.resolve --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"approved", claude_id:$cid}')"
4013
+ else
4014
+ # Auto-approved path. Without tool.used, these calls were invisible.
4015
+ bq update --session-id "$sid" --event tool.used --meta "$(jq -n --arg t "$tool" --arg d "$detail" --arg cid "$cid" '{tool:$t, detail:$d, outcome:"auto", claude_id:$cid}')"
4016
+ fi
3694
4017
  wait
3695
4018
  `;
3696
4019
  }
3697
4020
  function userPromptScript(bin) {
3698
- const BIN = bin;
3699
4021
  return `#!/usr/bin/env bash
3700
4022
  # Hook: UserPromptSubmit \u2192 record user free-text prompt
4023
+ ${quietHelper(bin)}
3701
4024
  sid="\${BERTRAND_SESSION:-}"
3702
4025
  [ -z "$sid" ] && exit 0
3703
4026
 
@@ -3707,28 +4030,31 @@ cid="\${BERTRAND_CLAUDE_ID:-}"
3707
4030
  meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
3708
4031
  [ -z "$meta" ] && exit 0
3709
4032
 
3710
- ${BIN} update --session-id "$sid" --event user.prompt --meta "$meta"
4033
+ bq update --session-id "$sid" --event user.prompt --meta "$meta"
3711
4034
  `;
3712
4035
  }
3713
4036
  function doneScript(bin) {
3714
- const BIN = bin;
3715
4037
  return `#!/usr/bin/env bash
3716
4038
  # Hook: Stop \u2192 mark session as paused
4039
+ ${quietHelper(bin)}
3717
4040
  sid="\${BERTRAND_SESSION:-}"
3718
4041
  [ -z "$sid" ] && exit 0
3719
4042
 
3720
4043
  input="$(cat)"
3721
4044
  cid="\${BERTRAND_CLAUDE_ID:-}"
3722
- ${BIN} update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
4045
+ bq update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3723
4046
 
3724
- # Final context snapshot \u2014 capture token usage at session end
4047
+ # Final transcript reads. assistant-message dedups against the most-recent
4048
+ # AskUQ-time capture, so a Done-for-now exit (no work between AskUQ and Stop)
4049
+ # lands zero new events here; intermediate Stops with fresh assistant output
4050
+ # do land new ones.
3725
4051
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
3726
4052
  if [ -n "$tpath" ]; then
3727
- ${BIN} snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3728
- ${BIN} assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
4053
+ bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
4054
+ bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3729
4055
  fi
3730
4056
 
3731
- ${BIN} badge check --color '#58c142' --priority 10
4057
+ bq badge check --color '#58c142' --priority 10
3732
4058
  `;
3733
4059
  }
3734
4060
  var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
@@ -4151,8 +4477,11 @@ var init_catalog = __esm(() => {
4151
4477
  "vercel.deploy": { label: "deployed", category: "integration", color: 245, detailColor: 245, skip: false },
4152
4478
  "user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
4153
4479
  "context.snapshot": { label: "context", category: "context", color: 245, detailColor: 245, skip: true },
4480
+ "tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: true },
4154
4481
  "tool.work": { label: "tool work", category: "work", color: 214, detailColor: 245, skip: false },
4155
- "session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false }
4482
+ "session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
4483
+ "assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
4484
+ "assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false }
4156
4485
  };
4157
4486
  DEFAULT_INFO = {
4158
4487
  label: "unknown",
@@ -4192,7 +4521,7 @@ function collapsePermissions(events2) {
4192
4521
  let i = 0;
4193
4522
  while (i < events2.length) {
4194
4523
  const ev = events2[i];
4195
- if (ev.event !== "permission.request" && ev.event !== "permission.resolve") {
4524
+ if (!ROLLUP_EVENTS.has(ev.event)) {
4196
4525
  result.push(ev);
4197
4526
  i++;
4198
4527
  continue;
@@ -4200,7 +4529,7 @@ function collapsePermissions(events2) {
4200
4529
  const batch = [];
4201
4530
  while (i < events2.length) {
4202
4531
  const current = events2[i];
4203
- if (current.event !== "permission.request" && current.event !== "permission.resolve")
4532
+ if (!ROLLUP_EVENTS.has(current.event))
4204
4533
  break;
4205
4534
  batch.push(current);
4206
4535
  i++;
@@ -4209,7 +4538,7 @@ function collapsePermissions(events2) {
4209
4538
  continue;
4210
4539
  const toolCounts = new Map;
4211
4540
  for (const pev of batch) {
4212
- if (pev.event !== "permission.request")
4541
+ if (pev.event !== "permission.request" && pev.event !== "tool.used")
4213
4542
  continue;
4214
4543
  const meta = pev.meta;
4215
4544
  const tool = meta?.tool ?? "unknown";
@@ -4268,8 +4597,14 @@ function filterSkipped(events2) {
4268
4597
  function compact(events2) {
4269
4598
  return deduplicate(collapsePermissions(repairQAPairs(filterSkipped(events2))));
4270
4599
  }
4600
+ var ROLLUP_EVENTS;
4271
4601
  var init_compact = __esm(() => {
4272
4602
  init_catalog();
4603
+ ROLLUP_EVENTS = new Set([
4604
+ "permission.request",
4605
+ "permission.resolve",
4606
+ "tool.used"
4607
+ ]);
4273
4608
  });
4274
4609
 
4275
4610
  // src/cli/commands/log.ts