bertrand 0.19.0 → 0.20.1

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) {
@@ -1778,8 +2117,10 @@ async function handleSwitchProject(req) {
1778
2117
  return Response.json({ error: `Unknown project: ${slug}` }, { status: 404 });
1779
2118
  }
1780
2119
  setActiveProjectSlug(slug);
1781
- queueMicrotask(() => process.exit(0));
1782
- return Response.json({ ok: true, slug, willRestart: true });
2120
+ process.env.BERTRAND_PROJECT = slug;
2121
+ _resetActiveProjectCache();
2122
+ invalidateDbCache();
2123
+ return Response.json({ ok: true, slug });
1783
2124
  }
1784
2125
  async function handleOpen(req) {
1785
2126
  let body;
@@ -1948,6 +2289,7 @@ var init_server = __esm(() => {
1948
2289
  init_session_archive();
1949
2290
  init_registry();
1950
2291
  init_resolve();
2292
+ init_client();
1951
2293
  PORT = Number(process.env.BERTRAND_PORT ?? 5200);
1952
2294
  routes = [
1953
2295
  [/^\/api\/sessions$/, listSessions],
@@ -2280,6 +2622,7 @@ function runMigrations(dbPath) {
2280
2622
  const target = dbPath ?? resolveActiveProject().db;
2281
2623
  mkdirSync5(dirname3(target), { recursive: true });
2282
2624
  const sqlite = new Database3(target);
2625
+ sqlite.exec("PRAGMA busy_timeout = 5000");
2283
2626
  sqlite.exec("PRAGMA journal_mode = WAL");
2284
2627
  sqlite.exec("PRAGMA foreign_keys = ON");
2285
2628
  const db = drizzle2(sqlite);
@@ -3145,29 +3488,22 @@ async function launch(opts) {
3145
3488
  await ensureServerStarted();
3146
3489
  const spawnContext = await captureSpawnContext();
3147
3490
  const sessionName = `${opts.categoryPath}/${opts.slug}`;
3148
- insertEvent({
3491
+ emitSessionStarted({
3149
3492
  sessionId: session.id,
3150
3493
  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
- }
3494
+ categoryPath: opts.categoryPath,
3495
+ sessionName: opts.name ?? opts.slug,
3496
+ sessionSlug: opts.slug,
3497
+ labels: opts.labelNames ?? [],
3498
+ summary: session.summary ?? null
3159
3499
  });
3160
- insertEvent({
3500
+ emitClaudeStarted({
3161
3501
  sessionId: session.id,
3162
3502
  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
- }
3503
+ model: spawnContext.model,
3504
+ claudeVersion: spawnContext.claudeVersion,
3505
+ git: spawnContext.git,
3506
+ cwd: spawnContext.cwd
3171
3507
  });
3172
3508
  const siblingContext = buildSiblingContext(categoryId, opts.categoryPath, session.id);
3173
3509
  const contract = buildContract(sessionName, siblingContext);
@@ -3191,11 +3527,9 @@ async function resume(opts) {
3191
3527
  liveSession = { sessionId: session.id, claudeId: opts.conversationId };
3192
3528
  installExitHandlers();
3193
3529
  await ensureServerStarted();
3194
- insertEvent({
3530
+ emitSessionResumed({
3195
3531
  sessionId: session.id,
3196
- conversationId: opts.conversationId,
3197
- event: "session.resumed",
3198
- meta: { claude_id: opts.conversationId }
3532
+ conversationId: opts.conversationId
3199
3533
  });
3200
3534
  const categoryPath = category?.path ?? "";
3201
3535
  const siblingContext = buildSiblingContext(session.categoryId, categoryPath, session.id);
@@ -3219,11 +3553,10 @@ function finalizeSession(sessionId, conversationId, exitCode) {
3219
3553
  if (conversationExists) {
3220
3554
  endConversation(conversationId);
3221
3555
  }
3222
- insertEvent({
3556
+ emitClaudeEnded({
3223
3557
  sessionId,
3224
3558
  conversationId: safeConversationId,
3225
- event: "claude.ended",
3226
- meta: { claude_id: conversationId, exit_code: exitCode }
3559
+ exitCode
3227
3560
  });
3228
3561
  updateSession(sessionId, {
3229
3562
  status: "paused",
@@ -3232,10 +3565,7 @@ function finalizeSession(sessionId, conversationId, exitCode) {
3232
3565
  });
3233
3566
  if (liveSession?.sessionId === sessionId)
3234
3567
  liveSession = null;
3235
- insertEvent({
3236
- sessionId,
3237
- event: "session.end"
3238
- });
3568
+ emitSessionEnded({ sessionId });
3239
3569
  computeAndPersist(sessionId);
3240
3570
  stopServerIfIdle();
3241
3571
  }
@@ -3243,7 +3573,7 @@ var liveSession = null, exitHandlersInstalled = false;
3243
3573
  var init_session = __esm(() => {
3244
3574
  init_sessions();
3245
3575
  init_conversations();
3246
- init_events();
3576
+ init_emit();
3247
3577
  init_categories();
3248
3578
  init_labels();
3249
3579
  init_template2();
@@ -3436,11 +3766,9 @@ function recoverStaleSessions() {
3436
3766
  status: "paused",
3437
3767
  pid: null
3438
3768
  });
3439
- insertEvent({
3769
+ emitSessionPausedByRecovery({
3440
3770
  sessionId: session.id,
3441
- event: "session.paused",
3442
- summary: "Recovered from stale state (process not found)",
3443
- meta: { stale_pid: session.pid }
3771
+ stalePid: session.pid
3444
3772
  });
3445
3773
  recovered++;
3446
3774
  }
@@ -3449,7 +3777,7 @@ function recoverStaleSessions() {
3449
3777
  }
3450
3778
  var init_recovery = __esm(() => {
3451
3779
  init_sessions();
3452
- init_events();
3780
+ init_emit();
3453
3781
  });
3454
3782
 
3455
3783
  // src/cli/commands/launch.ts
@@ -3473,10 +3801,13 @@ var init_launch = __esm(() => {
3473
3801
  });
3474
3802
 
3475
3803
  // src/hooks/scripts.ts
3804
+ function quietHelper(bin) {
3805
+ return `bq() { ${bin} "$@" 2>/dev/null || true; }`;
3806
+ }
3476
3807
  function waitingScript(bin) {
3477
- const BIN = bin;
3478
3808
  return `#!/usr/bin/env bash
3479
3809
  # Hook: PreToolUse AskUserQuestion \u2192 enforce multiSelect, mark session as waiting
3810
+ ${quietHelper(bin)}
3480
3811
  sid="\${BERTRAND_SESSION:-}"
3481
3812
  [ -z "$sid" ] && exit 0
3482
3813
 
@@ -3500,23 +3831,26 @@ question="$(printf '%s' "$input" | grep -o '"question":"[^"]*"' | head -1 | cut
3500
3831
  # Clear working debounce marker so next resume\u2192working transition fires
3501
3832
  rm -f "/tmp/bertrand-working-$sid"
3502
3833
 
3503
- ${BIN} update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
3834
+ bq update --session-id "$sid" --event session.waiting --meta "$(jq -n --arg q "$question" --arg cid "$cid" '{question:$q, claude_id:$cid}')"
3504
3835
 
3505
- # Context snapshot \u2014 extract transcript path and capture token usage
3836
+ # Context snapshot \u2014 extract transcript path and capture token usage.
3837
+ # assistant-message captures the latest turn's text + recap tag in one pass;
3838
+ # replaces the older recap-thinking call which only grabbed the recap. Dedup
3839
+ # inside the command makes it idempotent vs the matching Stop-time capture,
3840
+ # so the same turn never lands twice.
3506
3841
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
3507
3842
  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" &
3843
+ bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3844
+ bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3510
3845
  fi
3511
3846
 
3512
3847
  # 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" &
3848
+ bq badge message-question --color '#e0b956' --priority 20 --beep &
3849
+ bq notify bertrand "$question" &
3515
3850
  wait
3516
3851
  `;
3517
3852
  }
3518
3853
  function answeredScript(bin) {
3519
- const BIN = bin;
3520
3854
  return `#!/usr/bin/env bash
3521
3855
  # Hook: PostToolUse AskUserQuestion \u2192 mark session as active
3522
3856
  #
@@ -3524,6 +3858,7 @@ function answeredScript(bin) {
3524
3858
  # Claude Code so the agent halts immediately instead of taking another turn.
3525
3859
  # This is the mechanical enforcement of the contract's loop-exit rule \u2014 the
3526
3860
  # contract prose is a soft hint, this JSON is the guarantee.
3861
+ ${quietHelper(bin)}
3527
3862
  sid="\${BERTRAND_SESSION:-}"
3528
3863
  [ -z "$sid" ] && exit 0
3529
3864
 
@@ -3545,9 +3880,9 @@ meta="$(printf '%s' "$input" | jq --arg cid "$cid" '
3545
3880
  # Concatenate all answer values into a single string for the Done-for-now check.
3546
3881
  done_check="$(printf '%s' "$meta" | jq -r '.answers | to_entries | map(.value | tostring) | join(" ")' 2>/dev/null)"
3547
3882
 
3548
- ${BIN} update --session-id "$sid" --event session.answered --meta "$meta"
3883
+ bq update --session-id "$sid" --event session.answered --meta "$meta"
3549
3884
 
3550
- ${BIN} badge --clear &
3885
+ bq badge --clear &
3551
3886
 
3552
3887
  # Halt the agent loop if the user signaled Done for now. The Stop hook
3553
3888
  # (on-done.sh) will fire afterwards and mark the session as paused.
@@ -3560,7 +3895,7 @@ if printf '%s' "$done_check" | grep -q "Done for now"; then
3560
3895
  [.questions[]?.options[]? | select(.label == "Done for now") | .description] | first // empty
3561
3896
  ' 2>/dev/null)"
3562
3897
  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}')"
3898
+ bq update --session-id "$sid" --event session.recap --meta "$(jq -n --arg recap "$recap" --arg cid "$cid" '{recap:$recap, claude_id:$cid}')"
3564
3899
  fi
3565
3900
 
3566
3901
  printf '{"continue": false, "stopReason": "User selected Done for now"}\\n'
@@ -3570,9 +3905,9 @@ wait
3570
3905
  `;
3571
3906
  }
3572
3907
  function activeScript(bin) {
3573
- const BIN = bin;
3574
3908
  return `#!/usr/bin/env bash
3575
3909
  # Hook: PreToolUse (catch-all) \u2192 flip waiting to active
3910
+ ${quietHelper(bin)}
3576
3911
  sid="\${BERTRAND_SESSION:-}"
3577
3912
  [ -z "$sid" ] && exit 0
3578
3913
 
@@ -3592,13 +3927,13 @@ ${EXTRACT_TOOL}
3592
3927
 
3593
3928
  touch "$marker"
3594
3929
  cid="\${BERTRAND_CLAUDE_ID:-}"
3595
- ${BIN} update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3930
+ bq update --session-id "$sid" --event session.active --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3596
3931
  `;
3597
3932
  }
3598
3933
  function permissionWaitScript(bin) {
3599
- const BIN = bin;
3600
3934
  return `#!/usr/bin/env bash
3601
3935
  # Hook: PermissionRequest \u2192 mark pending, emit permission.request
3936
+ ${quietHelper(bin)}
3602
3937
  sid="\${BERTRAND_SESSION:-}"
3603
3938
  [ -z "$sid" ] && exit 0
3604
3939
 
@@ -3617,38 +3952,45 @@ case "$tool" in
3617
3952
  esac
3618
3953
 
3619
3954
  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}')"
3955
+ 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
3956
 
3622
3957
  # Badge + notify in background
3623
- ${BIN} badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
3624
- ${BIN} notify bertrand "Needs permission: $tool" &
3958
+ bq badge bell-exclamation --color '#ff6b35' --priority 25 --beep &
3959
+ bq notify bertrand "Needs permission: $tool" &
3625
3960
  wait
3626
3961
  `;
3627
3962
  }
3628
3963
  function permissionDoneScript(bin) {
3629
- const BIN = bin;
3630
3964
  return `#!/usr/bin/env bash
3631
3965
  # Hook: PostToolUse (catch-all)
3632
3966
  #
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.
3967
+ # Captures every tool call Claude makes. Three event flows:
3968
+ # 1. Edit/Write/MultiEdit \u2192 tool.applied with diff payload. Keeps the
3969
+ # existing dashboard diff-renderer happy and is the only place we get
3970
+ # old_string/new_string on auto-approved edits.
3971
+ # 2. Tools that went through a permission prompt \u2192 permission.resolve.
3972
+ # The PermissionRequest hook set a marker; we clear it and log the
3973
+ # approval. (Denials never reach PostToolUse, so absence-of-resolve
3974
+ # means the user said no.)
3975
+ # 3. Everything else (auto-approved Bash/Read/Grep/Glob/etc.) \u2192 tool.used
3976
+ # with outcome:"auto". Previously this case dropped the call entirely;
3977
+ # now Claude's read-only / shell activity shows up in the timeline.
3978
+ ${quietHelper(bin)}
3640
3979
  sid="\${BERTRAND_SESSION:-}"
3641
3980
  [ -z "$sid" ] && exit 0
3642
3981
 
3643
3982
  input="$(cat)"
3644
3983
  ${EXTRACT_TOOL}
3645
3984
 
3985
+ # Don't double-log: AskUserQuestion has its own waiting/answered events
3986
+ [ "$tool" = "AskUserQuestion" ] && exit 0
3987
+
3646
3988
  marker="/tmp/bertrand-perm-pending-$sid"
3647
3989
  had_marker=0
3648
3990
  if [ -f "$marker" ]; then
3649
3991
  had_marker=1
3650
3992
  rm -f "$marker"
3651
- ${BIN} badge --clear &
3993
+ bq badge --clear &
3652
3994
  fi
3653
3995
 
3654
3996
  cid="\${BERTRAND_CLAUDE_ID:-}"
@@ -3660,10 +4002,6 @@ case "$tool" in
3660
4002
  Write) summary="wrote a file" ;;
3661
4003
  *) summary="edited a file" ;;
3662
4004
  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
4005
  meta="$(printf '%s' "$input" | jq --arg t "$tool" --arg d "$detail" --arg cid "$cid" '
3668
4006
  {
3669
4007
  permissions: [
@@ -3676,28 +4014,40 @@ case "$tool" in
3676
4014
  claude_id: $cid
3677
4015
  }
3678
4016
  ')"
3679
- ${BIN} update --session-id "$sid" --event tool.applied --summary "$summary" --meta "$meta"
4017
+ bq update --session-id "$sid" --event tool.applied --summary "$summary" --meta "$meta"
3680
4018
  wait
3681
4019
  exit 0
3682
4020
  ;;
3683
4021
  esac
3684
4022
 
3685
- # Other tools: only emit permission.resolve if there was a real prompt
3686
- [ "$had_marker" = "0" ] && exit 0
3687
-
4023
+ # Extract a tool-shaped detail for the timeline summary. Bash gets the
4024
+ # command, file tools get the path; everything else falls back to a generic
4025
+ # label inside the emit helper.
3688
4026
  detail=""
3689
4027
  case "$tool" in
3690
4028
  Bash) detail="$(printf '%s' "$input" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
4029
+ Read|NotebookRead) detail="$(printf '%s' "$input" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-1000)" ;;
4030
+ Glob) detail="$(printf '%s' "$input" | grep -o '"pattern":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
4031
+ Grep) detail="$(printf '%s' "$input" | grep -o '"pattern":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
4032
+ WebFetch) detail="$(printf '%s' "$input" | grep -o '"url":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-300)" ;;
4033
+ WebSearch) detail="$(printf '%s' "$input" | grep -o '"query":"[^"]*"' | head -1 | cut -d'"' -f4 | cut -c1-200)" ;;
3691
4034
  esac
3692
4035
 
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}')"
4036
+ if [ "$had_marker" = "1" ]; then
4037
+ # Prompted-then-approved path. Keep permission.resolve for back-compat
4038
+ # rendering; downstream consumers can migrate to tool.used at their pace.
4039
+ 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}')"
4040
+ else
4041
+ # Auto-approved path. Without tool.used, these calls were invisible.
4042
+ 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}')"
4043
+ fi
3694
4044
  wait
3695
4045
  `;
3696
4046
  }
3697
4047
  function userPromptScript(bin) {
3698
- const BIN = bin;
3699
4048
  return `#!/usr/bin/env bash
3700
4049
  # Hook: UserPromptSubmit \u2192 record user free-text prompt
4050
+ ${quietHelper(bin)}
3701
4051
  sid="\${BERTRAND_SESSION:-}"
3702
4052
  [ -z "$sid" ] && exit 0
3703
4053
 
@@ -3707,28 +4057,31 @@ cid="\${BERTRAND_CLAUDE_ID:-}"
3707
4057
  meta="$(printf '%s' "$input" | jq --arg cid "$cid" '{prompt: (.prompt // ""), claude_id: $cid}')"
3708
4058
  [ -z "$meta" ] && exit 0
3709
4059
 
3710
- ${BIN} update --session-id "$sid" --event user.prompt --meta "$meta"
4060
+ bq update --session-id "$sid" --event user.prompt --meta "$meta"
3711
4061
  `;
3712
4062
  }
3713
4063
  function doneScript(bin) {
3714
- const BIN = bin;
3715
4064
  return `#!/usr/bin/env bash
3716
4065
  # Hook: Stop \u2192 mark session as paused
4066
+ ${quietHelper(bin)}
3717
4067
  sid="\${BERTRAND_SESSION:-}"
3718
4068
  [ -z "$sid" ] && exit 0
3719
4069
 
3720
4070
  input="$(cat)"
3721
4071
  cid="\${BERTRAND_CLAUDE_ID:-}"
3722
- ${BIN} update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
4072
+ bq update --session-id "$sid" --event session.paused --meta "$(jq -n --arg cid "$cid" '{claude_id:$cid}')"
3723
4073
 
3724
- # Final context snapshot \u2014 capture token usage at session end
4074
+ # Final transcript reads. assistant-message dedups against the most-recent
4075
+ # AskUQ-time capture, so a Done-for-now exit (no work between AskUQ and Stop)
4076
+ # lands zero new events here; intermediate Stops with fresh assistant output
4077
+ # do land new ones.
3725
4078
  tpath="$(printf '%s' "$input" | grep -o '"transcript_path":"[^"]*"' | cut -d'"' -f4)"
3726
4079
  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" &
4080
+ bq snapshot --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
4081
+ bq assistant-message --session-id "$sid" --transcript-path "$tpath" --conversation-id "$cid" &
3729
4082
  fi
3730
4083
 
3731
- ${BIN} badge check --color '#58c142' --priority 10
4084
+ bq badge check --color '#58c142' --priority 10
3732
4085
  `;
3733
4086
  }
3734
4087
  var EXTRACT_TOOL = `tool="$(printf '%s' "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4)"`, HOOK_SCRIPTS;
@@ -4151,8 +4504,11 @@ var init_catalog = __esm(() => {
4151
4504
  "vercel.deploy": { label: "deployed", category: "integration", color: 245, detailColor: 245, skip: false },
4152
4505
  "user.prompt": { label: "prompt", category: "interaction", color: 36, detailColor: 245, skip: false },
4153
4506
  "context.snapshot": { label: "context", category: "context", color: 245, detailColor: 245, skip: true },
4507
+ "tool.used": { label: "tool", category: "work", color: 214, detailColor: 245, skip: true },
4154
4508
  "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 }
4509
+ "session.recap": { label: "session recap", category: "lifecycle", color: 33, detailColor: 245, skip: false },
4510
+ "assistant.message": { label: "claude", category: "interaction", color: 39, detailColor: 245, skip: false },
4511
+ "assistant.recap": { label: "recap", category: "interaction", color: 39, detailColor: 245, skip: false }
4156
4512
  };
4157
4513
  DEFAULT_INFO = {
4158
4514
  label: "unknown",
@@ -4192,7 +4548,7 @@ function collapsePermissions(events2) {
4192
4548
  let i = 0;
4193
4549
  while (i < events2.length) {
4194
4550
  const ev = events2[i];
4195
- if (ev.event !== "permission.request" && ev.event !== "permission.resolve") {
4551
+ if (!ROLLUP_EVENTS.has(ev.event)) {
4196
4552
  result.push(ev);
4197
4553
  i++;
4198
4554
  continue;
@@ -4200,7 +4556,7 @@ function collapsePermissions(events2) {
4200
4556
  const batch = [];
4201
4557
  while (i < events2.length) {
4202
4558
  const current = events2[i];
4203
- if (current.event !== "permission.request" && current.event !== "permission.resolve")
4559
+ if (!ROLLUP_EVENTS.has(current.event))
4204
4560
  break;
4205
4561
  batch.push(current);
4206
4562
  i++;
@@ -4209,7 +4565,7 @@ function collapsePermissions(events2) {
4209
4565
  continue;
4210
4566
  const toolCounts = new Map;
4211
4567
  for (const pev of batch) {
4212
- if (pev.event !== "permission.request")
4568
+ if (pev.event !== "permission.request" && pev.event !== "tool.used")
4213
4569
  continue;
4214
4570
  const meta = pev.meta;
4215
4571
  const tool = meta?.tool ?? "unknown";
@@ -4268,8 +4624,14 @@ function filterSkipped(events2) {
4268
4624
  function compact(events2) {
4269
4625
  return deduplicate(collapsePermissions(repairQAPairs(filterSkipped(events2))));
4270
4626
  }
4627
+ var ROLLUP_EVENTS;
4271
4628
  var init_compact = __esm(() => {
4272
4629
  init_catalog();
4630
+ ROLLUP_EVENTS = new Set([
4631
+ "permission.request",
4632
+ "permission.resolve",
4633
+ "tool.used"
4634
+ ]);
4273
4635
  });
4274
4636
 
4275
4637
  // src/cli/commands/log.ts