claude-mission-control 1.19.0 → 1.21.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/lib/collector.js CHANGED
@@ -7,7 +7,7 @@ const { readQuota } = require('./quota');
7
7
  const { fetchOauthUsage } = require('./usage');
8
8
  const { readRegistry } = require('./registry');
9
9
  const { refreshHistory, activityBuckets, weekHourHeat } = require('./history');
10
- const { scanAllTranscripts, scanFile, groupByProject, sessionTitle, combinedUsage, combinedDays, dailyCostSeries, subagentSummary, usageBreakdown, dayKey, reasonText, contextSummary, toolSummary } = require('./transcripts');
10
+ const { scanAllTranscripts, scanFile, groupByProject, sessionTitle, sessionKind, combinedUsage, combinedDays, dailyCostSeries, subagentSummary, usageBreakdown, dayKey, reasonText, contextSummary, toolSummary } = require('./transcripts');
11
11
  const { collectGitStatus } = require('./gitstatus');
12
12
  const { friendlyName } = require('./names');
13
13
  const { isIgnored } = require('./ignore');
@@ -277,6 +277,7 @@ class Collector {
277
277
  const liveByProject = new Set(
278
278
  this.raw.live.map((s) => worktreeRoot(s.cwd).root.toLowerCase())
279
279
  );
280
+ const liveIds = new Set(this.raw.live.map((s) => s.sessionId));
280
281
 
281
282
  const projects = [];
282
283
  for (const p of this.projectPaths()) {
@@ -287,6 +288,15 @@ class Collector {
287
288
  const git = this.raw.git.get(p) || null;
288
289
 
289
290
  const sessionMetas = group ? group.sessions : [];
291
+ // Automated and empty sessions still count toward spend and activity;
292
+ // they just don't take up rows in the card.
293
+ const shown = [];
294
+ const hidden = { automated: 0, empty: 0 };
295
+ for (const m of sessionMetas) {
296
+ const kind = sessionKind(m, liveIds.has(m.sessionId));
297
+ if (kind) hidden[kind]++;
298
+ else shown.push(m);
299
+ }
290
300
  const lastActivityAt = Math.max(
291
301
  sessionMetas[0] ? sessionMetas[0].lastActivityAt : 0,
292
302
  hist ? hist.lastTimestamp : 0,
@@ -309,7 +319,8 @@ class Collector {
309
319
  Date.now() - m.lastActivityAt < SPEND_WINDOW_MS ? sum + estimateCost(combinedUsage(m), pricing) : sum,
310
320
  0
311
321
  ),
312
- sessions: sessionMetas.slice(0, SESSIONS_PER_PROJECT).map((m) => {
322
+ hidden,
323
+ sessions: shown.slice(0, SESSIONS_PER_PROJECT).map((m) => {
313
324
  const { title, source } = sessionTitle(m, sessionNames);
314
325
  return {
315
326
  sessionId: m.sessionId,
@@ -508,7 +519,6 @@ class Collector {
508
519
  const forecast = this.limitForecast();
509
520
 
510
521
  // Background jobs, live or finished, with any worktree work at risk.
511
- const liveIds = new Set(this.raw.live.map((s) => s.sessionId));
512
522
  const jobWt = this.raw.jobWt || new Map();
513
523
  const jobsOut = jobs.map((j) => ({
514
524
  jobId: j.jobId,
@@ -543,6 +553,7 @@ class Collector {
543
553
  sessionId: m.sessionId,
544
554
  title,
545
555
  titleSource: source,
556
+ kind: sessionKind(m, this.raw.live.some((s) => s.sessionId === m.sessionId)),
546
557
  estCost: estimateCost(combinedUsage(m), pricing),
547
558
  assumed: costReport(combinedUsage(m), pricing).assumedModels.length > 0,
548
559
  awaySummary: m.awaySummary,
@@ -128,6 +128,7 @@ async function scanFile(abs, sessionIdFromName) {
128
128
  awaySummaryAt: null,
129
129
  aiTitle: null,
130
130
  waitingReason: null,
131
+ entrypoint: null,
131
132
  };
132
133
 
133
134
  // Head: first ~40 lines; the first line can be queue-operation without cwd.
@@ -148,6 +149,9 @@ async function scanFile(abs, sessionIdFromName) {
148
149
  const t = Date.parse(rec.timestamp);
149
150
  if (!Number.isNaN(t)) meta.startedAt = t;
150
151
  }
152
+ if (meta.entrypoint === null && rec.type === 'user' && typeof rec.entrypoint === 'string') {
153
+ meta.entrypoint = rec.entrypoint;
154
+ }
151
155
  if (meta.firstUserPrompt === null) {
152
156
  const text = extractUserText(rec);
153
157
  if (text) meta.firstUserPrompt = text.slice(0, 120);
@@ -573,6 +577,16 @@ function combinedUsage(meta) {
573
577
  return mergeUsage(mergeUsage({}, meta.usage), meta.subagentUsage);
574
578
  }
575
579
 
580
+ // Sessions nobody needs in a project list. 'automated': a one-shot claude -p
581
+ // run (recap and summarizer tools, plugin probes). 'empty': opened and closed
582
+ // without ever calling the model — only /clear, /exit and the like. A live
583
+ // session is never empty; it may just not have had its first reply yet.
584
+ function sessionKind(meta, isLive = false) {
585
+ if (meta.entrypoint === 'sdk-cli') return 'automated';
586
+ if (!isLive && !Object.keys(combinedUsage(meta)).length) return 'empty';
587
+ return null;
588
+ }
589
+
576
590
  // Cost breakdown for the transcript viewer, from the meta the collector
577
591
  // already holds — never re-reads the file. `context` and `tools` are
578
592
  // filled by the scanner once the context-pressure batch lands.
@@ -671,7 +685,7 @@ function groupByProject(sessions) {
671
685
  }
672
686
 
673
687
  module.exports = {
674
- scanAllTranscripts, scanFile, groupByProject, sessionTitle, combinedUsage, mergeUsage,
688
+ scanAllTranscripts, scanFile, groupByProject, sessionTitle, sessionKind, combinedUsage, mergeUsage,
675
689
  scanLine, mergeDays, combinedDays, dailyCostSeries, subagentSummary, usageBreakdown, dayKey, contextSummary, toolSummary,
676
690
  waitingReason, reasonText, shouldReadMeta, TOOL_RING,
677
691
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "description": "Local dashboard for Claude Code: live sessions, transcripts, costs, git status — all your projects in one place.",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -350,6 +350,17 @@
350
350
  }
351
351
  .sessions li:hover > .acts, .sessions li:focus-within > .acts { pointer-events: auto; }
352
352
  }
353
+ .hidden-note {
354
+ align-self: flex-start; margin: 6px 0 4px; padding: 2px 0;
355
+ border: none; background: none; cursor: pointer;
356
+ font: 12px var(--ui-font); color: var(--faint);
357
+ }
358
+ .hidden-note:hover { color: var(--accent); }
359
+ .hidden-note:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
360
+ .sessions + .hidden-note { border-top: 1px solid var(--line); align-self: stretch; text-align: left; padding-top: 8px; margin-top: 0; }
361
+ .d-noise { margin-top: 10px; }
362
+ .d-noise > summary { cursor: pointer; color: var(--faint); font-size: 12.5px; padding: 4px 0; }
363
+ .d-noise > summary:hover { color: var(--ink); }
353
364
  .card .new-btn { font-size: 16px; line-height: 1; padding: 2px 7px; color: var(--faint); }
354
365
  .card .new-btn:hover { color: var(--accent); }
355
366
 
@@ -939,6 +950,34 @@
939
950
  #palette-list li.sel .pl { color: var(--accent); }
940
951
  #palette-empty { color: var(--faint); font-size: 12px; padding: 12px 16px; }
941
952
 
953
+ /* ---------- first-run guide ---------- */
954
+ #guide {
955
+ background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
956
+ padding: 16px 18px 14px; margin-bottom: 22px;
957
+ }
958
+ #guide .g-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
959
+ #guide .g-head h2 { font-size: 14px; font-weight: 600; }
960
+ #guide .g-head .g-sub { color: var(--faint); font-size: 12.5px; }
961
+ #guide .g-head .spacer { flex: 1; }
962
+ #guide .g-done {
963
+ font: 500 12.5px var(--ui-font); color: var(--amber-ink); background: var(--accent);
964
+ border: none; border-radius: 6px; padding: 5px 12px; cursor: pointer;
965
+ }
966
+ #guide .g-done:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
967
+ #guide .g-grid { display: grid; gap: 12px 24px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
968
+ #guide .g-item { font-size: 13px; color: var(--muted); line-height: 1.5; }
969
+ #guide .g-item b { display: block; color: var(--ink); font-weight: 600; margin-bottom: 4px; }
970
+ #guide .g-lamps { gap: 6px; }
971
+ #guide .g-lamps li { align-items: flex-start; }
972
+ #guide .g-lamps .flap { flex: none; min-width: 84px; justify-content: center; }
973
+ #guide kbd {
974
+ font-size: 11.5px; color: var(--ink); background: var(--surface2);
975
+ border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px;
976
+ }
977
+ #guide .g-icons { list-style: none; padding: 0; margin: 0; display: grid; gap: 3px; }
978
+ #guide .g-icons li { display: flex; align-items: center; gap: 8px; }
979
+ #guide .g-icons svg { flex: none; color: var(--ink); }
980
+
942
981
  /* welcome card for fresh installs */
943
982
  #welcome {
944
983
  grid-column: 1 / -1; background: var(--surface); border: 1px solid var(--line);
@@ -965,7 +1004,7 @@
965
1004
  header .spacer, #open-pref-wrap { display: none; } /* opening terminals is a desk thing; still in settings */
966
1005
  #quota { order: 10; flex: 1 1 100%; flex-wrap: wrap; gap: 8px 14px; }
967
1006
  .meter .bar { width: 52px; }
968
- #live { padding: 12px 16px; }
1007
+ #live { padding: 12px 16px; position: static; } /* a sticky rail would cover most of a phone screen */
969
1008
  main { padding: 14px 16px; }
970
1009
  .dep-row { grid-template-columns: 1fr; padding: 10px 12px; }
971
1010
  .dep-proj { font-size: 15px; }
@@ -1020,6 +1059,7 @@
1020
1059
 
1021
1060
  <section id="live"></section>
1022
1061
  <main>
1062
+ <section id="guide" hidden aria-label="How to read this board"></section>
1023
1063
  <div id="pinned" hidden></div>
1024
1064
  <div id="git-attn" hidden></div>
1025
1065
  <details id="digest" open>
@@ -1373,6 +1413,13 @@ function liveCard(s) {
1373
1413
  </div>`;
1374
1414
  }
1375
1415
 
1416
+ // "3 automated runs, 1 empty session" — the rows a card or drawer folds away.
1417
+ function hiddenLabel(h) {
1418
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
1419
+ return [h.automated && plural(h.automated, 'automated run'), h.empty && plural(h.empty, 'empty session')]
1420
+ .filter(Boolean).join(', ');
1421
+ }
1422
+
1376
1423
  function projectCard(p) {
1377
1424
  const sessions = p.sessions.length
1378
1425
  ? `<ul class="sessions">${p.sessions.map((sess) => `
@@ -1383,7 +1430,12 @@ function projectCard(p) {
1383
1430
  <span class="s-when">${relTime(sess.lastActivityAt)}</span>
1384
1431
  <span class="acts">${renameBtn(sess.sessionId, sess.title)}${openBtn(sess.sessionId)}${copyBtn(sess.resumeCommand)}</span>
1385
1432
  </li>`).join('')}</ul>`
1386
- : '<div class="none-yet">No sessions recorded.</div>';
1433
+ : '';
1434
+ const hiddenTxt = p.hidden ? hiddenLabel(p.hidden) : '';
1435
+ const hiddenNote = hiddenTxt
1436
+ ? `<button class="hidden-note" data-detail="${esc(p.path)}" data-detail-name="${esc(p.name)}" title="One-shot claude -p runs and sessions closed without a prompt. Open the project to see them.">${esc(hiddenTxt)} hidden</button>`
1437
+ : '';
1438
+ const empty = !p.sessions.length && !hiddenTxt ? '<div class="none-yet">No sessions recorded.</div>' : '';
1387
1439
  return `<div class="card">
1388
1440
  <div class="head">
1389
1441
  ${p.isLive ? '<span class="live-dot" title="session running"></span>' : ''}
@@ -1396,7 +1448,7 @@ function projectCard(p) {
1396
1448
  ${costChip(p.spend7d, { cls: 'badge', after: ' in 7d' })}
1397
1449
  <span class="when" title="${esc(p.path)}">${relTime(p.lastActivityAt)}</span>
1398
1450
  </div>
1399
- ${sessions}
1451
+ ${sessions}${empty}${hiddenNote}
1400
1452
  </div>`;
1401
1453
  }
1402
1454
 
@@ -1591,7 +1643,8 @@ function render(state) {
1591
1643
  const now = Date.now();
1592
1644
  const active = [], dormant = [];
1593
1645
  for (const p of state.projects || []) {
1594
- const isDormant = !p.isLive && (!p.lastActivityAt || now - p.lastActivityAt > DORMANT_MS);
1646
+ const noiseOnly = !p.sessions.length && p.hidden && (p.hidden.automated || p.hidden.empty);
1647
+ const isDormant = !p.isLive && (noiseOnly || !p.lastActivityAt || now - p.lastActivityAt > DORMANT_MS);
1595
1648
  (isDormant ? dormant : active).push(p);
1596
1649
  }
1597
1650
  patch($('#grid'), active.length || dormant.length
@@ -1599,7 +1652,7 @@ function render(state) {
1599
1652
  : `<div id="welcome">
1600
1653
  <h2><span class="tilde">❯</span> Welcome to Claude Mission Control</h2>
1601
1654
  <p>No Claude Code projects found yet. Run <code>claude</code> in any folder and it appears here automatically — live status, transcripts, git state, costs, the lot.</p>
1602
- <p>Tip: press <code>⌘K</code> to jump anywhere, <code>/</code> to search, and click ⚙ for settings.</p>
1655
+ <p>Tip: press <code>⌘K</code> to jump anywhere, <code>/</code> to search, and <code>?</code> for a quick guide.</p>
1603
1656
  </div>`);
1604
1657
  const d = $('#dormant');
1605
1658
  d.hidden = dormant.length === 0;
@@ -1851,8 +1904,7 @@ function toolsetHtml(t, projectPath) {
1851
1904
  function renderDetail(d, name) {
1852
1905
  $('#d-title').textContent = name;
1853
1906
  $('#d-path').textContent = d.path;
1854
- const sessions = d.sessions.length
1855
- ? `<ul class="d-sessions">${d.sessions.map((s) => `
1907
+ const sessionRow = (s) => `
1856
1908
  <li>
1857
1909
  <span class="s-title" data-session="${esc(s.sessionId)}" title="${esc(titleTip(s.model))}">${
1858
1910
  s.worktree ? `<span class="wt mono">⎇ ${esc(s.worktree)}</span> ` : ''}${esc(s.title)}</span>${noteMark(s)}
@@ -1860,7 +1912,16 @@ function renderDetail(d, name) {
1860
1912
  ${costChip(s.estCost, { assumed: s.assumed })}
1861
1913
  <span class="s-when">${relTime(s.lastActivityAt)}</span>
1862
1914
  <span class="acts">${renameBtn(s.sessionId, s.title)}${openBtn(s.sessionId)}${copyBtn(s.resumeCommand)}</span>
1863
- </li>`).join('')}</ul>`
1915
+ </li>`;
1916
+ const real = d.sessions.filter((s) => !s.kind);
1917
+ const noise = d.sessions.filter((s) => s.kind);
1918
+ const noiseTxt = hiddenLabel({
1919
+ automated: noise.filter((s) => s.kind === 'automated').length,
1920
+ empty: noise.filter((s) => s.kind === 'empty').length,
1921
+ });
1922
+ const sessions = d.sessions.length
1923
+ ? (real.length ? `<ul class="d-sessions">${real.map(sessionRow).join('')}</ul>` : '') +
1924
+ (noise.length ? `<details class="d-noise"><summary>${esc(noiseTxt)}</summary><ul class="d-sessions">${noise.map(sessionRow).join('')}</ul></details>` : '')
1864
1925
  : null;
1865
1926
  const caps = [...d.skills, ...d.agents.map((a) => ({ ...a, name: `agent: ${a.name}` })),
1866
1927
  ...d.commands.map((c) => ({ ...c, name: `/${c.name}` }))];
@@ -1897,7 +1958,7 @@ function renderDetail(d, name) {
1897
1958
  const tsOff = ts ? KINDS.reduce((n, k) => n + (ts[k] || []).filter((r) => !r.enabled).length, 0) : 0;
1898
1959
  $('#d-body').innerHTML =
1899
1960
  muteBtn +
1900
- dSection(`Sessions — ${d.sessions.length}`, sessions, 'No sessions recorded.') +
1961
+ dSection(`Sessions — ${d.sessions.filter((s) => !s.kind).length}`, sessions, 'No sessions recorded.') +
1901
1962
  dSection(`What changed — last ${(d.commits || []).length} commits`, commitRows ? commitSummaryLine(d.commitSummary) + `<ul class="d-sessions">${commitRows}</ul>` : null, 'No git history here.') +
1902
1963
  dSection('CLAUDE.md', d.claudeMd.length ? dFiles(d.claudeMd, 'name') : null, 'No CLAUDE.md — sessions here start without project instructions.') +
1903
1964
  dSection(`Memory — ${d.memory.length} file${d.memory.length === 1 ? '' : 's'}`, d.memory.length ? dFiles(d.memory, 'name') : null, 'No memory yet.') +
@@ -3147,7 +3208,7 @@ document.addEventListener('click', async (e) => {
3147
3208
 
3148
3209
  document.addEventListener('click', (e) => {
3149
3210
  const el = e.target.closest('[data-detail]');
3150
- if (el) openDetail(el.dataset.detail, el.textContent);
3211
+ if (el) openDetail(el.dataset.detail, el.dataset.detailName || el.textContent);
3151
3212
  });
3152
3213
  $('#d-close').addEventListener('click', closeDetail);
3153
3214
  $('#drawer-backdrop').addEventListener('click', closeDetail);
@@ -3729,6 +3790,7 @@ function paletteItems(q) {
3729
3790
  const items = [];
3730
3791
  const st = lastState || { projects: [], liveSessions: [] };
3731
3792
  items.push({ k: '⚙', label: 'Settings', hint: 'rename, hide, terminal, notifications', run: openSettings });
3793
+ items.push({ k: '?', label: 'Show the guide', hint: 'what the lamps, rows and buttons do', run: () => { showGuide(true, true); window.scrollTo({ top: 0 }); } });
3732
3794
  items.push({ k: '▦', label: 'Mission Control', hint: 'every live session at once', run: openMissionControl });
3733
3795
  items.push({ k: '⬢', label: 'Hive', hint: 'live sessions as characters · who is winning', run: openHive });
3734
3796
  items.push({ k: '★', label: 'Hall of Fame', hint: 'your best sessions by award · share cards', run: () => openFame() });
@@ -3832,6 +3894,58 @@ document.addEventListener('keydown', (e) => {
3832
3894
  }
3833
3895
  });
3834
3896
 
3897
+ // ---------- first-run guide: shown until dismissed, back with ? or the palette ----------
3898
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
3899
+ function guideSeen() { try { return localStorage.getItem('guideSeen') === '1'; } catch { return true; } }
3900
+ function renderGuide() {
3901
+ const icon = (id) => $(`#${id} svg`)?.outerHTML || '';
3902
+ const mod = isMac ? '⌘' : 'Ctrl';
3903
+ return `<div class="g-head">
3904
+ <h2>How to read this board</h2>
3905
+ <span class="g-sub">Press <kbd>?</kbd> to bring this back.</span>
3906
+ <span class="spacer"></span>
3907
+ <button class="g-done" id="guide-done">Got it</button>
3908
+ </div>
3909
+ <div class="g-grid">
3910
+ <div class="g-item"><b>Status lamps</b>
3911
+ <ul class="g-icons g-lamps">
3912
+ <li><span class="flap st st-waiting">needs you</span> Claude is waiting on you. What it's asking shows under the title.</li>
3913
+ <li><span class="flap st st-busy">busy</span> Claude is working.</li>
3914
+ </ul></div>
3915
+ <div class="g-item"><b>Sessions</b>
3916
+ Click a session title to read its transcript, live if it's running. Hover a row to rename it, open it in your terminal, or copy its resume command.</div>
3917
+ <div class="g-item"><b>Find anything</b>
3918
+ <kbd>${mod}</kbd> <kbd>K</kbd> jumps to any project, session or view. <kbd>/</kbd> searches inside every transcript. Click a project name for its details.</div>
3919
+ <div class="g-item"><b>Top-right buttons</b>
3920
+ <ul class="g-icons">
3921
+ <li>${icon('mc-btn')} Mission Control: every live session side by side</li>
3922
+ <li>${icon('hive-btn')} Hive: live sessions ranked, plus Hall of Fame</li>
3923
+ <li>${icon('bell-btn')} Catch up on what happened while you were away</li>
3924
+ <li>${icon('gear')} Settings, themes and your phone view</li>
3925
+ </ul></div>
3926
+ </div>`;
3927
+ }
3928
+ function showGuide(show, focus = false) {
3929
+ const g = $('#guide');
3930
+ if (show) {
3931
+ g.innerHTML = renderGuide();
3932
+ g.hidden = false;
3933
+ if (focus) $('#guide-done').focus({ preventScroll: true });
3934
+ } else g.hidden = true;
3935
+ }
3936
+ document.addEventListener('click', (e) => {
3937
+ if (!e.target.closest('#guide-done')) return;
3938
+ try { localStorage.setItem('guideSeen', '1'); } catch {}
3939
+ showGuide(false);
3940
+ });
3941
+ document.addEventListener('keydown', (e) => {
3942
+ if (e.key !== '?' || e.metaKey || e.ctrlKey || /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '')) return;
3943
+ e.preventDefault();
3944
+ showGuide($('#guide').hidden, true);
3945
+ if (!$('#guide').hidden) window.scrollTo({ top: 0 });
3946
+ });
3947
+ if (!guideSeen()) showGuide(true);
3948
+
3835
3949
  poll();
3836
3950
  connect();
3837
3951
  setInterval(poll, 60_000);