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 +41 -10
- package/lib/changes.js +121 -0
- package/lib/collector.js +93 -13
- package/lib/config.js +24 -1
- package/lib/demo.js +246 -19
- package/lib/diff.js +152 -0
- package/lib/inventory.js +174 -0
- package/lib/notify.js +27 -1
- package/lib/search.js +30 -1
- package/lib/teams.js +101 -0
- package/lib/transcripts.js +182 -7
- package/lib/update.js +17 -7
- package/menubar/claude-dash.15s.sh +7 -3
- package/package.json +1 -1
- package/public/index.html +393 -14
- package/server.js +65 -8
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,
|
|
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
|
-

|
|
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
|
-
|
|
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
|
+

|
|
87
|
+
|
|
88
|
+

|
|
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
|

|
|
93
|
-
|
|
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
|

|
|
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
|
-
- **
|
|
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.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
sendNotification({
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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,
|