claude-mission-control 1.9.0 → 1.10.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/README.md CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  A local, zero-dependency dashboard for all your [Claude Code](https://claude.com/claude-code) projects. Live sessions pinned at the top — including whether one is waiting on your input — with a card per project below: recent sessions, git status, a 14-day activity sparkline, and one-click resume commands.
4
4
 
5
- Everything is read from local files under `~/.claude`, and it never writes to Claude's data — read-only. No API keys to configure. The only automatic network call is to Anthropic's own usage API for your quota meters, authenticated with the Keychain credentials Claude Code already holds (kept in memory, toggleable in settings). Plain Node.js (which Claude Code already requires), zero npm dependencies, macOS.
5
+ Everything is read from local files under `~/.claude`, and it never writes to Claude's data — read-only. No API keys to configure. The only automatic network call is to Anthropic's own usage API for your quota meters, authenticated with the Keychain credentials Claude Code already holds (kept in memory, toggleable in settings). Plain Node.js (which Claude Code already requires), zero npm dependencies. macOS first, Linux supported, Windows experimental.
6
6
 
7
7
  **Requirements:** macOS or Linux (or Windows, experimental — see below), Node.js ≥ 18, Claude Code.
8
8
 
9
- ![Claude Dashboard, dark mode — live sessions, unpushed-work strip, and the daily digest](docs/screenshots/dashboard-dark.png)
9
+ ![Claude Dashboard, dark mode — live sessions, the pinned strip, unpushed work, and today's digest](docs/screenshots/dashboard-dark.png)
10
+
11
+ *Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`) — the projects are invented.*
10
12
 
11
13
  ## Try it in one command
12
14
 
@@ -73,36 +75,50 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
73
75
 
74
76
  ## What you're looking at
75
77
 
76
- - **Departures** — every live `claude` CLI session as a row on the board: start time, project, current task, model, elapsed time, and a split-flap status cell. The cell flips to an amber **needs you** when a session is waiting on your input — readable from across the room.
78
+ - **Departures** — every live `claude` CLI session as a row on the board: start time, project, current task, model, elapsed time, and a split-flap status cell. The cell flips to an amber **needs you** when a session is waiting on your input — readable from across the room. A second line under the title says what it needs: `?` for a question Claude asked (with its options), `🔒` for a tool call awaiting permission (with the command or file), or the last question in Claude's reply, or failing that the first line of its final paragraph.
79
+ - **Mission Control** — the `▦` button in the header (or `⌘K` → Mission Control) tiles every live session side by side, each pane following its own transcript. One screen for "what is everything doing right now".
80
+ - **Agents** — a session running subagents or an agent team shows them under its Mission Control pane: name, type, model, and a pulsing `●` while an agent has written in the last two minutes, `○` once it has gone quiet (idle teammates stay alive, so quiet is not finished), with the team name when there is one. Finished sessions list them at the top of the transcript.
81
+ - **Pinned** — `☆ pin` on any transcript sticks that session to a strip under the departures board, so the thing you care about stays in view whether it's running or not.
77
82
  - **Digest** — what happened across every project, grouped by day. Each entry shows the session's recap (Claude's own "away summary" where one exists — click to expand), how many tasks it completed, and an open button. Switch the window between day / 3 days / week; collapses out of the way and remembers your choice.
78
83
  - **Project cards** — sorted by last activity. Branch chip, `●n` uncommitted changes, `↑n` unpushed commits. The sparkline is prompts per day for the last two weeks. Each session row has an `open ⬈` button that resumes the session in your terminal (new window, right directory) or imports it into the Claude desktop app when it's installed — the **Open in** selector in the header names whichever terminal you've configured (auto-detected on first run). The `⧉` button copies the `claude --resume` command instead.
79
- - **Header meters** — your plan and rate-limit tier (Free/Pro/Max/Enterprise/API, detected from Claude Code's local account cache), live session and weekly usage from Anthropic's usage API via your existing Claude Code sign-in, plus extra-usage spend. First run may show one macOS Keychain prompt — click Always Allow.
80
- ![Transcript viewer live-following a running session — this one shows the session that built the dashboard](docs/screenshots/transcript.png)
84
+ - **Header meters** — your plan and rate-limit tier (Free/Pro/Max/Enterprise/API, detected from Claude Code's local account cache), live session and weekly usage from Anthropic's usage API via your existing Claude Code sign-in, plus extra-usage spend. Set a **weekly budget** in settings and a third meter tracks estimated spend against it, with one alert each at 75 / 90 / 100%. First run may show one macOS Keychain prompt — click Always Allow.
85
+
86
+ ![Mission Control — every live session tiled side by side, each pane following its own transcript](docs/screenshots/mission-control.png)
87
+
88
+ ![Transcript viewer live-following a running session — prompts, replies as markdown, tool calls as one-liners](docs/screenshots/transcript.png)
81
89
 
82
90
  - **Transcripts** — click any session title (cards, digest, search results, project drawer) to read the conversation: your prompts, Claude's replies rendered as markdown, tool calls as compact one-liners, and away-summaries highlighted. Long sessions show the newest ~1200 events. **Running sessions follow live** — a `● live` badge appears, new turns stream in every few seconds, and the view sticks to the bottom unless you've scrolled up to read.
83
- - **Search** — the header box searches every prompt you've ever sent plus all session titles (Enter to run, 2+ characters). Narrow with `project:name` or `since:7d` / `since:2026-08-01`. Clicking a prompt result opens the transcript scrolled to the matching turn.
91
+ - **Search** — the header box searches every prompt you've ever sent plus all session titles (Enter to run, 2+ characters). Narrow with `project:name` or `since:7d` / `since:2026-08-01`. Clicking a prompt result opens the transcript scrolled to the matching turn. If the answer isn't in a prompt, **search inside full transcripts** runs the same query across the conversation bodies — slower, so it's a second click rather than the default.
84
92
  - **Command palette** — `⌘K` from anywhere: fuzzy-jump to any project or session, watch a live session, start a new one, open settings or stats. Arrow keys + Enter.
85
93
  - **Stats** — click the weekly bar chart in the header: a 26-week activity heatmap, your busiest hours, a weekly-rhythm grid (prompts by day of week and hour), estimated spend per day for the last 90 days, and an all-time per-model token/cost breakdown. The daily spend history is computed from your existing transcripts, so it's full from the first run.
94
+ - **Your week with Claude** — a 7-day report (`⌘K`, or the button at the top of stats): sessions, prompts, estimated spend against your budget, busiest day and hour, models used, and how long you typically take to answer a question. Exports as markdown.
95
+ - **Day timeline** — `⌘K` → Day timeline: the last 24 hours as one lane per project, with amber ticks where a session needed you.
96
+ - **Catch-up** — the `🔔` in the header keeps every notification the server has fired since it started, with an unread count. Useful after a morning away from the desk.
86
97
  - **Export** — any transcript downloads as clean markdown via the `export ⇩` button.
98
+ - **Changes** — a `Δ n` chip on any session row counts the files Claude edited. Click it (or `changes` in the transcript header) for the list with `+added −removed` per file and a unified diff of what the session changed, from the pre-edit backup Claude Code keeps to the file as it is on disk now. Files with several checkpoints also offer each step (`v1 → v2`). Read-only: nothing here reverts anything.
99
+ - **Notes** — `note` in the transcript header attaches a free-text note to a session (⌘⏎ saves, empty removes). Rows with a note show `≡`, hover to read it, and notes are searchable from the header box. Stored in `config.json` under `sessionNotes`.
87
100
  - **New session** — the `⊕` button on a project card opens a fresh terminal window in that project running `claude`.
88
101
  - **Cost trend** — the small bar chart in the header is estimated cost per week for the last 8 weeks (hover for numbers). Costs include subagent tokens.
89
102
  - **Models everywhere** — every session shows which model ran it (live cards, digest, session lists), and the stats view breaks down usage per model and per project.
90
103
  - **Claude.ai chats** — import the official export from claude.ai (Settings → Privacy → Export data, then feed `conversations.json` to ⚙ settings here) and your chats become browsable (`⌘K` → Claude.ai chats) and full-text searchable next to your coding sessions. Stored slimmed in your local config dir, gitignored, never uploaded anywhere.
91
104
 
92
105
  ![Stats view: activity heatmap, busiest hours, weekly rhythm, 90-day spend, and per-model cost breakdowns](docs/screenshots/stats.png)
93
- - **Project details** — click any project's name for a slide-over with its full session list, rendered CLAUDE.md, per-project memory files, skills/agents/commands from `.claude/`, and settings (permissions, MCP servers, allowed tools). Read-only; also a quick audit of which projects are missing instructions or memory. Esc closes.
106
+
107
+ - **Project details** — click any project's name for a slide-over with its full session list, recent git commits, rendered CLAUDE.md, per-project memory files, skills/agents/commands from `.claude/`, and settings (permissions, MCP servers, allowed tools). Read-only; also a quick audit of which projects are missing instructions or memory. Esc closes.
108
+ - **Plugins & MCP** — `⌘K` → Plugins & MCP (or the button in settings) lists every installed plugin with its marketplace, version, on/off state and a `stale` flag when its marketplace was refreshed after the plugin was last updated, plus every MCP server with its scope, the projects that use it, an `off` chip for servers you have disabled in a project, and an amber `needs auth` when Claude Code says so. Read-only; server secrets never reach the browser.
94
109
  - **Unpushed work strip** — an amber band listing every repo with uncommitted changes (`●n`) or unpushed commits (`↑n`), sorted by recent activity. Disappears when everything's clean.
95
110
  - **Dormant** — projects with no activity for 60+ days, tucked away at the bottom.
96
111
  - Worktree sessions (`.claude/worktrees/…`) are folded into their parent project and badged `⎇`.
97
- - **Notifications** — the moment any session flips to "waiting for input", you get a macOS notification (with sound) naming the project. Fires once per wait, never on server restart. Mute a single noisy project from its slide-over (the bell button at the top), or disable everything with `CLAUDE_DASH_NOTIFY=0` in the plist. Notifications arrive via Script Editor/osascript — if you don't see them, allow it under System Settings → Notifications.
112
+ - **Notifications** — the moment any session flips to "waiting for input", you get a macOS notification (with sound) naming the project and what it needs — the question, the command awaiting permission, or Claude's last line. Fires once per wait, never on server restart. Mute a single noisy project from its slide-over (the bell button at the top), or disable everything with `CLAUDE_DASH_NOTIFY=0` in the plist. Notifications arrive via Script Editor/osascript — if you don't see them, allow it under System Settings → Notifications.
98
113
  - **Cost estimates** — the header shows the estimated list-price value of the last 7 days across all projects; each project card and digest entry shows its share. Computed from token usage in the transcripts at Anthropic list rates (cache reads at 0.1×, cache writes at 1.25×). On a subscription plan these are relative weights, not billed dollars — use them to see where your usage goes. Subagent tokens are included.
99
114
  - **Stuck flag** — a session that's "busy" but has written nothing to its transcript for 10+ minutes gets an amber `quiet Nm` cell; at 20 minutes you get one notification. It's a hint, not a verdict — a session waiting on slow background work can look the same.
115
+ - **Idle reminder** — a session left on **needs you** for an hour (configurable in settings, `0` to turn off) gets one more notification and a `still waiting` entry in the catch-up bell, and its elapsed cell turns amber with the wait time. Nothing repeats until you answer it.
100
116
 
101
117
  ![Light mode — the same board, printed on paper](docs/screenshots/dashboard-light.png)
102
118
 
103
119
  ## Menu bar companion
104
120
 
105
- A SwiftBar plugin lives in `menubar/claude-dash.15s.sh`. The menu bar shows `❯ N` while sessions run, `❯ N⚠` in amber when one is waiting on you, and `❯ N?` when a busy session has gone quiet. The dropdown lists live sessions, repos with unpushed work, the 7-day estimate, and an "Open dashboard" link.
121
+ A SwiftBar plugin lives in `menubar/claude-dash.15s.sh`. The menu bar shows `❯ N` while sessions run, `❯ N⚠` in amber when one is waiting on you, and `❯ N?` when a busy session has gone quiet, and `❯ N⚠⏳` when one of them has waited past the idle reminder. The dropdown lists live sessions, repos with unpushed work, the 7-day estimate, and an "Open dashboard" link.
106
122
 
107
123
  Setup: `brew install --cask swiftbar`, then point SwiftBar's plugin folder at this repo's `menubar/` directory. The plugin refreshes every 15 seconds (rename the file to change the interval).
108
124
 
@@ -112,8 +128,13 @@ The ⚙ gear in the header opens settings — no JSON editing required:
112
128
 
113
129
  - **Notifications** on/off (writes `config.json`); per-project mute lives on each project's slide-over
114
130
  - **Terminal** for open/new-session buttons: Ghostty, iTerm2, or Terminal.app, auto-detected (`config.json`)
115
- - **Rename any project** (writes `names.json`) or **hide it** and its whole subtree (writes `ignore.json`), with an unhide list below
131
+ - **Claude desktop app**: lets `open ⬈` hand a session to the Claude app instead of a terminal
132
+ - **Usage meters** on/off — this is the switch for the one automatic network call
116
133
  - **Theme**: Departures board (follows system light/dark), Phosphor, Amber CRT, Midnight, or Newsprint (`config.json`)
134
+ - **Weekly budget** in dollars — drives the header meter and the 75 / 90 / 100% alerts; `0` turns it off
135
+ - **Idle reminder** in minutes — one extra notification when a session has waited that long; `0` turns it off
136
+ - **Rename any project** (writes `names.json`) or **hide it** and its whole subtree (writes `ignore.json`), with an unhide list below
137
+ - **Claude.ai chats**: pick the `conversations.json` from your claude.ai export to make those chats searchable here
117
138
  - **Updates**: "check for updates" asks GitHub only when you click; when a new release is out, **update now** pulls it in place (git or npm installs) and service installs restart themselves on the new version
118
139
 
119
140
  Everything saves instantly; the underlying files stay hand-editable. Keyboard: `⌘K` for the palette, `/` for search, `Esc` closes anything.
@@ -133,6 +154,7 @@ Edit `names.json` to control how projects are titled:
133
154
  Unlisted projects fall back to a cleaned-up folder name. Changes are picked up automatically — no restart needed.
134
155
 
135
156
  Sessions can be renamed too: click the ✎ next to any session title (live board, project cards, pinned strip, or a project's slide-over), type a name, and press Enter. Escape cancels; an empty name goes back to the automatic title. Custom names live in `config.json` under `sessionNames`, keyed by session id, and win over the AI-generated or first-prompt title everywhere.
157
+ Notes work the same way: `sessionNotes` in `config.json`, keyed by session id, editable by hand.
136
158
 
137
159
  ## Hiding projects
138
160
 
@@ -147,6 +169,7 @@ Edit `ignore.json` — an array of absolute path prefixes. A project is hidden i
147
169
  | `CLAUDE_DASH_NOTIFY` | unset | `0` = disable macOS notifications |
148
170
  | `CLAUDE_DASH_HOST` | `127.0.0.1` | Bind address — see below before changing |
149
171
  | `CLAUDE_DASH_DEMO` | unset | `1` = serve believable fake data (screenshots, trying it without Claude history) |
172
+ | `CLAUDE_DASH_OPEN` | unset | `1` = open the browser on startup (what `npx` sets for you) |
150
173
  | `CLAUDE_DASH_CONFIG_DIR` | repo dir | Where config.json/names.json/ignore.json live (auto-falls back to `~/.config/claude-dashboard`) |
151
174
 
152
175
  The server binds to `127.0.0.1` only by default.
@@ -173,6 +196,14 @@ The recommended path is [Tailscale](https://tailscale.com): install it on the Ma
173
196
  - Restart after pulling changes: `launchctl kickstart -k gui/$(id -u)/com.claude-dashboard`
174
197
  - Tests: `node --test test/pure-logic.test.js`
175
198
 
199
+ ## Demo mode
200
+
201
+ ```bash
202
+ CLAUDE_DASH_DEMO=1 node server.js
203
+ ```
204
+
205
+ Every `/api/*` route serves invented data from `lib/demo.js` instead of reading `~/.claude` — two live sessions, a handful of projects, a full transcript. Nothing touches your own history, so it's the safe way to try the UI, record a walkthrough, or take screenshots. Every image in this README comes from it. Edit `lib/demo.js` if you want different fixtures.
206
+
176
207
  ## Data sources
177
208
 
178
209
  | What | Where |
package/lib/changes.js ADDED
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ // Files Claude edited in a session, from the file-history records in the
3
+ // transcript, plus read-only access to the backups Claude Code keeps under
4
+ // ~/.claude/file-history/<sessionId>/. Pure helpers first; fs code below.
5
+ const path = require('path');
6
+ const fsp = require('fs/promises');
7
+ const { CLAUDE_DIR } = require('./paths');
8
+ const { diffLines } = require('./diff');
9
+
10
+ const FILE_HISTORY_DIR = path.join(CLAUDE_DIR, 'file-history');
11
+ const BACKUP_NAME = /^[0-9a-f]{8,32}@v\d+$/;
12
+
13
+ // changes: { [realPath]: { versions: [{ v, backup, at }], first, last } }
14
+ // `backup` is null only for v1 of a file Claude created (nothing existed
15
+ // before it, so there is no pre-edit backup to point at). A null name at any
16
+ // other version means the file already has a real earlier backup; ignore it.
17
+ function recordChange(changes, realPath, backup) {
18
+ if (!realPath || !backup) return changes;
19
+ const v = Number(backup.version) || 0;
20
+ if (!backup.backupFileName && v !== 1) return changes;
21
+ const at = backup.backupTime ? Date.parse(backup.backupTime) : NaN;
22
+ const e = changes[realPath] || (changes[realPath] = { versions: [], first: null, last: null });
23
+ if (!e.versions.some((x) => x.v === v)) {
24
+ e.versions.push({ v, backup: backup.backupFileName ? String(backup.backupFileName) : null, at: Number.isNaN(at) ? null : at });
25
+ e.versions.sort((x, y) => x.v - y.v);
26
+ }
27
+ if (!Number.isNaN(at)) {
28
+ if (e.first === null || at < e.first) e.first = at;
29
+ if (e.last === null || at > e.last) e.last = at;
30
+ }
31
+ return changes;
32
+ }
33
+
34
+ function mergeChanges(target, src) {
35
+ for (const [p, e] of Object.entries(src || {})) {
36
+ for (const ver of e.versions) {
37
+ recordChange(target, p, { backupFileName: ver.backup, version: ver.v, backupTime: ver.at ? new Date(ver.at).toISOString() : null });
38
+ }
39
+ }
40
+ return target;
41
+ }
42
+
43
+ // Real path for a file-history entry: the key/trackingPath can be an odd
44
+ // encoded form, but realParentDir is always the true directory.
45
+ function realPathOf(key, backup) {
46
+ if (!backup || !backup.realParentDir || !key) return null;
47
+ return path.join(backup.realParentDir, path.basename(String(key)));
48
+ }
49
+
50
+ const SESSION_ID = /^[0-9a-f-]{8,64}$/i;
51
+
52
+ function changeFiles(changes) {
53
+ return Object.entries(changes || {})
54
+ .sort((a, b) => (b[1].last || 0) - (a[1].last || 0) || a[0].localeCompare(b[0]))
55
+ .map(([p, e], n) => ({ n, path: p, versions: e.versions, first: e.first, last: e.last }));
56
+ }
57
+
58
+ function relPath(abs, root) {
59
+ const r = String(root || '').replace(/\/+$/, '');
60
+ return r && abs.startsWith(r + '/') ? abs.slice(r.length + 1) : abs;
61
+ }
62
+
63
+ function backupPath(sessionId, backupName) {
64
+ if (!SESSION_ID.test(String(sessionId)) || !BACKUP_NAME.test(String(backupName))) return null;
65
+ return path.join(FILE_HISTORY_DIR, sessionId, backupName);
66
+ }
67
+
68
+ async function defaultRead(abs) {
69
+ try {
70
+ return await fsp.readFile(abs, 'utf8');
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ // A null backup (v1 of a created file) has no file to read: the file didn't
77
+ // exist before, so the "original" is the empty string, not a missing read.
78
+ async function readVersion(sessionId, file, v, readFile = defaultRead) {
79
+ const ver = file.versions.find((x) => x.v === v);
80
+ if (!ver) return null;
81
+ if (ver.backup === null) return '';
82
+ const p = backupPath(sessionId, ver.backup);
83
+ return p ? readFile(p) : null;
84
+ }
85
+
86
+ async function sessionChangeList({ sessionId, changes, root, readFile = defaultRead }) {
87
+ const files = [];
88
+ for (const f of changeFiles(changes)) {
89
+ const original = await readVersion(sessionId, f, f.versions[0].v, readFile);
90
+ const disk = await readFile(f.path);
91
+ const d = original === null ? { added: 0, removed: 0, tooLarge: false } : diffLines(original, disk || '');
92
+ files.push({
93
+ n: f.n, path: f.path, rel: relPath(f.path, root),
94
+ versions: f.versions.map((x) => x.v), first: f.first, last: f.last,
95
+ exists: disk !== null, missingBackup: original === null,
96
+ added: d.added, removed: d.removed, tooLarge: !!d.tooLarge, truncated: !!d.truncated,
97
+ });
98
+ }
99
+ return { files };
100
+ }
101
+
102
+ // Normalise the `to` side of a diff request: anything that is not a usable
103
+ // version number means "the file on disk".
104
+ function diffTarget(to) {
105
+ if (to === undefined || to === null || to === 'disk') return 'disk';
106
+ const n = Number(to);
107
+ return Number.isInteger(n) && n > 0 ? n : 'disk';
108
+ }
109
+
110
+ async function sessionFileDiff({ sessionId, changes, n, from, to, root, readFile = defaultRead }) {
111
+ const f = changeFiles(changes)[n];
112
+ if (!f) return null;
113
+ const fromV = Number.isInteger(from) ? from : f.versions[0].v;
114
+ const toV = diffTarget(to);
115
+ const a = await readVersion(sessionId, f, fromV, readFile);
116
+ const b = toV === 'disk' ? (await readFile(f.path)) || '' : await readVersion(sessionId, f, toV, readFile);
117
+ if (a === null || b === null) return { path: f.path, rel: relPath(f.path, root), from: fromV, to: toV, missingBackup: true, hunks: [], added: 0, removed: 0, truncated: false, tooLarge: false };
118
+ return { path: f.path, rel: relPath(f.path, root), from: fromV, to: toV, ...diffLines(a, b) };
119
+ }
120
+
121
+ module.exports = { recordChange, mergeChanges, realPathOf, changeFiles, relPath, backupPath, diffTarget, sessionChangeList, sessionFileDiff };
package/lib/collector.js CHANGED
@@ -7,15 +7,16 @@ 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, groupByProject, sessionTitle, combinedUsage, combinedDays, dailyCostSeries, subagentSummary, dayKey } = require('./transcripts');
10
+ const { scanAllTranscripts, scanFile, groupByProject, sessionTitle, combinedUsage, combinedDays, dailyCostSeries, subagentSummary, dayKey, reasonText } = require('./transcripts');
11
11
  const { collectGitStatus } = require('./gitstatus');
12
12
  const { friendlyName } = require('./names');
13
13
  const { isIgnored } = require('./ignore');
14
14
  const { worktreeRoot } = require('./paths');
15
- const { sendNotification, newlyWaiting, isProjectMuted } = require('./notify');
15
+ const { sendNotification, newlyWaiting, overdueWaits, isProjectMuted, trackWaits } = require('./notify');
16
16
  const { readConfig } = require('./config');
17
17
  const { estimateCost, budgetLevel, typicalWait } = require('./pricing');
18
18
  const { readPlan } = require('./plan');
19
+ const { readTeams, agentStatus, resolveTeam } = require('./teams');
19
20
 
20
21
  // A busy session whose transcript hasn't grown for this long may be stalled.
21
22
  const QUIET_FLAG_MS = 10 * 60 * 1000;
@@ -33,7 +34,9 @@ const CADENCE = {
33
34
  class Collector {
34
35
  constructor() {
35
36
  this.state = { generatedAt: 0, quota: null, liveSessions: [], projects: [], errors: [] };
36
- this.raw = { live: [], transcriptGroups: new Map(), history: new Map(), git: new Map(), quota: null };
37
+ this.raw = { live: [], transcriptGroups: new Map(), history: new Map(), git: new Map(), quota: null, metaBySession: new Map(), teams: [] };
38
+ this.waitingSince = new Map(); // sessionId -> when it entered 'waiting'
39
+ this.idleNotified = new Set(); // nagged once for the current wait
37
40
  this.listeners = new Set();
38
41
  this.events = []; // notification history for the catch-up bell, newest first
39
42
  this.clientCount = 0;
@@ -98,6 +101,17 @@ class Collector {
98
101
  s.tasksSummary = t ? t.tasksSummary : null;
99
102
  }
100
103
  this.raw.live = live;
104
+ // A session that just flipped to waiting has a transcript the scan loop
105
+ // may not have re-read yet; refresh those few so the reason is current.
106
+ for (const s of live) {
107
+ if (s.status !== 'waiting') continue;
108
+ const m = this.raw.metaBySession.get(s.sessionId);
109
+ if (!m || !m.file) continue;
110
+ try {
111
+ const fresh = await scanFile(m.file, s.sessionId);
112
+ if (fresh) this.raw.metaBySession.set(s.sessionId, { ...m, ...fresh });
113
+ } catch { /* transcript briefly unreadable: keep the last meta */ }
114
+ }
101
115
  this.notifyTransitions(live);
102
116
  }
103
117
 
@@ -111,17 +125,32 @@ class Collector {
111
125
  notifyTransitions(live) {
112
126
  const next = new Map(live.map((s) => [s.sessionId, s.status]));
113
127
  const prev = this.prevLiveStatus ?? null;
128
+ const now = Date.now();
129
+ const cfg = readConfig();
130
+
131
+ trackWaits(next, this.waitingSince, this.idleNotified, now);
132
+
114
133
  for (const id of newlyWaiting(prev, next)) {
115
134
  const s = live.find((x) => x.sessionId === id);
116
135
  const root = worktreeRoot(s.cwd).root;
117
- this.logEvent('needs you', friendlyName(root), s.waitingFor || 'Claude was waiting for your input');
118
- if (isProjectMuted(root, readConfig().mutedProjects)) continue;
119
- const project = friendlyName(root);
120
- sendNotification({
121
- title: `${project} needs you`,
122
- body: s.waitingFor || 'Claude is waiting for your input',
123
- sound: 'Glass',
124
- });
136
+ const body = reasonText(this.reasonFor(s));
137
+ this.logEvent('needs you', friendlyName(root), body);
138
+ if (isProjectMuted(root, cfg.mutedProjects)) continue;
139
+ sendNotification({ title: `${friendlyName(root)} needs you`, body, sound: 'Glass' });
140
+ }
141
+
142
+ // Idle nag: one reminder per wait once it passes the threshold.
143
+ const thresholdMs = (cfg.idleNagMinutes || 0) * 60000;
144
+ for (const id of overdueWaits(now, this.waitingSince, thresholdMs, this.idleNotified)) {
145
+ this.idleNotified.add(id);
146
+ const s = live.find((x) => x.sessionId === id);
147
+ if (!s) continue;
148
+ const root = worktreeRoot(s.cwd).root;
149
+ const mins = Math.floor((now - this.waitingSince.get(id)) / 60000);
150
+ const body = `Waiting ${mins} minutes — ${reasonText(this.reasonFor(s))}`;
151
+ this.logEvent('still waiting', friendlyName(root), body);
152
+ if (isProjectMuted(root, cfg.mutedProjects)) continue;
153
+ sendNotification({ title: `${friendlyName(root)} still needs you`, body, sound: 'Glass' });
125
154
  }
126
155
  this.prevLiveStatus = next;
127
156
  }
@@ -129,7 +158,9 @@ class Collector {
129
158
  async refreshScan() {
130
159
  const sessions = await scanAllTranscripts();
131
160
  this.raw.transcriptGroups = groupByProject(sessions);
161
+ this.raw.metaBySession = new Map(sessions.map((m) => [m.sessionId, m]));
132
162
  this.raw.history = await refreshHistory();
163
+ this.raw.teams = await readTeams();
133
164
  // Task summaries for recent sessions (readTasks caches by dir mtime).
134
165
  const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
135
166
  for (const g of this.raw.transcriptGroups.values()) {
@@ -170,9 +201,32 @@ class Collector {
170
201
  return sessionTitle(m, readConfig().sessionNames);
171
202
  }
172
203
 
204
+ // What a session is waiting for: the session file's own waitingFor when
205
+ // Claude Code populates it, else the reason derived from the transcript.
206
+ reasonFor(s) {
207
+ if (s.waitingFor) return { kind: 'reply', text: String(s.waitingFor).slice(0, 200) };
208
+ const m = this.raw.metaBySession.get(s.sessionId);
209
+ return (m && m.waitingReason) || null;
210
+ }
211
+
212
+ // Subagents/team for a session, statuses derived so state stays stable.
213
+ agentsFor(m, live) {
214
+ if (!m || !m.agents) return null;
215
+ const now = Date.now();
216
+ return {
217
+ team: resolveTeam(m.agents, this.raw.teams || [], m.sessionId),
218
+ list: m.agents.map((a) => ({
219
+ name: a.name, type: a.type, model: a.model, color: a.color,
220
+ status: live ? agentStatus(a.lastWriteAt, now) : 'done',
221
+ })),
222
+ };
223
+ }
224
+
173
225
  assemble() {
174
226
  const registry = readRegistry();
175
- const sessionNames = readConfig().sessionNames || {}; // read once, not per session
227
+ const cfg = readConfig(); // read once, not per session
228
+ const sessionNames = cfg.sessionNames || {};
229
+ const sessionNotes = cfg.sessionNotes || {};
176
230
  const liveByProject = new Set(
177
231
  this.raw.live.map((s) => worktreeRoot(s.cwd).root.toLowerCase())
178
232
  );
@@ -220,6 +274,8 @@ class Collector {
220
274
  awaySummary: m.awaySummary,
221
275
  awaySummaryAt: m.awaySummaryAt || null,
222
276
  tasksSummary: m.tasksSummary || null,
277
+ note: sessionNotes[m.sessionId] || null,
278
+ changeCount: m.changeCount || 0,
223
279
  startedAt: m.startedAt,
224
280
  lastActivityAt: m.lastActivityAt,
225
281
  worktree: m.worktree,
@@ -255,6 +311,7 @@ class Collector {
255
311
  }
256
312
  this.stuckNotified = this.stuckNotified || new Set();
257
313
 
314
+ const nagMs = (cfg.idleNagMinutes || 0) * 60000;
258
315
  const liveSessions = this.raw.live.map((s) => {
259
316
  const { root, worktree } = worktreeRoot(s.cwd);
260
317
  // "Quiet" = busy but the transcript hasn't grown. Long quiet spells
@@ -283,6 +340,12 @@ class Collector {
283
340
  if (quietMin === null) this.stuckNotified.delete(s.sessionId);
284
341
  return {
285
342
  quietMin,
343
+ waitingMin: (() => {
344
+ const since = s.status === 'waiting' ? this.waitingSince.get(s.sessionId) : null;
345
+ if (!since || !nagMs) return null;
346
+ const w = Date.now() - since;
347
+ return w >= nagMs ? Math.floor(w / 60000) : null;
348
+ })(),
286
349
  pid: s.pid,
287
350
  sessionId: s.sessionId,
288
351
  cwd: s.cwd,
@@ -294,11 +357,13 @@ class Collector {
294
357
  title: titleBySession.get(s.sessionId) || s.name,
295
358
  status: s.status,
296
359
  waitingFor: s.waitingFor,
360
+ waitingReason: s.status === 'waiting' ? this.reasonFor(s) : null,
297
361
  startedAt: s.startedAt,
298
362
  statusUpdatedAt: s.statusUpdatedAt,
299
363
  currentTask: s.currentTask,
300
364
  tasksSummary: s.tasksSummary,
301
365
  subagents: subsBySession.get(s.sessionId) || null,
366
+ agents: this.agentsFor(this.raw.metaBySession.get(s.sessionId), true),
302
367
  resumeCommand: resumeCommand(s.cwd, s.sessionId),
303
368
  };
304
369
  });
@@ -315,6 +380,8 @@ class Collector {
315
380
  title: sessionTitle(m, sessionNames).title,
316
381
  projectName: friendlyName(g.path),
317
382
  model: m.model || null,
383
+ note: sessionNotes[m.sessionId] || null,
384
+ changeCount: m.changeCount || 0,
318
385
  lastActivityAt: m.lastActivityAt,
319
386
  });
320
387
  }
@@ -370,6 +437,7 @@ class Collector {
370
437
  allSessions(projectPath) {
371
438
  const g = this.raw.transcriptGroups.get(projectPath.toLowerCase());
372
439
  if (!g) return [];
440
+ const notes = readConfig().sessionNotes || {};
373
441
  return g.sessions.map((m) => {
374
442
  const { title, source } = this.titleOf(m);
375
443
  return {
@@ -378,6 +446,8 @@ class Collector {
378
446
  titleSource: source,
379
447
  estCost: estimateCost(combinedUsage(m)),
380
448
  awaySummary: m.awaySummary,
449
+ note: notes[m.sessionId] || null,
450
+ changeCount: m.changeCount || 0,
381
451
  startedAt: m.startedAt,
382
452
  lastActivityAt: m.lastActivityAt,
383
453
  worktree: m.worktree,
@@ -513,7 +583,7 @@ class Collector {
513
583
  for (const g of this.raw.transcriptGroups.values()) {
514
584
  for (const m of g.sessions) {
515
585
  if (m.sessionId === sessionId) {
516
- return { file: m.file, title: this.titleOf(m).title, projectName: friendlyName(g.path) };
586
+ return { file: m.file, title: this.titleOf(m).title, projectName: friendlyName(g.path), note: (readConfig().sessionNotes || {})[m.sessionId] || null, agents: this.agentsFor(m, this.raw.live.some((s) => s.sessionId === sessionId)) };
517
587
  }
518
588
  }
519
589
  }
@@ -533,6 +603,16 @@ class Collector {
533
603
  return null;
534
604
  }
535
605
 
606
+ // Files a session edited, with its project root for relative display.
607
+ sessionChanges(sessionId) {
608
+ for (const g of this.raw.transcriptGroups.values()) {
609
+ for (const m of g.sessions) {
610
+ if (m.sessionId === sessionId) return { sessionId, changes: m.changes || {}, root: g.path };
611
+ }
612
+ }
613
+ return null;
614
+ }
615
+
536
616
  noteError(source, err) {
537
617
  const msg = `${source}: ${String(err && err.message ? err.message : err).slice(0, 200)}`;
538
618
  this.state.errors.push({ at: Date.now(), message: msg });
package/lib/config.js CHANGED
@@ -33,7 +33,7 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
33
33
  const NAMES_FILE = path.join(CONFIG_DIR, 'names.json');
34
34
  const IGNORE_FILE = path.join(CONFIG_DIR, 'ignore.json');
35
35
 
36
- const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], sessionNames: {}, theme: 'board' };
36
+ const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], sessionNames: {}, sessionNotes: {}, idleNagMinutes: 60, theme: 'board' };
37
37
 
38
38
  // The one theme list: updateConfig validates against it and GET /api/config
39
39
  // serves it, so the settings dropdown can never offer a value the server
@@ -70,6 +70,9 @@ function updateConfig(patch) {
70
70
  if (typeof patch.weeklyBudget === 'number' && patch.weeklyBudget >= 0 && Number.isFinite(patch.weeklyBudget)) {
71
71
  next.weeklyBudget = Math.round(patch.weeklyBudget);
72
72
  }
73
+ if (typeof patch.idleNagMinutes === 'number' && patch.idleNagMinutes >= 0 && Number.isFinite(patch.idleNagMinutes)) {
74
+ next.idleNagMinutes = Math.round(patch.idleNagMinutes);
75
+ }
73
76
  if (THEMES.some((t) => t.id === patch.theme)) next.theme = patch.theme;
74
77
  writeJson(CONFIG_FILE, next);
75
78
  return next;
@@ -113,6 +116,24 @@ function setSessionName(sessionId, name) {
113
116
  return next.sessionNames[sessionId] || '';
114
117
  }
115
118
 
119
+ // Pure: returns a new sessionNotes map with `note` set for `sessionId`
120
+ // (trimmed, capped at 2000 chars); an empty note removes the entry.
121
+ const NOTE_CAP = 2000;
122
+ function applySessionNote(sessionNotes, sessionId, note) {
123
+ const next = { ...(sessionNotes || {}) };
124
+ const clean = String(note || '').trim().slice(0, NOTE_CAP);
125
+ if (clean) next[sessionId] = clean;
126
+ else delete next[sessionId];
127
+ return next;
128
+ }
129
+
130
+ function setSessionNote(sessionId, note) {
131
+ const next = { ...readConfig() };
132
+ next.sessionNotes = applySessionNote(next.sessionNotes, sessionId, note);
133
+ writeJson(CONFIG_FILE, next);
134
+ return next.sessionNotes[sessionId] || '';
135
+ }
136
+
116
137
  function readNames() {
117
138
  return readJson(NAMES_FILE, {});
118
139
  }
@@ -227,6 +248,8 @@ module.exports = {
227
248
  togglePin,
228
249
  applySessionName,
229
250
  setSessionName,
251
+ applySessionNote,
252
+ setSessionNote,
230
253
  readNames,
231
254
  setName,
232
255
  readIgnores,