claude-mission-control 1.5.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/config.js ADDED
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+ // User configuration: config.json (general), names.json (display names),
3
+ // ignore.json (hidden path prefixes). All read fresh-by-mtime and written
4
+ // pretty-printed so hand-editing stays pleasant.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { canonicalize } = require('./paths');
8
+
9
+ const ROOT = path.join(__dirname, '..');
10
+
11
+ // Config lives beside the code when that's writable (git checkout — the
12
+ // original layout). Under npx / a global npm install the package dir may be
13
+ // read-only or ephemeral, so fall back to ~/.config/claude-dashboard.
14
+ // CLAUDE_DASH_CONFIG_DIR overrides both.
15
+ function resolveConfigDir() {
16
+ const os = require('os');
17
+ const env = process.env.CLAUDE_DASH_CONFIG_DIR;
18
+ if (env) {
19
+ fs.mkdirSync(env, { recursive: true });
20
+ return env;
21
+ }
22
+ try {
23
+ fs.accessSync(ROOT, fs.constants.W_OK);
24
+ return ROOT;
25
+ } catch {
26
+ const dir = path.join(os.homedir(), '.config', 'claude-dashboard');
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ return dir;
29
+ }
30
+ }
31
+ const CONFIG_DIR = resolveConfigDir();
32
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
33
+ const NAMES_FILE = path.join(CONFIG_DIR, 'names.json');
34
+ const IGNORE_FILE = path.join(CONFIG_DIR, 'ignore.json');
35
+
36
+ const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [] };
37
+
38
+ function readJson(file, fallback) {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
41
+ } catch {
42
+ return fallback;
43
+ }
44
+ }
45
+
46
+ function writeJson(file, value) {
47
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
48
+ }
49
+
50
+ function readConfig() {
51
+ return { ...DEFAULTS, ...readJson(CONFIG_FILE, {}) };
52
+ }
53
+
54
+ function updateConfig(patch) {
55
+ const next = { ...readConfig() };
56
+ if (typeof patch.terminal === 'string') next.terminal = patch.terminal;
57
+ if (typeof patch.notifications === 'boolean') next.notifications = patch.notifications;
58
+ if (typeof patch.usageApi === 'boolean') next.usageApi = patch.usageApi;
59
+ if (typeof patch.weeklyBudget === 'number' && patch.weeklyBudget >= 0 && Number.isFinite(patch.weeklyBudget)) {
60
+ next.weeklyBudget = Math.round(patch.weeklyBudget);
61
+ }
62
+ writeJson(CONFIG_FILE, next);
63
+ return next;
64
+ }
65
+
66
+ // Add or remove a project from the notification mute list.
67
+ function setProjectMuted(projectPath, muted) {
68
+ const next = { ...readConfig() };
69
+ const key = canonicalize(projectPath);
70
+ const list = (next.mutedProjects || []).filter((p) => canonicalize(p).toLowerCase() !== key.toLowerCase());
71
+ if (muted) list.push(key);
72
+ next.mutedProjects = list;
73
+ writeJson(CONFIG_FILE, next);
74
+ return next;
75
+ }
76
+
77
+ // Pin or unpin a session for the pinned strip. Returns the new pinned state.
78
+ function togglePin(sessionId) {
79
+ const next = { ...readConfig() };
80
+ const list = next.pinnedSessions || [];
81
+ const has = list.includes(sessionId);
82
+ next.pinnedSessions = has ? list.filter((id) => id !== sessionId) : [...list, sessionId];
83
+ writeJson(CONFIG_FILE, next);
84
+ return !has;
85
+ }
86
+
87
+ function readNames() {
88
+ return readJson(NAMES_FILE, {});
89
+ }
90
+
91
+ function setName(projectPath, name) {
92
+ const names = readNames();
93
+ const key = canonicalize(projectPath);
94
+ // Stored keys may differ in case from the canonical path — replace any match.
95
+ for (const k of Object.keys(names)) {
96
+ if (canonicalize(k).toLowerCase() === key.toLowerCase()) delete names[k];
97
+ }
98
+ if (name && name.trim()) names[key] = name.trim().slice(0, 80);
99
+ writeJson(NAMES_FILE, names);
100
+ }
101
+
102
+ function readIgnores() {
103
+ const raw = readJson(IGNORE_FILE, []);
104
+ return Array.isArray(raw) ? raw : [];
105
+ }
106
+
107
+ function addIgnore(prefix) {
108
+ const list = readIgnores();
109
+ const key = canonicalize(prefix);
110
+ if (!list.some((p) => canonicalize(p).toLowerCase() === key.toLowerCase())) {
111
+ list.push(key);
112
+ writeJson(IGNORE_FILE, list);
113
+ }
114
+ }
115
+
116
+ function removeIgnore(prefix) {
117
+ const key = canonicalize(prefix).toLowerCase();
118
+ writeJson(IGNORE_FILE, readIgnores().filter((p) => canonicalize(p).toLowerCase() !== key));
119
+ }
120
+
121
+ // Walk PATH for a binary; `exists` injected so the walk is unit-testable.
122
+ function findOnPath(name, pathEnv = process.env.PATH || '', exists = fs.existsSync) {
123
+ for (const dir of String(pathEnv).split(path.delimiter)) {
124
+ if (!dir) continue;
125
+ const p = path.join(dir, name);
126
+ if (exists(p)) return p;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ const LINUX_TERMINALS = [
132
+ { id: 'kitty', label: 'kitty' },
133
+ { id: 'alacritty', label: 'Alacritty' },
134
+ { id: 'gnome-terminal', label: 'GNOME Terminal' },
135
+ { id: 'konsole', label: 'Konsole' },
136
+ { id: 'xterm', label: 'xterm' },
137
+ ];
138
+
139
+ // Terminals we know how to launch, filtered to what's installed.
140
+ function detectTerminals() {
141
+ if (process.platform === 'linux') {
142
+ return LINUX_TERMINALS.filter((t) => findOnPath(t.id));
143
+ }
144
+ if (process.platform === 'win32') {
145
+ const wtPath = path.join(
146
+ process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'wt.exe'
147
+ );
148
+ const out = [];
149
+ if (fs.existsSync(wtPath)) out.push({ id: 'wt', label: 'Windows Terminal' });
150
+ out.push({ id: 'powershell', label: 'PowerShell' });
151
+ out.push({ id: 'cmd', label: 'Command Prompt' });
152
+ return out;
153
+ }
154
+ const home = process.env.HOME || '';
155
+ const candidates = [
156
+ { id: 'ghostty', label: 'Ghostty', app: 'Ghostty.app' },
157
+ { id: 'iterm', label: 'iTerm2', app: 'iTerm.app' },
158
+ { id: 'terminal', label: 'Terminal.app', app: null }, // always present on macOS
159
+ ];
160
+ return candidates.filter(
161
+ (c) =>
162
+ !c.app ||
163
+ fs.existsSync(path.join('/Applications', c.app)) ||
164
+ fs.existsSync(path.join(home, 'Applications', c.app))
165
+ );
166
+ }
167
+
168
+ function detectClaudeApp() {
169
+ if (process.platform === 'win32') {
170
+ return fs.existsSync(
171
+ path.join(process.env.LOCALAPPDATA || '', 'AnthropicClaude')
172
+ );
173
+ }
174
+ const home = process.env.HOME || '';
175
+ return (
176
+ fs.existsSync('/Applications/Claude.app') ||
177
+ fs.existsSync(path.join(home, 'Applications', 'Claude.app'))
178
+ );
179
+ }
180
+
181
+ // The terminal that will actually be used: configured if installed, else the
182
+ // first installed one.
183
+ function resolvedTerminal() {
184
+ const installed = detectTerminals();
185
+ const configured = readConfig().terminal;
186
+ return installed.find((t) => t.id === configured) || installed[0] || { id: 'terminal', label: 'Terminal.app' };
187
+ }
188
+
189
+ module.exports = {
190
+ readConfig,
191
+ findOnPath,
192
+ detectClaudeApp,
193
+ resolvedTerminal,
194
+ updateConfig,
195
+ setProjectMuted,
196
+ togglePin,
197
+ readNames,
198
+ setName,
199
+ readIgnores,
200
+ addIgnore,
201
+ removeIgnore,
202
+ detectTerminals,
203
+ };
package/lib/detail.js ADDED
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+ // On-demand project detail: CLAUDE.md files, per-project memory, skills/
3
+ // agents/commands, and settings. Read-only; every read is confined to the
4
+ // project root or that project's memory dir under ~/.claude.
5
+ const fsp = require('fs/promises');
6
+ const path = require('path');
7
+ const { CLAUDE_DIR, canonicalize, encodeProjectDir } = require('./paths');
8
+
9
+ const MAX_FILE_BYTES = 64 * 1024; // per-file cap for returned content
10
+
11
+ async function readCapped(abs) {
12
+ try {
13
+ const st = await fsp.stat(abs);
14
+ if (!st.isFile()) return null;
15
+ const fh = await fsp.open(abs, 'r');
16
+ try {
17
+ const len = Math.min(st.size, MAX_FILE_BYTES);
18
+ const buf = Buffer.alloc(len);
19
+ await fh.read(buf, 0, len, 0);
20
+ let content = buf.toString('utf8');
21
+ if (st.size > MAX_FILE_BYTES) content += `\n\n… truncated (${st.size} bytes total)`;
22
+ return { content, size: st.size, mtimeMs: st.mtimeMs };
23
+ } finally {
24
+ await fh.close();
25
+ }
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ async function listMd(dir) {
32
+ try {
33
+ const names = await fsp.readdir(dir);
34
+ return names.filter((n) => n.endsWith('.md')).sort();
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ // First `description:` line of YAML frontmatter, if any.
41
+ function frontmatterDescription(content) {
42
+ if (!content || !content.startsWith('---')) return null;
43
+ const end = content.indexOf('\n---', 3);
44
+ if (end === -1) return null;
45
+ const m = content.slice(0, end).match(/^description:\s*(.+)$/m);
46
+ return m ? m[1].trim().replace(/^["']|["']$/g, '').slice(0, 300) : null;
47
+ }
48
+
49
+ // Skills are directories containing SKILL.md; agents/commands are .md files.
50
+ async function collectCapabilities(root) {
51
+ const base = path.join(root, '.claude');
52
+ const out = { skills: [], agents: [], commands: [] };
53
+
54
+ try {
55
+ for (const entry of await fsp.readdir(path.join(base, 'skills'), { withFileTypes: true })) {
56
+ if (!entry.isDirectory()) continue;
57
+ const skillMd = await readCapped(path.join(base, 'skills', entry.name, 'SKILL.md'));
58
+ out.skills.push({
59
+ name: entry.name,
60
+ description: skillMd ? frontmatterDescription(skillMd.content) : null,
61
+ content: skillMd ? skillMd.content : null,
62
+ });
63
+ }
64
+ } catch { /* no skills dir */ }
65
+
66
+ for (const kind of ['agents', 'commands']) {
67
+ for (const name of await listMd(path.join(base, kind))) {
68
+ const file = await readCapped(path.join(base, kind, name));
69
+ out[kind].push({
70
+ name: name.replace(/\.md$/, ''),
71
+ description: file ? frontmatterDescription(file.content) : null,
72
+ content: file ? file.content : null,
73
+ });
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ async function collectSettings(root) {
80
+ const out = {};
81
+ for (const [key, rel] of [
82
+ ['settings', '.claude/settings.json'],
83
+ ['settingsLocal', '.claude/settings.local.json'],
84
+ ['mcpJson', '.mcp.json'],
85
+ ]) {
86
+ const f = await readCapped(path.join(root, rel));
87
+ if (f) out[key] = f.content;
88
+ }
89
+ // Per-project entry in Claude's own registry: MCP servers + allowed tools.
90
+ try {
91
+ const reg = JSON.parse(await fsp.readFile(path.join(CLAUDE_DIR, '..', '.claude.json'), 'utf8'));
92
+ for (const [p, info] of Object.entries(reg.projects || {})) {
93
+ if (canonicalize(p).toLowerCase() !== canonicalize(root).toLowerCase()) continue;
94
+ if (info.mcpServers && Object.keys(info.mcpServers).length) {
95
+ out.mcpServers = Object.keys(info.mcpServers);
96
+ }
97
+ if (Array.isArray(info.allowedTools) && info.allowedTools.length) {
98
+ out.allowedTools = info.allowedTools;
99
+ }
100
+ break;
101
+ }
102
+ } catch { /* registry unreadable */ }
103
+ return out;
104
+ }
105
+
106
+ async function collectMemory(root) {
107
+ const memDir = path.join(CLAUDE_DIR, 'projects', encodeProjectDir(root), 'memory');
108
+ const files = [];
109
+ for (const name of await listMd(memDir)) {
110
+ const f = await readCapped(path.join(memDir, name));
111
+ if (f) files.push({ name, content: f.content, mtimeMs: f.mtimeMs });
112
+ }
113
+ // MEMORY.md (the index) first, then newest first.
114
+ files.sort((a, b) =>
115
+ a.name === 'MEMORY.md' ? -1 : b.name === 'MEMORY.md' ? 1 : b.mtimeMs - a.mtimeMs
116
+ );
117
+ return files;
118
+ }
119
+
120
+ async function projectDetail(root) {
121
+ const claudeMd = [];
122
+ for (const name of ['CLAUDE.md', 'CLAUDE.local.md']) {
123
+ const f = await readCapped(path.join(root, name));
124
+ if (f) claudeMd.push({ name, content: f.content });
125
+ }
126
+ const [capabilities, settings, memory] = await Promise.all([
127
+ collectCapabilities(root),
128
+ collectSettings(root),
129
+ collectMemory(root),
130
+ ]);
131
+ return { path: root, claudeMd, memory, ...capabilities, settings };
132
+ }
133
+
134
+ module.exports = { projectDetail };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+ // Git status per project via `git -C <path> status --porcelain=v2 --branch`.
3
+ // execFile arg-arrays only (paths contain spaces). Keeps last good values.
4
+ const { execFile } = require('child_process');
5
+ const fs = require('fs/promises');
6
+ const path = require('path');
7
+
8
+ const lastGood = new Map(); // projectPath -> git object
9
+
10
+ async function isGitRepo(projectPath) {
11
+ try {
12
+ // .git is a dir in normal repos, a FILE in worktrees.
13
+ await fs.stat(path.join(projectPath, '.git'));
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function runGitStatus(projectPath) {
21
+ return new Promise((resolve) => {
22
+ execFile(
23
+ 'git',
24
+ ['-C', projectPath, 'status', '--porcelain=v2', '--branch'],
25
+ { timeout: 4000, maxBuffer: 4 * 1024 * 1024 },
26
+ (err, stdout) => {
27
+ if (err) {
28
+ const prev = lastGood.get(projectPath);
29
+ resolve(prev ? { ...prev, error: shortErr(err) } : { isRepo: true, error: shortErr(err) });
30
+ return;
31
+ }
32
+ const git = parsePorcelainV2(stdout);
33
+ lastGood.set(projectPath, git);
34
+ resolve(git);
35
+ }
36
+ );
37
+ });
38
+ }
39
+
40
+ function parsePorcelainV2(text) {
41
+ const git = { isRepo: true, branch: null, dirty: 0, untracked: 0, ahead: null, behind: null, error: null };
42
+ for (const line of text.split('\n')) {
43
+ if (line.startsWith('# branch.head ')) {
44
+ const h = line.slice('# branch.head '.length).trim();
45
+ git.branch = h === '(detached)' ? '(detached)' : h;
46
+ } else if (line.startsWith('# branch.ab ')) {
47
+ const m = line.match(/\+(\d+) -(\d+)/);
48
+ if (m) {
49
+ git.ahead = Number(m[1]);
50
+ git.behind = Number(m[2]);
51
+ }
52
+ } else if (line.startsWith('1 ') || line.startsWith('2 ') || line.startsWith('u ')) {
53
+ git.dirty++;
54
+ } else if (line.startsWith('? ')) {
55
+ git.untracked++;
56
+ }
57
+ }
58
+ return git;
59
+ }
60
+
61
+ function shortErr(err) {
62
+ const msg = String(err.message || err).split('\n')[0];
63
+ return msg.slice(0, 120);
64
+ }
65
+
66
+ // Pooled: at most 3 concurrent git processes.
67
+ async function collectGitStatus(projectPaths) {
68
+ const results = new Map();
69
+ const queue = [...projectPaths];
70
+ const workers = Array.from({ length: 3 }, async () => {
71
+ while (queue.length) {
72
+ const p = queue.shift();
73
+ if (!(await isGitRepo(p))) {
74
+ results.set(p, { isRepo: false });
75
+ continue;
76
+ }
77
+ results.set(p, await runGitStatus(p));
78
+ }
79
+ });
80
+ await Promise.all(workers);
81
+ return results;
82
+ }
83
+
84
+ module.exports = { collectGitStatus, parsePorcelainV2 };
package/lib/history.js ADDED
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ // Incremental reader for ~/.claude/history.jsonl (append-only prompt log).
3
+ // Keeps per-project timestamps (90 days) + total counts in memory.
4
+ const fs = require('fs/promises');
5
+ const path = require('path');
6
+ const { CLAUDE_DIR, worktreeRoot } = require('./paths');
7
+
8
+ const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl');
9
+ const KEEP_MS = 400 * 24 * 60 * 60 * 1000; // enough for a year-ish heatmap
10
+
11
+ const state = {
12
+ offset: 0,
13
+ partial: '',
14
+ // lowercaseProjectPath -> { timestamps: number[], promptCount, lastPrompt, lastTimestamp }
15
+ byProject: new Map(),
16
+ };
17
+
18
+ async function refreshHistory() {
19
+ let st;
20
+ try {
21
+ st = await fs.stat(HISTORY_FILE);
22
+ } catch {
23
+ return state.byProject;
24
+ }
25
+ if (st.size < state.offset) {
26
+ // truncated/rotated: start over
27
+ state.offset = 0;
28
+ state.partial = '';
29
+ state.byProject.clear();
30
+ }
31
+ if (st.size === state.offset) return state.byProject;
32
+
33
+ const fh = await fs.open(HISTORY_FILE, 'r');
34
+ try {
35
+ const len = st.size - state.offset;
36
+ const buf = Buffer.alloc(len);
37
+ await fh.read(buf, 0, len, state.offset);
38
+ state.offset = st.size;
39
+ const text = state.partial + buf.toString('utf8');
40
+ const lines = text.split('\n');
41
+ state.partial = lines.pop() || ''; // last element may be a partial line
42
+ for (const line of lines) {
43
+ if (!line.trim()) continue;
44
+ let rec;
45
+ try {
46
+ rec = JSON.parse(line);
47
+ } catch {
48
+ continue;
49
+ }
50
+ if (!rec.project || !rec.timestamp) continue;
51
+ const { root } = worktreeRoot(rec.project);
52
+ const key = root.toLowerCase();
53
+ let e = state.byProject.get(key);
54
+ if (!e) {
55
+ e = { path: root, timestamps: [], promptCount: 0, lastPrompt: null, lastTimestamp: 0 };
56
+ state.byProject.set(key, e);
57
+ }
58
+ e.promptCount++;
59
+ if (rec.timestamp >= e.lastTimestamp) {
60
+ e.lastTimestamp = rec.timestamp;
61
+ e.lastPrompt = typeof rec.display === 'string' ? rec.display.slice(0, 200) : null;
62
+ }
63
+ if (Date.now() - rec.timestamp < KEEP_MS) e.timestamps.push(rec.timestamp);
64
+ }
65
+ } finally {
66
+ await fh.close();
67
+ }
68
+ // prune old timestamps occasionally
69
+ const cutoff = Date.now() - KEEP_MS;
70
+ for (const e of state.byProject.values()) {
71
+ if (e.timestamps.length && e.timestamps[0] < cutoff) {
72
+ e.timestamps = e.timestamps.filter((t) => t >= cutoff);
73
+ }
74
+ }
75
+ return state.byProject;
76
+ }
77
+
78
+ // Daily prompt counts for the last `days` local days, oldest -> newest.
79
+ function activityBuckets(timestamps, days = 14) {
80
+ const counts = new Array(days).fill(0);
81
+ const midnight = new Date();
82
+ midnight.setHours(0, 0, 0, 0);
83
+ const todayStart = midnight.getTime();
84
+ const DAY = 24 * 60 * 60 * 1000;
85
+ for (const t of timestamps) {
86
+ const daysAgo = t >= todayStart ? 0 : Math.ceil((todayStart - t) / DAY);
87
+ const slot = days - 1 - daysAgo;
88
+ if (slot >= 0 && slot < days) counts[slot]++;
89
+ }
90
+ return counts;
91
+ }
92
+
93
+ // 7×24 grid of prompt counts by [dayOfWeek][hour], Sunday-first, local time.
94
+ function weekHourHeat(timestamps) {
95
+ const heat = Array.from({ length: 7 }, () => new Array(24).fill(0));
96
+ for (const t of timestamps) {
97
+ const d = new Date(t);
98
+ heat[d.getDay()][d.getHours()]++;
99
+ }
100
+ return heat;
101
+ }
102
+
103
+ module.exports = { refreshHistory, activityBuckets, weekHourHeat };
package/lib/ignore.js ADDED
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ // User-editable ignore list: ignore.json is an array of absolute path prefixes.
3
+ // A project is hidden if its path equals a prefix or sits anywhere under it.
4
+ // Hides from the dashboard only — nothing on disk or in ~/.claude is touched.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { canonicalize } = require('./paths');
8
+
9
+ const IGNORE_FILE = path.join(__dirname, '..', 'ignore.json');
10
+
11
+ let cache = { mtimeMs: 0, prefixes: [] };
12
+
13
+ function loadIgnores() {
14
+ try {
15
+ const st = fs.statSync(IGNORE_FILE);
16
+ if (st.mtimeMs !== cache.mtimeMs) {
17
+ const raw = JSON.parse(fs.readFileSync(IGNORE_FILE, 'utf8'));
18
+ const prefixes = (Array.isArray(raw) ? raw : [])
19
+ .map((p) => canonicalize(String(p)).toLowerCase())
20
+ .filter(Boolean);
21
+ cache = { mtimeMs: st.mtimeMs, prefixes };
22
+ }
23
+ } catch {
24
+ cache = { mtimeMs: 0, prefixes: [] };
25
+ }
26
+ return cache.prefixes;
27
+ }
28
+
29
+ // Pure prefix matcher (prefixes pre-lowercased) — unit-testable.
30
+ function matchesPrefix(projectPath, prefixes) {
31
+ const p = canonicalize(projectPath).toLowerCase();
32
+ for (const prefix of prefixes) {
33
+ if (p === prefix || p.startsWith(prefix + '/')) return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ function isIgnored(projectPath) {
39
+ return matchesPrefix(projectPath, loadIgnores());
40
+ }
41
+
42
+ module.exports = { isIgnored, matchesPrefix };
package/lib/names.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ // Friendly project names: names.json exact match, else a cleaned-up basename.
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { canonicalize } = require('./paths');
6
+
7
+ const NAMES_FILE = path.join(__dirname, '..', 'names.json');
8
+
9
+ let cache = { mtimeMs: 0, map: {} };
10
+
11
+ function loadNames() {
12
+ try {
13
+ const st = fs.statSync(NAMES_FILE);
14
+ if (st.mtimeMs !== cache.mtimeMs) {
15
+ const raw = JSON.parse(fs.readFileSync(NAMES_FILE, 'utf8'));
16
+ const map = {};
17
+ for (const [k, v] of Object.entries(raw)) map[canonicalize(k).toLowerCase()] = v;
18
+ cache = { mtimeMs: st.mtimeMs, map };
19
+ }
20
+ } catch {
21
+ cache = { mtimeMs: 0, map: {} };
22
+ }
23
+ return cache.map;
24
+ }
25
+
26
+ // Segments that are scaffolding, not identity. Walk up past them so
27
+ // "/Users/x/Local Sites/jonimms/app/public" names as "jonimms".
28
+ const GENERIC = new Set(['app', 'public', 'wp-content', 'themes', 'plugins', 'src', 'site']);
29
+
30
+ function displayBase(p) {
31
+ const parts = canonicalize(p).split(path.sep).filter(Boolean);
32
+ for (let i = parts.length - 1; i >= 0; i--) {
33
+ if (!GENERIC.has(parts[i].toLowerCase())) return parts[i];
34
+ }
35
+ return parts[parts.length - 1] || p;
36
+ }
37
+
38
+ function titleCase(s) {
39
+ return s
40
+ .replace(/[-_]+/g, ' ')
41
+ .split(' ')
42
+ .filter(Boolean)
43
+ .map((w) => (/[A-Z]/.test(w.slice(1)) ? w : w[0].toUpperCase() + w.slice(1)))
44
+ .join(' ');
45
+ }
46
+
47
+ function friendlyName(projectPath) {
48
+ const map = loadNames();
49
+ const hit = map[canonicalize(projectPath).toLowerCase()];
50
+ if (hit) return hit;
51
+ return titleCase(displayBase(projectPath));
52
+ }
53
+
54
+ module.exports = { friendlyName, displayBase, titleCase };
package/lib/notify.js ADDED
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+ // macOS notifications via osascript — no dependencies, no signing.
3
+ // Disable with CLAUDE_DASH_NOTIFY=0.
4
+ const { execFile } = require('child_process');
5
+ const { readConfig } = require('./config');
6
+ const { canonicalize } = require('./paths');
7
+
8
+ function notificationsEnabled() {
9
+ if (process.env.CLAUDE_DASH_NOTIFY === '0') return false;
10
+ return readConfig().notifications !== false;
11
+ }
12
+
13
+ function aq(s) {
14
+ // AppleScript string literal: escape backslash and double quote.
15
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
16
+ }
17
+
18
+ function sendNotification({ title, body, sound }) {
19
+ if (!notificationsEnabled()) return;
20
+ if (process.platform === 'darwin') {
21
+ const script =
22
+ `display notification "${aq(body)}" with title "${aq(title)}"` +
23
+ (sound ? ` sound name "${aq(sound)}"` : '');
24
+ execFile('osascript', ['-e', script], { timeout: 5000 }, () => {});
25
+ } else if (process.platform === 'win32') {
26
+ // Windows toast via WinRT — no modules required. Untested; best effort.
27
+ const esc = (s) => String(s).replace(/'/g, "''").replace(/[\r\n]+/g, ' ');
28
+ const ps = `
29
+ $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
30
+ $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
31
+ $t = $xml.GetElementsByTagName('text')
32
+ $null = $t.Item(0).AppendChild($xml.CreateTextNode('${esc(title)}'))
33
+ $null = $t.Item(1).AppendChild($xml.CreateTextNode('${esc(body)}'))
34
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Dashboard').Show([Windows.UI.Notifications.ToastNotification]::new($xml))`;
35
+ execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 8000 }, () => {});
36
+ } else {
37
+ execFile('notify-send', [title, body], { timeout: 5000 }, () => {});
38
+ }
39
+ }
40
+
41
+ // Pure transition detector so it's unit-testable.
42
+ // prev/next: Map<sessionId, status>; prev === null means first poll after
43
+ // startup — never notify then, or every restart would replay notifications.
44
+ // A session unseen in prev but waiting in next DOES notify (it went waiting
45
+ // between polls).
46
+ function newlyWaiting(prev, next) {
47
+ if (prev === null) return [];
48
+ const out = [];
49
+ for (const [id, status] of next) {
50
+ if (status === 'waiting' && prev.get(id) !== 'waiting') out.push(id);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // Per-project mute: compare canonical, lowercased roots.
56
+ function isProjectMuted(root, mutedProjects) {
57
+ if (!Array.isArray(mutedProjects) || !mutedProjects.length) return false;
58
+ const key = canonicalize(root).toLowerCase();
59
+ return mutedProjects.some((p) => canonicalize(p).toLowerCase() === key);
60
+ }
61
+
62
+ module.exports = { sendNotification, newlyWaiting, isProjectMuted };