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/opener.js ADDED
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+ // Launch a session in the configured terminal or the Claude desktop app.
3
+ // Paths come from OUR collected state, never from the client.
4
+ const { execFile } = require('child_process');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { readConfig, detectTerminals, detectClaudeApp } = require('./config');
9
+
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+
12
+ function run(cmd, args) {
13
+ return new Promise((resolve) => {
14
+ execFile(cmd, args, { timeout: 10_000 }, (err) => {
15
+ resolve(err ? { ok: false, error: String(err.message).slice(0, 200) } : { ok: true });
16
+ });
17
+ });
18
+ }
19
+
20
+ function sq(s) {
21
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
22
+ }
23
+
24
+ // Launch `shellCmd` in a new window of the configured terminal, cwd set.
25
+ function launchInTerminal(cwd, shellCmd) {
26
+ const configured = readConfig().terminal;
27
+ const installed = detectTerminals();
28
+ const terminal = installed.some((t) => t.id === configured)
29
+ ? configured
30
+ : (installed[0] || { id: process.platform === 'win32' ? 'cmd' : 'terminal' }).id;
31
+
32
+ if (process.platform === 'win32') {
33
+ // Untested on real Windows — via `start` so the window detaches.
34
+ if (terminal === 'wt') {
35
+ return run('cmd', ['/c', 'start', '', 'wt', '-d', cwd, 'cmd', '/k', shellCmd]);
36
+ }
37
+ if (terminal === 'powershell') {
38
+ return run('cmd', ['/c', 'start', '', 'powershell', '-NoExit', '-Command',
39
+ `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${shellCmd}`]);
40
+ }
41
+ return run('cmd', ['/c', 'start', '', 'cmd', '/k', `cd /d "${cwd}" && ${shellCmd}`]);
42
+ }
43
+
44
+ if (process.platform === 'linux') {
45
+ const keepAlive = `cd ${sq(cwd)} && ${shellCmd}; exec bash -i`;
46
+ if (terminal === 'gnome-terminal') {
47
+ return run('gnome-terminal', [`--working-directory=${cwd}`, '--', 'bash', '-c', keepAlive]);
48
+ }
49
+ if (terminal === 'konsole') {
50
+ return run('konsole', ['--workdir', cwd, '-e', 'bash', '-c', keepAlive]);
51
+ }
52
+ if (terminal === 'kitty' || terminal === 'alacritty') {
53
+ return run(terminal, ['--working-directory', cwd, '-e', 'bash', '-c', keepAlive]);
54
+ }
55
+ return run('xterm', ['-e', 'bash', '-c', keepAlive]);
56
+ }
57
+
58
+ if (terminal === 'ghostty') {
59
+ return run('open', [
60
+ '-na', 'Ghostty.app', '--args',
61
+ `--working-directory=${cwd}`,
62
+ '-e', 'zsh', '-ilc', shellCmd,
63
+ ]);
64
+ }
65
+
66
+ // iTerm2 / Terminal.app: a temp .command file avoids AppleScript permission
67
+ // prompts. `exec zsh -i` keeps the window alive after the command exits.
68
+ const script = `#!/bin/zsh\ncd ${sq(cwd)}\n${shellCmd}\nexec zsh -i\n`;
69
+ const file = path.join(os.tmpdir(), `claude-dash-${Date.now()}-${Math.floor(Math.random() * 1e6)}.command`);
70
+ try {
71
+ fs.writeFileSync(file, script, { mode: 0o755 });
72
+ } catch (e) {
73
+ return Promise.resolve({ ok: false, error: String(e.message).slice(0, 200) });
74
+ }
75
+ const app = terminal === 'iterm' ? 'iTerm' : 'Terminal';
76
+ return run('open', ['-a', app, file]);
77
+ }
78
+
79
+ function openSession({ sessionId, cwd, target }) {
80
+ if (!UUID_RE.test(sessionId)) {
81
+ return Promise.resolve({ ok: false, error: 'invalid session id' });
82
+ }
83
+ if (target === 'app') {
84
+ if (!detectClaudeApp()) {
85
+ return Promise.resolve({ ok: false, error: 'Claude desktop app is not installed' });
86
+ }
87
+ // Claude desktop deep link: imports the CLI session transcript.
88
+ const url = `claude://resume?session=${sessionId}`;
89
+ if (process.platform === 'win32') return run('cmd', ['/c', 'start', '', url]);
90
+ if (process.platform === 'linux') return run('xdg-open', [url]);
91
+ return run('open', [url]);
92
+ }
93
+ if (target === 'terminal') {
94
+ if (!cwd) return Promise.resolve({ ok: false, error: 'unknown project path for session' });
95
+ return launchInTerminal(cwd, `claude --resume ${sessionId}`);
96
+ }
97
+ return Promise.resolve({ ok: false, error: 'unknown target' });
98
+ }
99
+
100
+ function openNewSession(cwd) {
101
+ return launchInTerminal(cwd, 'claude');
102
+ }
103
+
104
+ module.exports = { openSession, openNewSession };
package/lib/paths.js ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ // Shared path helpers: canonicalization, worktree grouping, project-dir encoding.
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
7
+
8
+ // Canonical form used for all internal keys and comparisons: resolved,
9
+ // forward slashes on every platform (Windows paths become C:/Users/...),
10
+ // no trailing slash. Display strings keep whatever the source gave us.
11
+ function canonicalize(p) {
12
+ if (!p) return p;
13
+ // Don't run path.resolve on a Windows-style path when we're not on Windows
14
+ // (it would prefix the posix cwd) — those appear in tests and in data
15
+ // copied between machines.
16
+ const winStyle = /^[A-Za-z]:[\\/]/.test(p);
17
+ let r = winStyle && process.platform !== 'win32' ? p : path.resolve(p);
18
+ r = r.replace(/\\/g, '/');
19
+ if (r.length > 1 && r.endsWith('/')) r = r.slice(0, -1);
20
+ return r;
21
+ }
22
+
23
+ // A session run inside `<repo>/.claude/worktrees/<name>` belongs to <repo>.
24
+ // Returns { root, worktree } where worktree is the worktree folder name or null.
25
+ function worktreeRoot(p) {
26
+ const c = canonicalize(p);
27
+ const m = c.match(/^(.*)\/\.claude\/worktrees\/([^/]+)$/);
28
+ if (m) return { root: m[1], worktree: m[2] };
29
+ return { root: c, worktree: null };
30
+ }
31
+
32
+ // Encode a real path the way ~/.claude/projects dir names are built.
33
+ // Forward-only (real path -> encoded); never decode, the mapping is lossy.
34
+ // Windows drive colons become dashes too (best guess — unverified on Windows).
35
+ function encodeProjectDir(p) {
36
+ return canonicalize(p).replace(/[/:. ]/g, '-');
37
+ }
38
+
39
+ module.exports = { CLAUDE_DIR, canonicalize, worktreeRoot, encodeProjectDir };
package/lib/plan.js ADDED
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ // Plan detection from Claude Code's own local account cache
3
+ // (~/.claude.json -> oauthAccount). Local-only; no tokens are read.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const CLAUDE_JSON = path.join(os.homedir(), '.claude.json');
9
+
10
+ let cache = { mtimeMs: 0, plan: null };
11
+
12
+ const TYPE_LABELS = {
13
+ claude_max: 'Max',
14
+ claude_pro: 'Pro',
15
+ claude_free: 'Free',
16
+ claude_team: 'Team',
17
+ claude_enterprise: 'Enterprise',
18
+ };
19
+
20
+ function planLabel(orgType, tier) {
21
+ const base = TYPE_LABELS[orgType] || (orgType ? orgType.replace(/^claude_/, '') : null);
22
+ if (!base) return null;
23
+ const mult = tier && tier.match(/_(\d+x)$/);
24
+ return mult ? `${base} ${mult[1]}` : base;
25
+ }
26
+
27
+ function readPlan() {
28
+ let st;
29
+ try {
30
+ st = fs.statSync(CLAUDE_JSON);
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (st.mtimeMs === cache.mtimeMs) return cache.plan;
35
+ let plan = null;
36
+ try {
37
+ const oa = JSON.parse(fs.readFileSync(CLAUDE_JSON, 'utf8')).oauthAccount;
38
+ if (oa && (oa.organizationType || oa.emailAddress)) {
39
+ plan = {
40
+ label: planLabel(oa.organizationType, oa.organizationRateLimitTier) || 'unknown',
41
+ tier: oa.userRateLimitTier || oa.organizationRateLimitTier || null,
42
+ organization: oa.organizationName || null,
43
+ email: oa.emailAddress || null,
44
+ extraUsage: oa.hasExtraUsageEnabled === true,
45
+ billing: oa.billingType || null,
46
+ };
47
+ } else {
48
+ // No OAuth account cached — likely API-key auth.
49
+ plan = { label: 'API', tier: null, organization: null, email: null, extraUsage: false, billing: 'api' };
50
+ }
51
+ } catch {
52
+ /* keep null */
53
+ }
54
+ cache = { mtimeMs: st.mtimeMs, plan };
55
+ return plan;
56
+ }
57
+
58
+ module.exports = { readPlan, planLabel };
package/lib/pricing.js ADDED
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+ // Estimated cost from token usage, at Anthropic list rates (USD per MTok).
3
+ // Cache read = 0.1x input; cache write = 1.25x input (5-minute TTL).
4
+ // These are list-price estimates — on a Max plan they show relative weight,
5
+ // not billed dollars. Rates cached 2026-08; matched by id prefix.
6
+ const RATES = [
7
+ { prefix: 'claude-fable-5', input: 10, output: 50 },
8
+ { prefix: 'claude-mythos', input: 10, output: 50 },
9
+ { prefix: 'claude-opus-4-1', input: 15, output: 75 },
10
+ { prefix: 'claude-opus-4-0', input: 15, output: 75 },
11
+ { prefix: 'claude-opus-4-2025', input: 15, output: 75 },
12
+ { prefix: 'claude-opus', input: 5, output: 25 }, // opus-5, 4-8, 4-7, 4-6, 4-5
13
+ { prefix: 'claude-sonnet', input: 3, output: 15 },
14
+ { prefix: 'claude-haiku-4', input: 1, output: 5 },
15
+ { prefix: 'claude-3-5-haiku', input: 0.8, output: 4 },
16
+ { prefix: 'claude-haiku', input: 1, output: 5 },
17
+ ];
18
+ const DEFAULT_RATE = { input: 5, output: 25 }; // unknown model: assume opus-tier
19
+
20
+ function rateFor(model) {
21
+ const m = String(model || '');
22
+ for (const r of RATES) if (m.startsWith(r.prefix)) return r;
23
+ return DEFAULT_RATE;
24
+ }
25
+
26
+ // usageByModel: { [model]: {input, output, cacheRead, cacheCreation} } (token counts)
27
+ function estimateCost(usageByModel) {
28
+ let usd = 0;
29
+ for (const [model, u] of Object.entries(usageByModel || {})) {
30
+ const r = rateFor(model);
31
+ usd +=
32
+ ((u.input || 0) * r.input +
33
+ (u.output || 0) * r.output +
34
+ (u.cacheRead || 0) * r.input * 0.1 +
35
+ (u.cacheCreation || 0) * r.input * 1.25) /
36
+ 1_000_000;
37
+ }
38
+ return usd;
39
+ }
40
+
41
+ function totalTokens(usageByModel) {
42
+ let t = 0;
43
+ for (const u of Object.values(usageByModel || {})) {
44
+ t += (u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheCreation || 0);
45
+ }
46
+ return t;
47
+ }
48
+
49
+ // Highest budget threshold crossed (0, 75, 90, or 100), for one-shot alerts.
50
+ function budgetLevel(spent, budget) {
51
+ if (!budget || budget <= 0) return 0;
52
+ const pct = (spent / budget) * 100;
53
+ if (pct >= 100) return 100;
54
+ if (pct >= 90) return 90;
55
+ if (pct >= 75) return 75;
56
+ return 0;
57
+ }
58
+
59
+ // Median response-time bucket as a label, from [<30s, <2m, <10m, <=30m] counts.
60
+ const WAIT_LABELS = ['under 30s', 'under 2m', 'under 10m', 'under 30m'];
61
+ function typicalWait(buckets) {
62
+ const total = (buckets || []).reduce((a, b) => a + b, 0);
63
+ if (!total) return null;
64
+ let cum = 0;
65
+ for (let i = 0; i < buckets.length; i++) {
66
+ cum += buckets[i];
67
+ if (cum * 2 >= total) return WAIT_LABELS[i];
68
+ }
69
+ return null;
70
+ }
71
+
72
+ module.exports = { estimateCost, totalTokens, rateFor, budgetLevel, typicalWait };
package/lib/quota.js ADDED
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ // Usage/quota widget data from ~/.claude/.statusline-usage-cache (KEY=VALUE).
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+ const { CLAUDE_DIR } = require('./paths');
6
+
7
+ const CACHE_FILE = path.join(CLAUDE_DIR, '.statusline-usage-cache');
8
+ const STALE_MS = 2 * 60 * 60 * 1000; // statusline only updates while Claude runs
9
+
10
+ async function readQuota() {
11
+ let raw;
12
+ try {
13
+ raw = await fs.readFile(CACHE_FILE, 'utf8');
14
+ } catch {
15
+ return null;
16
+ }
17
+ const kv = {};
18
+ for (const line of raw.split('\n')) {
19
+ const i = line.indexOf('=');
20
+ if (i > 0) kv[line.slice(0, i).trim()] = line.slice(i + 1).trim();
21
+ }
22
+ const ts = Number(kv.TIMESTAMP) || 0;
23
+ // TIMESTAMP may be epoch seconds or ms; normalize to ms.
24
+ const tsMs = ts > 1e12 ? ts : ts * 1000;
25
+ return {
26
+ utilization: num(kv.UTILIZATION),
27
+ weeklyUtilization: num(kv.WEEKLY_UTILIZATION),
28
+ costUsed: num(kv.COST_USED),
29
+ costLimit: num(kv.COST_LIMIT),
30
+ currency: kv.COST_CURRENCY || 'USD',
31
+ resetsAt: kv.RESETS_AT || null,
32
+ weeklyResetsAt: kv.WEEKLY_RESETS_AT || null,
33
+ profileName: kv.PROFILE_NAME || null,
34
+ stale: !tsMs || Date.now() - tsMs > STALE_MS,
35
+ };
36
+ }
37
+
38
+ function num(v) {
39
+ const n = parseFloat(v);
40
+ return Number.isFinite(n) ? n : null;
41
+ }
42
+
43
+ module.exports = { readQuota };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ // Project registry from ~/.claude.json `projects` map, with worktree-root
3
+ // normalization and case-insensitive dedupe (APFS is case-insensitive; the
4
+ // registry really does contain e.g. both "Projects/" and "projects/" variants).
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { canonicalize, worktreeRoot } = require('./paths');
9
+
10
+ const CLAUDE_JSON = path.join(os.homedir(), '.claude.json');
11
+
12
+ let cache = { mtimeMs: 0, projects: new Map() };
13
+
14
+ // Returns Map<lowercasePath, {path, lastSessionId, lastSessionModified,
15
+ // lastStartTime, lastSessionFirstPrompt, lastCost}>
16
+ function readRegistry() {
17
+ let st;
18
+ try {
19
+ st = fs.statSync(CLAUDE_JSON);
20
+ } catch {
21
+ return new Map();
22
+ }
23
+ if (st.mtimeMs === cache.mtimeMs) return cache.projects;
24
+
25
+ let data;
26
+ try {
27
+ data = JSON.parse(fs.readFileSync(CLAUDE_JSON, 'utf8'));
28
+ } catch {
29
+ return cache.projects; // keep last good copy on parse failure (mid-write)
30
+ }
31
+
32
+ const merged = new Map();
33
+ for (const [rawPath, info] of Object.entries(data.projects || {})) {
34
+ const { root } = worktreeRoot(canonicalize(rawPath));
35
+ const key = root.toLowerCase();
36
+ const entry = merged.get(key) || { path: root, exists: null };
37
+ const modified = info.lastSessionModified || info.lastStartTime || 0;
38
+ const prevModified = entry.lastSessionModified || entry.lastStartTime || 0;
39
+ // Newest-activity variant wins for both metadata and display casing.
40
+ if (!merged.has(key) || modified >= prevModified) {
41
+ entry.path = pickExistingCasing(entry.path, root);
42
+ if (info.lastSessionId) entry.lastSessionId = info.lastSessionId;
43
+ if (modified) entry.lastSessionModified = modified;
44
+ if (info.lastStartTime) entry.lastStartTime = info.lastStartTime;
45
+ if (info.lastSessionFirstPrompt) entry.lastSessionFirstPrompt = info.lastSessionFirstPrompt;
46
+ if (typeof info.lastCost === 'number') entry.lastCost = info.lastCost;
47
+ }
48
+ merged.set(key, entry);
49
+ }
50
+ cache = { mtimeMs: st.mtimeMs, projects: merged };
51
+ return merged;
52
+ }
53
+
54
+ function pickExistingCasing(a, b) {
55
+ if (!a) return b;
56
+ if (a === b) return a;
57
+ // Prefer the variant that exists on disk with that exact casing.
58
+ for (const cand of [b, a]) {
59
+ try {
60
+ const real = fs.realpathSync.native(cand);
61
+ if (path.basename(real) === path.basename(cand)) return cand;
62
+ } catch {
63
+ /* missing path, try next */
64
+ }
65
+ }
66
+ return b;
67
+ }
68
+
69
+ module.exports = { readRegistry };
package/lib/search.js ADDED
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+ // Search across every prompt ever sent (history.jsonl) and session titles.
3
+ // Reads history on demand — it's under 1MB and a search is a click.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const readline = require('readline');
7
+ const { CLAUDE_DIR, worktreeRoot } = require('./paths');
8
+ const { isIgnored } = require('./ignore');
9
+
10
+ const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl');
11
+ const MAX_RESULTS = 60;
12
+
13
+ // 'project:dashboard since:7d fix the build' -> filters plus remaining text.
14
+ // project: substring-matches the canonical project path; since: takes Nd or
15
+ // YYYY-MM-DD (local). Malformed values are dropped rather than erroring.
16
+ function parseSearchQuery(q, now = Date.now()) {
17
+ let project = null;
18
+ let since = null;
19
+ const text = String(q)
20
+ .replace(/(?:^|\s)project:(\S+)/i, (_, v) => { project = v.toLowerCase(); return ' '; })
21
+ .replace(/(?:^|\s)since:(\S+)/i, (_, v) => {
22
+ const rel = /^(\d+)d$/i.exec(v);
23
+ const abs = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
24
+ if (rel) since = now - Number(rel[1]) * 86400000;
25
+ else if (abs) since = new Date(Number(abs[1]), Number(abs[2]) - 1, Number(abs[3])).getTime();
26
+ return ' ';
27
+ })
28
+ .replace(/\s+/g, ' ')
29
+ .trim();
30
+ return { text, project, since };
31
+ }
32
+
33
+ function searchHistory(q) {
34
+ const { text, project, since } = parseSearchQuery(q);
35
+ const needle = text.toLowerCase();
36
+ return new Promise((resolve) => {
37
+ const matches = [];
38
+ const rl = readline.createInterface({
39
+ input: fs.createReadStream(HISTORY_FILE, { encoding: 'utf8' }),
40
+ crlfDelay: Infinity,
41
+ });
42
+ rl.on('line', (line) => {
43
+ if (!line.toLowerCase().includes(needle)) return;
44
+ try {
45
+ const rec = JSON.parse(line);
46
+ if (typeof rec.display !== 'string' || !rec.display.toLowerCase().includes(needle)) return;
47
+ const { root } = worktreeRoot(rec.project || '');
48
+ if (isIgnored(root)) return;
49
+ if (project && !root.toLowerCase().includes(project)) return;
50
+ if (since && !(rec.timestamp >= since)) return;
51
+ const i = rec.display.toLowerCase().indexOf(needle);
52
+ const start = Math.max(0, i - 60);
53
+ matches.push({
54
+ snippet: (start > 0 ? '…' : '') + rec.display.slice(start, i + needle.length + 120).replace(/\s+/g, ' '),
55
+ project: root,
56
+ timestamp: rec.timestamp || null,
57
+ sessionId: rec.sessionId || null,
58
+ });
59
+ } catch {
60
+ /* skip */
61
+ }
62
+ });
63
+ const done = () => {
64
+ matches.reverse(); // newest first
65
+ resolve(matches.slice(0, MAX_RESULTS));
66
+ };
67
+ rl.on('close', done);
68
+ rl.on('error', done);
69
+ });
70
+ }
71
+
72
+ // Title matches come from the collector's in-memory transcript groups.
73
+ function searchTitles(q, transcriptGroups, sessionTitle) {
74
+ const { text, project, since } = parseSearchQuery(q);
75
+ const needle = text.toLowerCase();
76
+ const out = [];
77
+ for (const g of transcriptGroups.values()) {
78
+ if (isIgnored(g.path)) continue;
79
+ if (project && !g.path.toLowerCase().includes(project)) continue;
80
+ for (const m of g.sessions) {
81
+ if (since && !(m.lastActivityAt >= since)) continue;
82
+ const { title } = sessionTitle(m);
83
+ if (title && title.toLowerCase().includes(needle)) {
84
+ out.push({ title, project: g.path, sessionId: m.sessionId, lastActivityAt: m.lastActivityAt });
85
+ }
86
+ }
87
+ }
88
+ out.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
89
+ return out.slice(0, MAX_RESULTS);
90
+ }
91
+
92
+ // One transcript line against a lowercase needle -> {role, text, ts} | null.
93
+ // Only real conversation text counts: tool results and harness noise don't.
94
+ function transcriptLineMatch(line, needle) {
95
+ if (!line.toLowerCase().includes(needle)) return null;
96
+ if (line.includes('"tool_use_id"')) return null;
97
+ let rec;
98
+ try {
99
+ rec = JSON.parse(line);
100
+ } catch {
101
+ return null;
102
+ }
103
+ const m = rec.message;
104
+ if (!m || (rec.type !== 'user' && rec.type !== 'assistant')) return null;
105
+ let text = null;
106
+ const c = m.content;
107
+ if (typeof c === 'string') text = c;
108
+ else if (Array.isArray(c)) {
109
+ const part = c.find((x) => x && x.type === 'text' && x.text && x.text.toLowerCase().includes(needle));
110
+ text = part && part.text;
111
+ }
112
+ if (!text) return null;
113
+ const t = text.trim();
114
+ if (t.startsWith('<') || t.startsWith('[SYSTEM') || t.startsWith('Caveat:') || t.startsWith('Base directory')) return null;
115
+ const i = t.toLowerCase().indexOf(needle);
116
+ if (i === -1) return null;
117
+ const start = Math.max(0, i - 60);
118
+ return {
119
+ role: rec.type === 'user' ? 'you' : 'claude',
120
+ text: (start > 0 ? '…' : '') + t.slice(start, i + needle.length + 140).replace(/\s+/g, ' '),
121
+ ts: rec.timestamp ? Date.parse(rec.timestamp) || null : null,
122
+ };
123
+ }
124
+
125
+ // Stream-search full transcripts, newest sessions first. No index, no cache:
126
+ // ~200MB greps in a couple of seconds, and the result reports its coverage.
127
+ const MAX_DEEP_RESULTS = 40;
128
+ const MAX_PER_SESSION = 3;
129
+
130
+ async function searchTranscripts(q, transcriptGroups) {
131
+ const { text, project, since } = parseSearchQuery(q);
132
+ const needle = text.toLowerCase();
133
+ if (!needle) return { matches: [], scanned: 0, total: 0, complete: true };
134
+ const sessions = [];
135
+ for (const g of transcriptGroups.values()) {
136
+ if (isIgnored(g.path)) continue;
137
+ if (project && !g.path.toLowerCase().includes(project)) continue;
138
+ for (const m of g.sessions) {
139
+ if (since && !(m.lastActivityAt >= since)) continue;
140
+ sessions.push({ file: m.file, sessionId: m.sessionId, project: g.path, lastActivityAt: m.lastActivityAt });
141
+ }
142
+ }
143
+ sessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
144
+ const matches = [];
145
+ let scanned = 0;
146
+ for (const s of sessions) {
147
+ if (matches.length >= MAX_DEEP_RESULTS) break;
148
+ scanned++;
149
+ await new Promise((resolve) => {
150
+ let found = 0;
151
+ const rl = readline.createInterface({
152
+ input: fs.createReadStream(s.file, { encoding: 'utf8' }),
153
+ crlfDelay: Infinity,
154
+ });
155
+ rl.on('line', (line) => {
156
+ if (matches.length >= MAX_DEEP_RESULTS || found >= MAX_PER_SESSION) return;
157
+ const hit = transcriptLineMatch(line, needle);
158
+ if (hit) {
159
+ found++;
160
+ matches.push({ ...hit, sessionId: s.sessionId, project: s.project });
161
+ }
162
+ });
163
+ rl.on('close', resolve);
164
+ rl.on('error', resolve);
165
+ });
166
+ }
167
+ return { matches, scanned, total: sessions.length, complete: scanned >= sessions.length };
168
+ }
169
+
170
+ module.exports = { searchHistory, searchTitles, parseSearchQuery, transcriptLineMatch, searchTranscripts };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ // Live Claude Code sessions from ~/.claude/sessions/<pid>.json.
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+ const { CLAUDE_DIR } = require('./paths');
6
+
7
+ const SESSIONS_DIR = path.join(CLAUDE_DIR, 'sessions');
8
+ const STALE_MS = 24 * 60 * 60 * 1000; // updatedAt older than this => pid reuse, ignore
9
+
10
+ async function readLiveSessions() {
11
+ let files;
12
+ try {
13
+ files = await fs.readdir(SESSIONS_DIR);
14
+ } catch {
15
+ return [];
16
+ }
17
+ const out = [];
18
+ for (const f of files) {
19
+ if (!f.endsWith('.json')) continue;
20
+ try {
21
+ const raw = JSON.parse(await fs.readFile(path.join(SESSIONS_DIR, f), 'utf8'));
22
+ const pidFromName = parseInt(f, 10);
23
+ if (!raw.pid || raw.pid !== pidFromName) continue;
24
+ if (!isAlive(raw.pid)) continue;
25
+ const updated = raw.statusUpdatedAt || raw.updatedAt || raw.startedAt || 0;
26
+ if (Date.now() - updated > STALE_MS) continue;
27
+ out.push({
28
+ pid: raw.pid,
29
+ sessionId: raw.sessionId,
30
+ cwd: raw.cwd,
31
+ name: raw.name || null,
32
+ status: raw.status || 'unknown',
33
+ waitingFor: raw.waitingFor || null,
34
+ startedAt: raw.startedAt || null,
35
+ statusUpdatedAt: raw.statusUpdatedAt || raw.updatedAt || null,
36
+ kind: raw.kind || null,
37
+ });
38
+ } catch {
39
+ /* unreadable/partial file: skip */
40
+ }
41
+ }
42
+ out.sort((a, b) => rank(a) - rank(b) || (b.startedAt || 0) - (a.startedAt || 0));
43
+ return out;
44
+ }
45
+
46
+ function rank(s) {
47
+ if (s.status === 'waiting') return 0;
48
+ if (s.status === 'busy') return 1;
49
+ return 2;
50
+ }
51
+
52
+ function isAlive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (e) {
57
+ return e.code === 'EPERM'; // exists but not ours
58
+ }
59
+ }
60
+
61
+ module.exports = { readLiveSessions };
package/lib/tasks.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ // Todo lists for live sessions: ~/.claude/tasks/<sessionId>/<n>.json
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+ const { CLAUDE_DIR } = require('./paths');
6
+
7
+ const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
8
+
9
+ // cache: sessionId -> { mtimeMs, result }
10
+ const cache = new Map();
11
+
12
+ async function readTasks(sessionId) {
13
+ if (!sessionId) return null;
14
+ const dir = path.join(TASKS_DIR, sessionId);
15
+ let st;
16
+ try {
17
+ st = await fs.stat(dir);
18
+ } catch {
19
+ return null;
20
+ }
21
+ const hit = cache.get(sessionId);
22
+ if (hit && hit.mtimeMs === st.mtimeMs) return hit.result;
23
+
24
+ let files;
25
+ try {
26
+ files = await fs.readdir(dir);
27
+ } catch {
28
+ return null;
29
+ }
30
+ const tasks = [];
31
+ for (const f of files) {
32
+ if (!/^\d+\.json$/.test(f)) continue;
33
+ try {
34
+ tasks.push(JSON.parse(await fs.readFile(path.join(dir, f), 'utf8')));
35
+ } catch {
36
+ /* skip partial writes */
37
+ }
38
+ }
39
+ if (!tasks.length) return null;
40
+ tasks.sort((a, b) => Number(a.id) - Number(b.id));
41
+ const current = tasks.find((t) => t.status === 'in_progress') || null;
42
+ const result = {
43
+ currentTask: current ? { subject: current.subject, activeForm: current.activeForm || current.subject } : null,
44
+ tasksSummary: {
45
+ completed: tasks.filter((t) => t.status === 'completed').length,
46
+ inProgress: tasks.filter((t) => t.status === 'in_progress').length,
47
+ pending: tasks.filter((t) => t.status === 'pending').length,
48
+ },
49
+ };
50
+ cache.set(sessionId, { mtimeMs: st.mtimeMs, result });
51
+ return result;
52
+ }
53
+
54
+ module.exports = { readTasks };