botmux 2.86.0 → 2.88.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.
Files changed (247) hide show
  1. package/README.en.md +27 -0
  2. package/README.md +27 -0
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +12 -1
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/liveness-gate.d.ts +38 -0
  7. package/dist/adapters/backend/liveness-gate.d.ts.map +1 -0
  8. package/dist/adapters/backend/liveness-gate.js +53 -0
  9. package/dist/adapters/backend/liveness-gate.js.map +1 -0
  10. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  11. package/dist/adapters/backend/pty-backend.js +4 -1
  12. package/dist/adapters/backend/pty-backend.js.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.d.ts +22 -1
  14. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  15. package/dist/adapters/backend/tmux-backend.js +124 -36
  16. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  17. package/dist/adapters/backend/tmux-pipe-backend.d.ts +20 -0
  18. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  19. package/dist/adapters/backend/tmux-pipe-backend.js +86 -20
  20. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  21. package/dist/adapters/backend/types.d.ts +9 -0
  22. package/dist/adapters/backend/types.d.ts.map +1 -1
  23. package/dist/adapters/backend/types.js.map +1 -1
  24. package/dist/adapters/backend/zellij-backend.js +1 -1
  25. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  26. package/dist/adapters/backend/zellij-observe-backend.d.ts +13 -0
  27. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  28. package/dist/adapters/backend/zellij-observe-backend.js +51 -12
  29. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  30. package/dist/adapters/cli/codex.d.ts.map +1 -1
  31. package/dist/adapters/cli/codex.js +8 -5
  32. package/dist/adapters/cli/codex.js.map +1 -1
  33. package/dist/adapters/cli/shared-hints.d.ts +2 -1
  34. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  35. package/dist/adapters/cli/shared-hints.js +25 -3
  36. package/dist/adapters/cli/shared-hints.js.map +1 -1
  37. package/dist/adapters/cli/traex.d.ts.map +1 -1
  38. package/dist/adapters/cli/traex.js +4 -0
  39. package/dist/adapters/cli/traex.js.map +1 -1
  40. package/dist/adapters/cli/types.d.ts +6 -0
  41. package/dist/adapters/cli/types.d.ts.map +1 -1
  42. package/dist/bot-registry.d.ts +23 -0
  43. package/dist/bot-registry.d.ts.map +1 -1
  44. package/dist/bot-registry.js +9 -0
  45. package/dist/bot-registry.js.map +1 -1
  46. package/dist/cli.d.ts.map +1 -1
  47. package/dist/cli.js +368 -16
  48. package/dist/cli.js.map +1 -1
  49. package/dist/core/command-handler.d.ts.map +1 -1
  50. package/dist/core/command-handler.js +7 -5
  51. package/dist/core/command-handler.js.map +1 -1
  52. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  53. package/dist/core/dashboard-ipc-server.js +210 -5
  54. package/dist/core/dashboard-ipc-server.js.map +1 -1
  55. package/dist/core/dashboard-rows.d.ts +3 -0
  56. package/dist/core/dashboard-rows.d.ts.map +1 -1
  57. package/dist/core/dashboard-rows.js +4 -1
  58. package/dist/core/dashboard-rows.js.map +1 -1
  59. package/dist/core/maintenance.d.ts +18 -0
  60. package/dist/core/maintenance.d.ts.map +1 -1
  61. package/dist/core/maintenance.js +58 -30
  62. package/dist/core/maintenance.js.map +1 -1
  63. package/dist/core/per-bot-env.d.ts +38 -0
  64. package/dist/core/per-bot-env.d.ts.map +1 -0
  65. package/dist/core/per-bot-env.js +79 -0
  66. package/dist/core/per-bot-env.js.map +1 -0
  67. package/dist/core/session-activity.d.ts +1 -0
  68. package/dist/core/session-activity.d.ts.map +1 -1
  69. package/dist/core/session-activity.js +7 -4
  70. package/dist/core/session-activity.js.map +1 -1
  71. package/dist/core/session-create.d.ts +62 -0
  72. package/dist/core/session-create.d.ts.map +1 -0
  73. package/dist/core/session-create.js +131 -0
  74. package/dist/core/session-create.js.map +1 -0
  75. package/dist/core/session-manager.d.ts +43 -0
  76. package/dist/core/session-manager.d.ts.map +1 -1
  77. package/dist/core/session-manager.js +306 -8
  78. package/dist/core/session-manager.js.map +1 -1
  79. package/dist/core/skills/discovery.d.ts +7 -0
  80. package/dist/core/skills/discovery.d.ts.map +1 -1
  81. package/dist/core/skills/discovery.js +51 -0
  82. package/dist/core/skills/discovery.js.map +1 -1
  83. package/dist/core/trigger-session.d.ts.map +1 -1
  84. package/dist/core/trigger-session.js +5 -2
  85. package/dist/core/trigger-session.js.map +1 -1
  86. package/dist/core/update-check.d.ts +67 -0
  87. package/dist/core/update-check.d.ts.map +1 -0
  88. package/dist/core/update-check.js +159 -0
  89. package/dist/core/update-check.js.map +1 -0
  90. package/dist/core/worker-pool.d.ts.map +1 -1
  91. package/dist/core/worker-pool.js +82 -4
  92. package/dist/core/worker-pool.js.map +1 -1
  93. package/dist/daemon.d.ts.map +1 -1
  94. package/dist/daemon.js +40 -11
  95. package/dist/daemon.js.map +1 -1
  96. package/dist/dashboard/bot-payload.d.ts +2 -0
  97. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  98. package/dist/dashboard/bot-payload.js +2 -0
  99. package/dist/dashboard/bot-payload.js.map +1 -1
  100. package/dist/dashboard/session-cleanup.d.ts +30 -0
  101. package/dist/dashboard/session-cleanup.d.ts.map +1 -0
  102. package/dist/dashboard/session-cleanup.js +47 -0
  103. package/dist/dashboard/session-cleanup.js.map +1 -0
  104. package/dist/dashboard/skill-install-request.d.ts +10 -0
  105. package/dist/dashboard/skill-install-request.d.ts.map +1 -1
  106. package/dist/dashboard/skill-install-request.js +17 -0
  107. package/dist/dashboard/skill-install-request.js.map +1 -1
  108. package/dist/dashboard/web/app.d.ts.map +1 -1
  109. package/dist/dashboard/web/app.js +29 -1
  110. package/dist/dashboard/web/app.js.map +1 -1
  111. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  112. package/dist/dashboard/web/bot-defaults.js +82 -6
  113. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  114. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  115. package/dist/dashboard/web/i18n.js +312 -2
  116. package/dist/dashboard/web/i18n.js.map +1 -1
  117. package/dist/dashboard/web/insights.d.ts.map +1 -1
  118. package/dist/dashboard/web/insights.js +355 -30
  119. package/dist/dashboard/web/insights.js.map +1 -1
  120. package/dist/dashboard/web/sessions.d.ts +2 -0
  121. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  122. package/dist/dashboard/web/sessions.js +457 -30
  123. package/dist/dashboard/web/sessions.js.map +1 -1
  124. package/dist/dashboard/web/settings.d.ts.map +1 -1
  125. package/dist/dashboard/web/settings.js +289 -0
  126. package/dist/dashboard/web/settings.js.map +1 -1
  127. package/dist/dashboard/web/skills.d.ts.map +1 -1
  128. package/dist/dashboard/web/skills.js +183 -10
  129. package/dist/dashboard/web/skills.js.map +1 -1
  130. package/dist/dashboard/web/whiteboards.d.ts +2 -0
  131. package/dist/dashboard/web/whiteboards.d.ts.map +1 -0
  132. package/dist/dashboard/web/whiteboards.js +267 -0
  133. package/dist/dashboard/web/whiteboards.js.map +1 -0
  134. package/dist/dashboard-web/app.js +879 -591
  135. package/dist/dashboard-web/index.html +1 -0
  136. package/dist/dashboard-web/style.css +528 -8
  137. package/dist/dashboard.js +472 -5
  138. package/dist/dashboard.js.map +1 -1
  139. package/dist/global-config.d.ts +6 -0
  140. package/dist/global-config.d.ts.map +1 -1
  141. package/dist/global-config.js +12 -0
  142. package/dist/global-config.js.map +1 -1
  143. package/dist/i18n/en.d.ts.map +1 -1
  144. package/dist/i18n/en.js +21 -1
  145. package/dist/i18n/en.js.map +1 -1
  146. package/dist/i18n/zh.d.ts.map +1 -1
  147. package/dist/i18n/zh.js +21 -1
  148. package/dist/i18n/zh.js.map +1 -1
  149. package/dist/im/lark/card-builder.d.ts +4 -5
  150. package/dist/im/lark/card-builder.d.ts.map +1 -1
  151. package/dist/im/lark/card-builder.js +113 -11
  152. package/dist/im/lark/card-builder.js.map +1 -1
  153. package/dist/im/lark/card-handler.d.ts +3 -2
  154. package/dist/im/lark/card-handler.d.ts.map +1 -1
  155. package/dist/im/lark/card-handler.js +174 -14
  156. package/dist/im/lark/card-handler.js.map +1 -1
  157. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  158. package/dist/im/lark/event-dispatcher.js +19 -5
  159. package/dist/im/lark/event-dispatcher.js.map +1 -1
  160. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  161. package/dist/im/lark/grant-pending.js +9 -2
  162. package/dist/im/lark/grant-pending.js.map +1 -1
  163. package/dist/index-daemon.js +5 -0
  164. package/dist/index-daemon.js.map +1 -1
  165. package/dist/platform/bind.d.ts +2 -0
  166. package/dist/platform/bind.d.ts.map +1 -0
  167. package/dist/platform/bind.js +91 -0
  168. package/dist/platform/bind.js.map +1 -0
  169. package/dist/platform/binding.d.ts +22 -0
  170. package/dist/platform/binding.d.ts.map +1 -0
  171. package/dist/platform/binding.js +33 -0
  172. package/dist/platform/binding.js.map +1 -0
  173. package/dist/platform/tunnel-client.d.ts +15 -0
  174. package/dist/platform/tunnel-client.d.ts.map +1 -0
  175. package/dist/platform/tunnel-client.js +193 -0
  176. package/dist/platform/tunnel-client.js.map +1 -0
  177. package/dist/services/bot-config-store.d.ts.map +1 -1
  178. package/dist/services/bot-config-store.js +20 -0
  179. package/dist/services/bot-config-store.js.map +1 -1
  180. package/dist/services/git-worktree.d.ts +10 -0
  181. package/dist/services/git-worktree.d.ts.map +1 -1
  182. package/dist/services/git-worktree.js +71 -26
  183. package/dist/services/git-worktree.js.map +1 -1
  184. package/dist/services/grant-prefs-store.d.ts +3 -0
  185. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  186. package/dist/services/grant-prefs-store.js +17 -2
  187. package/dist/services/grant-prefs-store.js.map +1 -1
  188. package/dist/services/insight/claude-span-reader.d.ts.map +1 -1
  189. package/dist/services/insight/claude-span-reader.js +18 -2
  190. package/dist/services/insight/claude-span-reader.js.map +1 -1
  191. package/dist/services/insight/codex-span-reader.d.ts.map +1 -1
  192. package/dist/services/insight/codex-span-reader.js +17 -1
  193. package/dist/services/insight/codex-span-reader.js.map +1 -1
  194. package/dist/services/insight/report.d.ts.map +1 -1
  195. package/dist/services/insight/report.js +48 -6
  196. package/dist/services/insight/report.js.map +1 -1
  197. package/dist/services/insight/types.d.ts +9 -0
  198. package/dist/services/insight/types.d.ts.map +1 -1
  199. package/dist/services/insight/types.js.map +1 -1
  200. package/dist/services/resumable-session-discovery.d.ts.map +1 -1
  201. package/dist/services/resumable-session-discovery.js +21 -4
  202. package/dist/services/resumable-session-discovery.js.map +1 -1
  203. package/dist/services/skill-registry-store.d.ts +1 -0
  204. package/dist/services/skill-registry-store.d.ts.map +1 -1
  205. package/dist/services/skill-registry-store.js +32 -0
  206. package/dist/services/skill-registry-store.js.map +1 -1
  207. package/dist/services/whiteboard-store.d.ts +67 -0
  208. package/dist/services/whiteboard-store.d.ts.map +1 -0
  209. package/dist/services/whiteboard-store.js +450 -0
  210. package/dist/services/whiteboard-store.js.map +1 -0
  211. package/dist/services/worktree-slug-ai.d.ts.map +1 -1
  212. package/dist/services/worktree-slug-ai.js +3 -1
  213. package/dist/services/worktree-slug-ai.js.map +1 -1
  214. package/dist/setup/bot-config-editor.d.ts +8 -0
  215. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  216. package/dist/setup/bot-config-editor.js +9 -0
  217. package/dist/setup/bot-config-editor.js.map +1 -1
  218. package/dist/skills/definitions.d.ts.map +1 -1
  219. package/dist/skills/definitions.js +118 -0
  220. package/dist/skills/definitions.js.map +1 -1
  221. package/dist/types.d.ts +15 -0
  222. package/dist/types.d.ts.map +1 -1
  223. package/dist/utils/crash-log.d.ts +22 -0
  224. package/dist/utils/crash-log.d.ts.map +1 -0
  225. package/dist/utils/crash-log.js +37 -0
  226. package/dist/utils/crash-log.js.map +1 -0
  227. package/dist/utils/input-gate.d.ts +10 -0
  228. package/dist/utils/input-gate.d.ts.map +1 -1
  229. package/dist/utils/input-gate.js +7 -0
  230. package/dist/utils/input-gate.js.map +1 -1
  231. package/dist/utils/install-diagnostics.d.ts +52 -0
  232. package/dist/utils/install-diagnostics.d.ts.map +1 -0
  233. package/dist/utils/install-diagnostics.js +155 -0
  234. package/dist/utils/install-diagnostics.js.map +1 -0
  235. package/dist/utils/install-info.d.ts +4 -0
  236. package/dist/utils/install-info.d.ts.map +1 -1
  237. package/dist/utils/install-info.js +6 -0
  238. package/dist/utils/install-info.js.map +1 -1
  239. package/dist/utils/stdio-epipe-guard.d.ts +39 -0
  240. package/dist/utils/stdio-epipe-guard.d.ts.map +1 -0
  241. package/dist/utils/stdio-epipe-guard.js +67 -0
  242. package/dist/utils/stdio-epipe-guard.js.map +1 -0
  243. package/dist/worker.js +222 -26
  244. package/dist/worker.js.map +1 -1
  245. package/dist/workflows/daemon-spawn.d.ts +2 -0
  246. package/dist/workflows/daemon-spawn.d.ts.map +1 -1
  247. package/package.json +1 -1
@@ -117,11 +117,17 @@ function toRecord(s) {
117
117
  report: s.report,
118
118
  };
119
119
  }
120
- function filterRecords(records, filter, q, cliSel = new Set()) {
120
+ function filterRecords(records, filter, q, cliSel = new Set(), scope = {}) {
121
121
  const query = q.trim().toLowerCase();
122
122
  return records.filter(rec => {
123
123
  const s = rec.session;
124
124
  const r = rec.report;
125
+ if (scope.analyzableOnly && r?.status !== 'ok')
126
+ return false;
127
+ if (scope.project && projectOf(rec) !== scope.project)
128
+ return false;
129
+ if (scope.sinceMs && Number(s.lastMessageAt ?? s.spawnedAt ?? 0) < scope.sinceMs)
130
+ return false;
125
131
  if (cliSel.size && !cliSel.has(cliIdOf(rec)))
126
132
  return false;
127
133
  if (filter === 'review' && !reportNeedsReview(r))
@@ -165,15 +171,6 @@ function renderCliChips(records, active) {
165
171
  return [chip('all', t('common.all'), records.length, active.size === 0),
166
172
  ...counts.map(c => chip(c.id, c.id, c.count, active.has(c.id)))].join('');
167
173
  }
168
- function sortRecords(records) {
169
- return [...records].sort((a, b) => {
170
- const ar = a.report;
171
- const br = b.report;
172
- const aScore = (ar?.status === 'ok' ? ar.agg.failedSpans * 6 + ar.agg.slowSpans * 3 + ar.suggestions.filter(s => s.severity === 'bad').length * 5 : 0);
173
- const bScore = (br?.status === 'ok' ? br.agg.failedSpans * 6 + br.agg.slowSpans * 3 + br.suggestions.filter(s => s.severity === 'bad').length * 5 : 0);
174
- return bScore - aScore || Number(b.session.lastMessageAt ?? 0) - Number(a.session.lastMessageAt ?? 0);
175
- });
176
- }
177
174
  function renderMetric(label, value, sub = '') {
178
175
  return `<div class="card"><div class="cv">${escapeHtml(value)}</div><div class="cl">${escapeHtml(label)}</div>${sub ? `<div class="cs">${escapeHtml(sub)}</div>` : ''}</div>`;
179
176
  }
@@ -291,27 +288,229 @@ function renderPhaseMix(report) {
291
288
  return `<i class="${phaseClass(phase)}" style="flex:${weight}" title="${escapeHtml(`${phaseLabel(phase)} · ${v.count} · ${fmtMs(v.ms)}`)}"></i>`;
292
289
  }).join('')}</div>`;
293
290
  }
294
- function renderSessionRows(records, selectedId) {
291
+ const INSIGHT_TABS = [
292
+ { key: 'overview', label: 'insights.tabOverview' },
293
+ { key: 'sessions', label: 'insights.tabSessions' },
294
+ { key: 'dist', label: 'insights.tabDist' },
295
+ { key: 'hot', label: 'insights.tabHot' },
296
+ ];
297
+ function renderTabBar(active) {
298
+ return `<div class="insight-tabs" role="tablist">${INSIGHT_TABS.map(tb => `<button type="button" class="itab${tb.key === active ? ' on' : ''}" data-itab="${tb.key}" role="tab" aria-selected="${tb.key === active}">${escapeHtml(t(tb.label))}</button>`).join('')}</div>`;
299
+ }
300
+ function projectOf(rec) {
301
+ const wd = String(rec.session.workingDir ?? '').replace(/\/+$/, '');
302
+ if (!wd)
303
+ return '';
304
+ return wd.split('/').pop() || wd;
305
+ }
306
+ function projectOptions(records) {
307
+ const m = new Map();
308
+ for (const rec of records) {
309
+ const p = projectOf(rec);
310
+ if (p)
311
+ m.set(p, (m.get(p) ?? 0) + 1);
312
+ }
313
+ return [...m.entries()].map(([id, count]) => ({ id, count })).sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
314
+ }
315
+ const TIME_WINDOWS = [
316
+ { key: 'all', label: 'insights.timeAll', days: 0 },
317
+ { key: '1d', label: 'insights.time1d', days: 1 },
318
+ { key: '7d', label: 'insights.time7d', days: 7 },
319
+ { key: '30d', label: 'insights.time30d', days: 30 },
320
+ ];
321
+ function agentMsOf(r) {
322
+ return INSIGHT_PHASES.reduce((sum, ph) => sum + (r.agg.phase?.[ph]?.ms ?? 0), 0);
323
+ }
324
+ function okReports(records) {
325
+ return records.map(r => r.report).filter((r) => !!r && r.status === 'ok');
326
+ }
327
+ function median(values) {
328
+ if (!values.length)
329
+ return 0;
330
+ const s = [...values].sort((a, b) => a - b);
331
+ const mid = Math.floor(s.length / 2);
332
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
333
+ }
334
+ // One histogram card. bins is an ordered list of {label, test}; each session value
335
+ // falls into the first matching bin. Reuses the .hbars bar styling.
336
+ function renderHistogram(title, values, bins, fmtMedian = fmtInt) {
337
+ const counts = bins.map(b => ({ label: b.label, count: values.filter(b.test).length }));
338
+ const max = Math.max(1, ...counts.map(c => c.count));
339
+ const total = Math.max(1, values.length);
340
+ const rows = counts.map(c => {
341
+ const pct = Math.max(c.count ? 4 : 0, Math.round((c.count / max) * 100));
342
+ const share = Math.round((c.count / total) * 100);
343
+ return `<div class="hbrow"><div class="hblabel">${escapeHtml(c.label)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(c.count)}<small>${share}%</small></div></div>`;
344
+ }).join('');
345
+ return `<section class="block ihist">
346
+ <div class="ihist-head"><h3>${escapeHtml(title)}</h3><span class="mut">${escapeHtml(t('insights.distMedian', { v: fmtMedian(median(values)) }))}</span></div>
347
+ <div class="hbars">${rows}</div>
348
+ </section>`;
349
+ }
350
+ function renderDistribution(records) {
351
+ const reports = okReports(records);
352
+ if (!reports.length)
353
+ return `<div class="insight-empty">${escapeHtml(t('insights.distEmpty'))}</div>`;
354
+ const spans = reports.map(r => r.agg.totalSpans);
355
+ const failed = reports.map(r => r.agg.failedSpans);
356
+ const slow = reports.map(r => r.agg.slowSpans);
357
+ const agentMin = reports.map(r => agentMsOf(r) / 60000);
358
+ const rw = reports.map(r => r.agg.readWriteRatio).filter((v) => v !== null && Number.isFinite(v));
359
+ return `<p class="mut ins-hint">${escapeHtml(t('insights.distHint'))}</p>
360
+ <div class="ihist-grid">
361
+ ${renderHistogram(t('insights.distSpans'), spans, [
362
+ { label: '0–10', test: v => v <= 10 },
363
+ { label: '11–50', test: v => v > 10 && v <= 50 },
364
+ { label: '51–200', test: v => v > 50 && v <= 200 },
365
+ { label: '201–500', test: v => v > 200 && v <= 500 },
366
+ { label: '500+', test: v => v > 500 },
367
+ ])}
368
+ ${renderHistogram(t('insights.distFailed'), failed, [
369
+ { label: '0', test: v => v === 0 },
370
+ { label: '1–2', test: v => v >= 1 && v <= 2 },
371
+ { label: '3–5', test: v => v >= 3 && v <= 5 },
372
+ { label: '6–10', test: v => v >= 6 && v <= 10 },
373
+ { label: '10+', test: v => v > 10 },
374
+ ])}
375
+ ${renderHistogram(t('insights.distSlow'), slow, [
376
+ { label: '0', test: v => v === 0 },
377
+ { label: '1–2', test: v => v >= 1 && v <= 2 },
378
+ { label: '3–5', test: v => v >= 3 && v <= 5 },
379
+ { label: '5+', test: v => v > 5 },
380
+ ])}
381
+ ${renderHistogram(t('insights.distAgentTime'), agentMin, [
382
+ { label: '<1m', test: v => v < 1 },
383
+ { label: '1–5m', test: v => v >= 1 && v < 5 },
384
+ { label: '5–30m', test: v => v >= 5 && v < 30 },
385
+ { label: '30m–2h', test: v => v >= 30 && v < 120 },
386
+ { label: '2h+', test: v => v >= 120 },
387
+ ], n => `${Math.round(n)}m`)}
388
+ ${rw.length ? renderHistogram(t('insights.distRw'), rw, [
389
+ { label: '0', test: v => v === 0 },
390
+ { label: '0–1', test: v => v > 0 && v < 1 },
391
+ { label: '1–3', test: v => v >= 1 && v < 3 },
392
+ { label: '3+', test: v => v >= 3 },
393
+ ], n => n.toFixed(1)) : ''}
394
+ </div>`;
395
+ }
396
+ function renderHotspots(records) {
397
+ const reports = okReports(records);
398
+ if (!reports.length)
399
+ return `<div class="insight-empty">${escapeHtml(t('insights.distEmpty'))}</div>`;
400
+ // Tool failure hotspots: total fails + how many sessions hit each tool.
401
+ const toolFails = new Map();
402
+ for (const r of reports) {
403
+ for (const [tool, n] of Object.entries(r.agg.failByTool ?? {})) {
404
+ const e = toolFails.get(tool) ?? { fails: 0, sessions: 0 };
405
+ e.fails += n;
406
+ e.sessions += 1;
407
+ toolFails.set(tool, e);
408
+ }
409
+ }
410
+ const tools = [...toolFails.entries()].map(([tool, v]) => ({ tool, ...v })).sort((a, b) => b.fails - a.fails).slice(0, 10);
411
+ const toolMax = Math.max(1, ...tools.map(x => x.fails));
412
+ // Slowest sessions.
413
+ const slowSessions = [...records].filter(r => r.report?.status === 'ok' && r.report.agg.slowSpans > 0)
414
+ .sort((a, b) => (b.report.agg.slowSpans) - (a.report.agg.slowSpans)).slice(0, 8);
415
+ // Projects by session + fails.
416
+ const projMap = new Map();
417
+ for (const rec of records) {
418
+ if (rec.report?.status !== 'ok')
419
+ continue;
420
+ const p = projectOf(rec);
421
+ if (!p)
422
+ continue;
423
+ const e = projMap.get(p) ?? { sessions: 0, fails: 0 };
424
+ e.sessions += 1;
425
+ e.fails += rec.report.agg.failedSpans;
426
+ projMap.set(p, e);
427
+ }
428
+ const projects = [...projMap.entries()].map(([id, v]) => ({ id, ...v })).sort((a, b) => b.fails - a.fails || b.sessions - a.sessions).slice(0, 10);
429
+ const projMax = Math.max(1, ...projects.map(x => x.sessions));
430
+ return `<p class="mut ins-hint">${escapeHtml(t('insights.hotHint'))}</p>
431
+ <div class="hot-grid">
432
+ <section class="block">
433
+ <h3>${escapeHtml(t('insights.hotToolFailures'))}</h3>
434
+ <div class="hbars">${tools.length ? tools.map(x => {
435
+ const pct = Math.max(4, Math.round((x.fails / toolMax) * 100));
436
+ return `<div class="hbrow"><div class="hblabel">${escapeHtml(x.tool)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(x.fails)}<small>${x.sessions} ${escapeHtml(t('insights.hotSessionsCol'))}</small></div></div>`;
437
+ }).join('') : `<p class="mut">${escapeHtml(t('insights.noFailures'))}</p>`}</div>
438
+ </section>
439
+ <section class="block">
440
+ <h3>${escapeHtml(t('insights.hotProjects'))}</h3>
441
+ <div class="hbars">${projects.length ? projects.map(x => {
442
+ const pct = Math.max(4, Math.round((x.sessions / projMax) * 100));
443
+ return `<div class="hbrow"><div class="hblabel">${escapeHtml(x.id)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(x.sessions)}<small>${x.fails} ${escapeHtml(t('insights.hotFailsCol'))}</small></div></div>`;
444
+ }).join('') : `<p class="mut">-</p>`}</div>
445
+ </section>
446
+ <section class="block hot-sessions">
447
+ <h3>${escapeHtml(t('insights.hotSlowSessions'))}</h3>
448
+ <div class="slist">${slowSessions.length ? slowSessions.map(rec => {
449
+ const a = rec.report.agg;
450
+ return `<button type="button" class="srow" data-session-id="${escapeHtml(rec.session.sessionId)}">
451
+ <div class="srmain"><strong>${escapeHtml(sessionTitle(rec.session))}</strong>
452
+ <small>${escapeHtml(botDisplayName(rec.session))} · ${escapeHtml(String(rec.session.cliId ?? '-'))}</small></div>
453
+ <div class="srstats"><b>${escapeHtml(t('insights.hotSlowCol'))}<em>${fmtInt(a.slowSpans)}</em></b><b>span<em>${fmtInt(a.totalSpans)}</em></b></div>
454
+ </button>`;
455
+ }).join('') : `<p class="mut">-</p>`}</div>
456
+ </section>
457
+ <p class="hot-note mut">${escapeHtml(t('insights.hotComingSoon'))}</p>
458
+ </div>`;
459
+ }
460
+ const SESS_SORTS = [
461
+ { key: 'recent', label: 'insights.sortRecent' },
462
+ { key: 'review', label: 'insights.sortReview' },
463
+ { key: 'spans', label: 'insights.sortSpans' },
464
+ { key: 'fails', label: 'insights.sortFails' },
465
+ { key: 'slow', label: 'insights.sortSlow' },
466
+ { key: 'agent', label: 'insights.sortAgent' },
467
+ ];
468
+ function renderSortBar(active) {
469
+ return `<span class="sesssort-label mut">${escapeHtml(t('insights.sortLabel'))}</span>` + SESS_SORTS.map(s => `<button type="button" class="spanchip${s.key === active ? ' on' : ''}" data-sesssort="${s.key}">${escapeHtml(t(s.label))}</button>`).join('');
470
+ }
471
+ function reviewScore(r) {
472
+ return r?.status === 'ok' ? r.agg.failedSpans * 6 + r.agg.slowSpans * 3 + r.suggestions.filter(s => s.severity === 'bad').length * 5 : 0;
473
+ }
474
+ function sortRecordsBy(records, key) {
475
+ const recency = (rec) => Number(rec.session.lastMessageAt ?? 0);
476
+ const val = (rec) => {
477
+ const r = rec.report;
478
+ const a = r?.status === 'ok' ? r.agg : null;
479
+ switch (key) {
480
+ case 'spans': return a?.totalSpans ?? -1;
481
+ case 'fails': return a?.failedSpans ?? -1;
482
+ case 'slow': return a?.slowSpans ?? -1;
483
+ case 'agent': return r?.status === 'ok' ? agentMsOf(r) : -1;
484
+ case 'review': return reviewScore(r);
485
+ default: return 0;
486
+ }
487
+ };
488
+ return [...records].sort((a, b) => key === 'recent' ? recency(b) - recency(a) : (val(b) - val(a)) || recency(b) - recency(a));
489
+ }
490
+ function renderSessionRows(records, selectedId, wide = false) {
295
491
  if (!records.length)
296
492
  return `<div class="insight-empty">${escapeHtml(t('insights.empty'))}</div>`;
297
493
  const stat = (label, val, bad = false) => `<b${bad ? ' class="bad"' : ''}>${escapeHtml(label)}<em>${escapeHtml(val)}</em></b>`;
298
- return `<div class="slist">${records.map(rec => {
494
+ return `<div class="slist${wide ? ' wide' : ''}">${records.map(rec => {
299
495
  const s = rec.session;
300
496
  const r = rec.report;
301
497
  const ok = r?.status === 'ok';
302
498
  const agg = r?.agg;
303
499
  const on = s.sessionId === selectedId ? ' on' : '';
304
500
  const review = reportNeedsReview(r) ? ' review' : '';
501
+ const reads = agg?.phase?.research?.count ?? 0;
502
+ const edits = agg?.phase?.edit?.count ?? 0;
305
503
  return `<button type="button" class="srow${on}${review}" data-session-id="${escapeHtml(s.sessionId)}">
306
504
  <div class="srmain">
307
505
  <strong>${escapeHtml(sessionTitle(s))}</strong>
308
- <small>${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))} · ${escapeHtml(relTime(s.lastMessageAt ?? s.spawnedAt ?? 0))}</small>
506
+ <small>${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))}${s.workingDir ? ` · ${escapeHtml(projectOf(rec))}` : ''} · ${escapeHtml(relTime(s.lastMessageAt ?? s.spawnedAt ?? 0))}</small>
309
507
  ${ok ? renderPhaseMix(r) : ''}
310
508
  </div>
311
509
  ${ok ? `<div class="srstats">
312
510
  ${stat(t('insights.spansShort'), fmtInt(agg.totalSpans))}
313
511
  ${stat(t('insights.failedShort'), fmtInt(agg.failedSpans), agg.failedSpans > 0)}
314
512
  ${stat(t('insights.slowShort'), fmtInt(agg.slowSpans))}
513
+ ${wide ? stat(t('insights.readsShort'), fmtInt(reads)) + stat(t('insights.editsShort'), fmtInt(edits)) + stat(t('insights.durShort'), fmtMs(agentMsOf(r))) : ''}
315
514
  ${stat(t('insights.rwShort'), agg.readWriteRatio !== null ? agg.readWriteRatio.toFixed(1) : '-')}
316
515
  </div>` : `<div class="srmsg">${escapeHtml(safeStatus(r, rec.error))}</div>`}
317
516
  </button>`;
@@ -829,11 +1028,18 @@ function renderConvoOpRow(m, open) {
829
1028
  </div>`;
830
1029
  }
831
1030
  function renderConvoOps(unit, openOps, recByTurn) {
1031
+ // An agent turn carries narration ('say': text, no event) + operations (event).
1032
+ const sayMsgs = unit.msgs.filter(m => m.text && !m.event);
1033
+ const opMsgs = unit.msgs.filter(m => m.event);
832
1034
  const worst = unit.msgs.some(m => m.severity === 'bad') ? ' sev-bad' : unit.msgs.some(m => m.severity === 'warn') ? ' sev-warn' : '';
833
- const rows = unit.msgs.map(m => renderConvoOpRow(m, openOps.has(m.id))).join('');
1035
+ // m.text already carries a trailing '' when truncated (safeScrubAndTruncate),
1036
+ // so don't append another — that double-ellipsis'd the bubble.
1037
+ const say = sayMsgs.map(m => `<div class="cbub-say"><div class="md-body">${renderPromptMarkdown(m.text)}</div></div>`).join('');
1038
+ const rows = opMsgs.map(m => renderConvoOpRow(m, openOps.has(m.id))).join('');
834
1039
  return `<div class="cbub cbub-left role-agent cbub-ops${worst}">
835
- <div class="cbub-head"><span class="tp-label tp-src tp-src-system">🤖 ${escapeHtml(t('insights.replayAgent'))}</span><span class="cbub-turn">#${escapeHtml(String(unit.turnIndex))}</span><span class="cbub-opcount">${escapeHtml(t('insights.replayOps', { count: unit.msgs.length }))}</span>${convoRecBadges(unit.turnIndex, recByTurn)}</div>
836
- <div class="cbub-ops-list">${rows}</div>
1040
+ <div class="cbub-head"><span class="tp-label tp-src tp-src-system">🤖 ${escapeHtml(t('insights.replayAgent'))}</span><span class="cbub-turn">#${escapeHtml(String(unit.turnIndex))}</span>${opMsgs.length ? `<span class="cbub-opcount">${escapeHtml(t('insights.replayOps', { count: opMsgs.length }))}</span>` : ''}${convoRecBadges(unit.turnIndex, recByTurn)}</div>
1041
+ ${say ? `<div class="cbub-saywrap">${say}</div>` : ''}
1042
+ ${rows ? `<div class="cbub-ops-list">${rows}</div>` : ''}
837
1043
  </div>`;
838
1044
  }
839
1045
  function renderConvoThread(convo, recByTurn) {
@@ -1099,6 +1305,11 @@ export function renderInsightsPage(root) {
1099
1305
  let modalPrompt = null;
1100
1306
  let modalReq = 0;
1101
1307
  let disposed = false;
1308
+ let tab = 'overview';
1309
+ let project = '';
1310
+ let timeWin = 'all';
1311
+ let showNoise = false;
1312
+ let sessSort = 'recent';
1102
1313
  root.innerHTML = `
1103
1314
  <section class="page insights-page">
1104
1315
  <div class="page-heading">
@@ -1111,6 +1322,10 @@ export function renderInsightsPage(root) {
1111
1322
  </div>
1112
1323
  <form id="insight-filters" class="filters insights-filters">
1113
1324
  <input type="search" name="q" placeholder="${escapeHtml(t('insights.search'))}">
1325
+ <select id="insight-project" class="ins-select" aria-label="${escapeHtml(t('insights.projectAll'))}"></select>
1326
+ <select id="insight-time" class="ins-select" aria-label="${escapeHtml(t('insights.timeAll'))}">
1327
+ ${TIME_WINDOWS.map(w => `<option value="${w.key}">${escapeHtml(t(w.label))}</option>`).join('')}
1328
+ </select>
1114
1329
  <div class="segmented" role="group" aria-label="${escapeHtml(t('insights.filter'))}">
1115
1330
  <button type="button" data-filter="all">${escapeHtml(t('insights.filterAll'))}</button>
1116
1331
  <button type="button" data-filter="review">${escapeHtml(t('insights.filterReview'))}</button>
@@ -1118,16 +1333,27 @@ export function renderInsightsPage(root) {
1118
1333
  <button type="button" data-filter="slow">${escapeHtml(t('insights.filterSlow'))}</button>
1119
1334
  </div>
1120
1335
  <div id="insight-cli-filter" class="spanfilter cli-filter" role="group" aria-label="${escapeHtml(t('insights.filter'))}"></div>
1336
+ <label class="ins-toggle"><input type="checkbox" id="insight-noise"> ${escapeHtml(t('insights.showAll'))}</label>
1337
+ <button type="button" id="insight-clear" class="ins-clear">${escapeHtml(t('insights.clear'))}</button>
1121
1338
  </form>
1339
+ <div id="insight-tabbar">${renderTabBar(tab)}</div>
1122
1340
  <div id="insight-status" class="insight-page-status"></div>
1123
- <div id="insight-overview"></div>
1124
- <div class="insight-workbench">
1125
- <section class="insight-list-panel">
1126
- <div class="insight-list-head"><h3>${escapeHtml(t('insights.sessions'))}</h3><span id="insight-list-subtitle"></span></div>
1341
+ <div class="insight-panel" role="tabpanel" data-tabpanel="overview"><div id="insight-overview"></div></div>
1342
+ <div class="insight-panel" role="tabpanel" data-tabpanel="sessions" hidden>
1343
+ <div id="insight-list-view">
1344
+ <div class="insight-list-head">
1345
+ <span id="insight-list-subtitle"></span>
1346
+ <div class="sesssort" id="insight-sort"></div>
1347
+ </div>
1127
1348
  <div id="insight-list"></div>
1128
- </section>
1129
- <div id="insight-detail">${renderDetailShell(undefined)}</div>
1349
+ </div>
1350
+ <div id="insight-detail-view" hidden>
1351
+ <button type="button" id="insight-back" class="ins-back">← ${escapeHtml(t('insights.backToList'))}</button>
1352
+ <div id="insight-detail">${renderDetailShell(undefined)}</div>
1353
+ </div>
1130
1354
  </div>
1355
+ <div class="insight-panel" role="tabpanel" data-tabpanel="dist" hidden><div id="insight-dist"></div></div>
1356
+ <div class="insight-panel" role="tabpanel" data-tabpanel="hot" hidden><div id="insight-hot"></div></div>
1131
1357
  <div id="insight-modal" class="insight-modal" hidden></div>
1132
1358
  <div id="insight-tip" class="ins-tip" role="tooltip" hidden></div>
1133
1359
  </section>`;
@@ -1140,31 +1366,101 @@ export function renderInsightsPage(root) {
1140
1366
  const refreshBtn = root.querySelector('#insight-refresh');
1141
1367
  const filterButtons = [...root.querySelectorAll('[data-filter]')];
1142
1368
  const cliFilterEl = root.querySelector('#insight-cli-filter');
1369
+ const distEl = root.querySelector('#insight-dist');
1370
+ const hotEl = root.querySelector('#insight-hot');
1371
+ const projectSel = root.querySelector('#insight-project');
1372
+ const timeSel = root.querySelector('#insight-time');
1373
+ const noiseToggle = root.querySelector('#insight-noise');
1374
+ const clearBtn = root.querySelector('#insight-clear');
1375
+ const tabbar = root.querySelector('#insight-tabbar');
1376
+ const panels = [...root.querySelectorAll('.insight-panel')];
1377
+ const listView = root.querySelector('#insight-list-view');
1378
+ const detailView = root.querySelector('#insight-detail-view');
1379
+ const backBtn = root.querySelector('#insight-back');
1380
+ const sortBar = root.querySelector('#insight-sort');
1381
+ // Sessions tab is list ⇆ full-width detail (not a cramped side panel): selecting
1382
+ // a session swaps to the detail view; 返回 clears it back to the list.
1383
+ function showSessionsView() {
1384
+ const detailMode = selectedId !== null;
1385
+ listView.hidden = detailMode;
1386
+ detailView.hidden = !detailMode;
1387
+ }
1388
+ function scopeOpts() {
1389
+ const w = TIME_WINDOWS.find(x => x.key === timeWin);
1390
+ const sinceMs = w && w.days > 0 ? Date.now() - w.days * 86400000 : undefined;
1391
+ return { project: project || undefined, sinceMs, analyzableOnly: !showNoise };
1392
+ }
1393
+ // Project <select> options reflect the severity+search+time scoped set (project
1394
+ // pick itself NOT applied), so the dropdown always shows reachable projects.
1395
+ function paintProjectOptions() {
1396
+ const base = filterRecords(records, filter, q, cliFilter, { ...scopeOpts(), project: undefined });
1397
+ const opts = projectOptions(base);
1398
+ const cur = project;
1399
+ projectSel.innerHTML = `<option value="">${escapeHtml(t('insights.projectAll'))}</option>` +
1400
+ opts.map(o => `<option value="${escapeHtml(o.id)}"${o.id === cur ? ' selected' : ''}>${escapeHtml(o.id)} (${o.count})</option>`).join('');
1401
+ if (cur && !opts.some(o => o.id === cur)) {
1402
+ project = '';
1403
+ projectSel.value = '';
1404
+ }
1405
+ }
1406
+ function showTab() {
1407
+ for (const p of panels)
1408
+ p.hidden = p.dataset.tabpanel !== tab;
1409
+ for (const b of tabbar.querySelectorAll('[data-itab]')) {
1410
+ const on = b.dataset.itab === tab;
1411
+ b.classList.toggle('on', on);
1412
+ b.setAttribute('aria-selected', String(on));
1413
+ }
1414
+ }
1415
+ function wireSessionButtons(host, jumpToSessions = false) {
1416
+ for (const btn of host.querySelectorAll('[data-session-id]')) {
1417
+ btn.onclick = () => {
1418
+ if (jumpToSessions && tab !== 'sessions') {
1419
+ tab = 'sessions';
1420
+ showTab();
1421
+ paint();
1422
+ }
1423
+ void selectSession(btn.dataset.sessionId ?? '');
1424
+ };
1425
+ }
1426
+ }
1143
1427
  function currentRows() {
1144
- return sortRecords(filterRecords(records, filter, q, cliFilter));
1428
+ return sortRecordsBy(filterRecords(records, filter, q, cliFilter, scopeOpts()), sessSort);
1145
1429
  }
1146
1430
  function paint() {
1147
1431
  if (disposed)
1148
1432
  return;
1149
- // Faceted CLI chips reflect the severity+search set; drop stale CLI picks that
1150
- // the other filters have emptied out so the list never gets stuck on nothing.
1151
- const cliBase = filterRecords(records, filter, q);
1433
+ // Faceted CLI chips reflect the severity+search+scope set (CLI pick itself NOT
1434
+ // applied); drop stale CLI picks the other filters emptied out so the list
1435
+ // never gets stuck on nothing.
1436
+ const cliBase = filterRecords(records, filter, q, new Set(), scopeOpts());
1152
1437
  const present = new Set(cliBase.map(cliIdOf));
1153
1438
  for (const id of [...cliFilter])
1154
1439
  if (!present.has(id))
1155
1440
  cliFilter.delete(id);
1156
1441
  cliFilterEl.innerHTML = renderCliChips(cliBase, cliFilter);
1442
+ paintProjectOptions();
1443
+ noiseToggle.checked = showNoise;
1444
+ timeSel.value = timeWin;
1157
1445
  const rows = currentRows();
1158
1446
  filterButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.filter === filter));
1159
1447
  overviewEl.innerHTML = overviewData ? renderOverview(aggregateRecords(rows)) : '';
1160
1448
  listSubtitle.textContent = t('insights.listCount', { shown: rows.length, total: records.length });
1161
- list.innerHTML = renderSessionRows(rows, selectedId);
1449
+ sortBar.innerHTML = renderSortBar(sessSort);
1450
+ // When the analyzable-only default empties the list, point at the toggle so
1451
+ // the user isn't stranded on a blank page wondering where their sessions went.
1452
+ list.innerHTML = (!rows.length && !showNoise)
1453
+ ? `<div class="insight-empty">${escapeHtml(t('insights.empty'))}<br><span class="mut">${escapeHtml(t('insights.emptyAnalyzableHint'))}</span></div>`
1454
+ : renderSessionRows(rows, selectedId, true);
1162
1455
  const selected = rows.find(r => r.session.sessionId === selectedId) ?? records.find(r => r.session.sessionId === selectedId);
1163
1456
  if (!selectedId || !selected)
1164
1457
  detail.innerHTML = renderDetailShell(undefined);
1165
- for (const btn of list.querySelectorAll('[data-session-id]')) {
1166
- btn.onclick = () => void selectSession(btn.dataset.sessionId ?? '');
1167
- }
1458
+ wireSessionButtons(list);
1459
+ showSessionsView();
1460
+ distEl.innerHTML = overviewData ? renderDistribution(rows) : '';
1461
+ hotEl.innerHTML = overviewData ? renderHotspots(rows) : '';
1462
+ wireSessionButtons(hotEl, true);
1463
+ showTab();
1168
1464
  }
1169
1465
  // Re-render the detail body in place (no refetch) after a focus/filter change.
1170
1466
  function paintDetailBody() {
@@ -1426,6 +1722,7 @@ export function renderInsightsPage(root) {
1426
1722
  }
1427
1723
  async function selectSession(sessionId) {
1428
1724
  selectedId = sessionId;
1725
+ showSessionsView();
1429
1726
  activeRec = null;
1430
1727
  detailTab = 'spans';
1431
1728
  spanFilter = 'all';
@@ -1492,6 +1789,34 @@ export function renderInsightsPage(root) {
1492
1789
  }
1493
1790
  search.oninput = () => { q = search.value; paint(); };
1494
1791
  refreshBtn.onclick = () => void refresh();
1792
+ tabbar.addEventListener('click', e => {
1793
+ const btn = e.target.closest('[data-itab]');
1794
+ if (!btn)
1795
+ return;
1796
+ tab = btn.dataset.itab || 'overview';
1797
+ paint();
1798
+ });
1799
+ backBtn.addEventListener('click', () => { selectedId = null; showSessionsView(); paint(); });
1800
+ sortBar.addEventListener('click', e => {
1801
+ const btn = e.target.closest('[data-sesssort]');
1802
+ if (!btn)
1803
+ return;
1804
+ sessSort = btn.dataset.sesssort || 'recent';
1805
+ paint();
1806
+ });
1807
+ projectSel.addEventListener('change', () => { project = projectSel.value; paint(); });
1808
+ timeSel.addEventListener('change', () => { timeWin = timeSel.value; paint(); });
1809
+ noiseToggle.addEventListener('change', () => { showNoise = noiseToggle.checked; paint(); });
1810
+ clearBtn.addEventListener('click', () => {
1811
+ q = '';
1812
+ search.value = '';
1813
+ filter = 'all';
1814
+ cliFilter.clear();
1815
+ project = '';
1816
+ timeWin = 'all';
1817
+ showNoise = false;
1818
+ paint();
1819
+ });
1495
1820
  for (const btn of filterButtons) {
1496
1821
  btn.onclick = () => {
1497
1822
  filter = btn.dataset.filter || 'all';