claudit 0.1.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.
Files changed (40) hide show
  1. package/README.md +139 -0
  2. package/bin/claudit-mcp.js +3 -0
  3. package/bin/claudit.js +11 -0
  4. package/client/dist/assets/index-DhjH_2Wd.css +32 -0
  5. package/client/dist/assets/index-Dwom-XdC.js +98 -0
  6. package/client/dist/index.html +13 -0
  7. package/package.json +40 -0
  8. package/server/dist/server/src/index.js +170 -0
  9. package/server/dist/server/src/mcp-server.js +144 -0
  10. package/server/dist/server/src/routes/cron.js +101 -0
  11. package/server/dist/server/src/routes/filesystem.js +71 -0
  12. package/server/dist/server/src/routes/groups.js +60 -0
  13. package/server/dist/server/src/routes/sessions.js +206 -0
  14. package/server/dist/server/src/routes/todo.js +93 -0
  15. package/server/dist/server/src/routes/todoProviders.js +179 -0
  16. package/server/dist/server/src/services/claudeProcess.js +220 -0
  17. package/server/dist/server/src/services/cronScheduler.js +163 -0
  18. package/server/dist/server/src/services/cronStorage.js +154 -0
  19. package/server/dist/server/src/services/database.js +103 -0
  20. package/server/dist/server/src/services/eventBus.js +11 -0
  21. package/server/dist/server/src/services/groupStorage.js +52 -0
  22. package/server/dist/server/src/services/historyIndex.js +100 -0
  23. package/server/dist/server/src/services/jsonStore.js +41 -0
  24. package/server/dist/server/src/services/managedSessions.js +96 -0
  25. package/server/dist/server/src/services/providerConfigStorage.js +80 -0
  26. package/server/dist/server/src/services/providers/TodoProvider.js +1 -0
  27. package/server/dist/server/src/services/providers/larkDocsProvider.js +75 -0
  28. package/server/dist/server/src/services/providers/mcpClient.js +151 -0
  29. package/server/dist/server/src/services/providers/meegoProvider.js +99 -0
  30. package/server/dist/server/src/services/providers/registry.js +17 -0
  31. package/server/dist/server/src/services/providers/supabaseProvider.js +172 -0
  32. package/server/dist/server/src/services/ptyManager.js +263 -0
  33. package/server/dist/server/src/services/sessionIndexCache.js +24 -0
  34. package/server/dist/server/src/services/sessionParser.js +98 -0
  35. package/server/dist/server/src/services/sessionScanner.js +244 -0
  36. package/server/dist/server/src/services/todoStorage.js +112 -0
  37. package/server/dist/server/src/services/todoSyncEngine.js +170 -0
  38. package/server/dist/server/src/types.js +1 -0
  39. package/server/dist/shared/src/index.js +1 -0
  40. package/server/dist/shared/src/types.js +2 -0
@@ -0,0 +1,163 @@
1
+ import cron from 'node-cron';
2
+ import { spawn, execFile } from 'child_process';
3
+ import { getAllTasks, getTask, updateTask, createExecution, updateExecution, } from './cronStorage.js';
4
+ import { getAllTodos } from './todoStorage.js';
5
+ import { addManagedSession, renameManagedSession } from './managedSessions.js';
6
+ import { invalidateSessionCache } from './historyIndex.js';
7
+ const scheduledJobs = new Map();
8
+ const MAX_OUTPUT_SIZE = 512 * 1024; // 512 KB limit per stream
9
+ const PRIORITY_ICON = { high: '🔴', medium: '🟡', low: '🟢' };
10
+ function formatTodosForPrompt() {
11
+ const todos = getAllTodos().filter(t => !t.completed);
12
+ if (todos.length === 0)
13
+ return '(No pending todos)';
14
+ return todos.map((t, i) => {
15
+ const icon = PRIORITY_ICON[t.priority] || '⚪';
16
+ let line = `${i + 1}. ${icon} [${t.priority}] ${t.title}`;
17
+ if (t.description)
18
+ line += `\n ${t.description}`;
19
+ if (t.provider?.externalUrl)
20
+ line += `\n Link: ${t.provider.externalUrl}`;
21
+ return line;
22
+ }).join('\n');
23
+ }
24
+ function resolvePrompt(prompt) {
25
+ if (!prompt.includes('{{todos}}'))
26
+ return prompt;
27
+ return prompt.replace(/\{\{todos\}\}/g, formatTodosForPrompt());
28
+ }
29
+ function appendCapped(current, chunk) {
30
+ if (current.length >= MAX_OUTPUT_SIZE)
31
+ return current;
32
+ const remaining = MAX_OUTPUT_SIZE - current.length;
33
+ return current + (chunk.length <= remaining ? chunk : chunk.slice(0, remaining) + '\n[output truncated]');
34
+ }
35
+ function createSessionAsync(taskName, cwd, env) {
36
+ return new Promise((resolve) => {
37
+ const initPrompt = `Workflow task: ${taskName} — starting execution.`;
38
+ const child = execFile('claude', ['-p', '--output-format', 'json', '--max-turns', '1'], { cwd, encoding: 'utf-8', timeout: 60_000, env }, (err, stdout) => {
39
+ if (err) {
40
+ console.warn(`[cron] Failed to create session for task ${taskName}, falling back:`, err.message);
41
+ resolve(undefined);
42
+ return;
43
+ }
44
+ try {
45
+ const parsed = JSON.parse(stdout);
46
+ resolve(parsed.session_id || undefined);
47
+ }
48
+ catch {
49
+ resolve(undefined);
50
+ }
51
+ });
52
+ child.stdin?.write(initPrompt);
53
+ child.stdin?.end();
54
+ });
55
+ }
56
+ async function executeTask(taskId) {
57
+ const task = getTask(taskId);
58
+ if (!task)
59
+ return;
60
+ console.log(`[cron] Executing task: ${task.name} (${taskId})`);
61
+ const resolvedPrompt = resolvePrompt(task.prompt);
62
+ const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'CLAUDECODE'));
63
+ const cwd = task.projectPath || undefined;
64
+ // Step 1: Create a session asynchronously (non-blocking)
65
+ const sessionId = await createSessionAsync(task.name, cwd, cleanEnv);
66
+ if (sessionId) {
67
+ addManagedSession(sessionId, task.projectPath || '');
68
+ renameManagedSession(sessionId, `Workflow: ${task.name}`);
69
+ invalidateSessionCache();
70
+ console.log(`[cron] Created session ${sessionId} for task ${task.name}`);
71
+ }
72
+ const exec = createExecution(taskId, sessionId);
73
+ // Step 2: Execute the full prompt (resume session if available)
74
+ const args = sessionId
75
+ ? ['--session-id', sessionId, '--resume', '-p', '--output-format', 'text', '--dangerously-skip-permissions']
76
+ : ['-p', '--output-format', 'text', '--dangerously-skip-permissions'];
77
+ const child = spawn('claude', args, {
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ cwd,
80
+ env: cleanEnv,
81
+ });
82
+ let output = '';
83
+ let errorOutput = '';
84
+ child.stdout.on('data', (data) => {
85
+ output = appendCapped(output, data.toString());
86
+ });
87
+ child.stderr.on('data', (data) => {
88
+ errorOutput = appendCapped(errorOutput, data.toString());
89
+ });
90
+ child.stdin.write(resolvedPrompt);
91
+ child.stdin.end();
92
+ child.on('close', (code) => {
93
+ const finishedAt = new Date().toISOString();
94
+ const status = code === 0 ? 'success' : 'error';
95
+ updateExecution(exec.id, {
96
+ finishedAt,
97
+ status,
98
+ output: output || undefined,
99
+ error: code !== 0 ? (errorOutput || `Exit code: ${code}`) : undefined,
100
+ });
101
+ updateTask(taskId, { lastRun: finishedAt });
102
+ console.log(`[cron] Task ${task.name} finished with status: ${status}`);
103
+ });
104
+ child.on('error', (err) => {
105
+ updateExecution(exec.id, {
106
+ finishedAt: new Date().toISOString(),
107
+ status: 'error',
108
+ error: err.message,
109
+ });
110
+ console.error(`[cron] Task ${task.name} spawn error:`, err.message);
111
+ });
112
+ }
113
+ function scheduleTask(taskId, cronExpression) {
114
+ // Remove existing schedule if any
115
+ unscheduleTask(taskId);
116
+ if (!cron.validate(cronExpression)) {
117
+ console.error(`[cron] Invalid cron expression for task ${taskId}: ${cronExpression}`);
118
+ return;
119
+ }
120
+ const job = cron.schedule(cronExpression, () => {
121
+ executeTask(taskId);
122
+ });
123
+ scheduledJobs.set(taskId, job);
124
+ console.log(`[cron] Scheduled task ${taskId}: ${cronExpression}`);
125
+ }
126
+ function unscheduleTask(taskId) {
127
+ const existing = scheduledJobs.get(taskId);
128
+ if (existing) {
129
+ existing.stop();
130
+ scheduledJobs.delete(taskId);
131
+ }
132
+ }
133
+ export function initScheduler() {
134
+ console.log('[cron] Initializing scheduler...');
135
+ const tasks = getAllTasks();
136
+ for (const task of tasks) {
137
+ if (task.enabled) {
138
+ scheduleTask(task.id, task.cronExpression);
139
+ }
140
+ }
141
+ console.log(`[cron] Loaded ${tasks.filter(t => t.enabled).length} active tasks`);
142
+ }
143
+ export function refreshTask(taskId) {
144
+ const task = getTask(taskId);
145
+ if (!task || !task.enabled) {
146
+ unscheduleTask(taskId);
147
+ }
148
+ else {
149
+ scheduleTask(taskId, task.cronExpression);
150
+ }
151
+ }
152
+ export function removeTask(taskId) {
153
+ unscheduleTask(taskId);
154
+ }
155
+ export function runTaskNow(taskId) {
156
+ executeTask(taskId);
157
+ }
158
+ export function stopAllJobs() {
159
+ for (const [id, job] of scheduledJobs) {
160
+ job.stop();
161
+ }
162
+ scheduledJobs.clear();
163
+ }
@@ -0,0 +1,154 @@
1
+ import crypto from 'crypto';
2
+ import { db } from './database.js';
3
+ const MAX_EXECUTIONS_PER_TASK = 100;
4
+ // --- Prepared statements: Tasks ---
5
+ const stmtAllTasks = db.prepare('SELECT * FROM cron_tasks');
6
+ const stmtTaskById = db.prepare('SELECT * FROM cron_tasks WHERE id = ?');
7
+ const stmtInsertTask = db.prepare(`
8
+ INSERT INTO cron_tasks (id, name, cronExpression, prompt, enabled, projectPath, lastRun, nextRun, createdAt)
9
+ VALUES (@id, @name, @cronExpression, @prompt, @enabled, @projectPath, @lastRun, @nextRun, @createdAt)
10
+ `);
11
+ const stmtDeleteTask = db.prepare('DELETE FROM cron_tasks WHERE id = ?');
12
+ const stmtUpdateTask = db.prepare(`
13
+ UPDATE cron_tasks SET name = @name, cronExpression = @cronExpression, prompt = @prompt,
14
+ enabled = @enabled, projectPath = @projectPath, lastRun = @lastRun, nextRun = @nextRun
15
+ WHERE id = @id
16
+ `);
17
+ // --- Prepared statements: Executions ---
18
+ const stmtExecsByTask = db.prepare('SELECT * FROM cron_executions WHERE taskId = ? ORDER BY startedAt DESC');
19
+ const stmtInsertExec = db.prepare(`
20
+ INSERT INTO cron_executions (id, taskId, startedAt, finishedAt, status, output, error, sessionId)
21
+ VALUES (@id, @taskId, @startedAt, @finishedAt, @status, @output, @error, @sessionId)
22
+ `);
23
+ const stmtExecById = db.prepare('SELECT * FROM cron_executions WHERE id = ?');
24
+ const stmtDeleteExec = db.prepare('DELETE FROM cron_executions WHERE id = ?');
25
+ const stmtUpdateExec = db.prepare(`
26
+ UPDATE cron_executions SET finishedAt = @finishedAt, status = @status, output = @output, error = @error, sessionId = @sessionId
27
+ WHERE id = @id
28
+ `);
29
+ // Trim: keep latest MAX_EXECUTIONS_PER_TASK, delete the rest
30
+ const stmtTrimExecs = db.prepare(`
31
+ DELETE FROM cron_executions WHERE taskId = ? AND id NOT IN (
32
+ SELECT id FROM cron_executions WHERE taskId = ? ORDER BY startedAt DESC LIMIT ?
33
+ )
34
+ `);
35
+ // --- Row mappers ---
36
+ function rowToTask(row) {
37
+ const task = {
38
+ id: row.id,
39
+ name: row.name,
40
+ cronExpression: row.cronExpression,
41
+ prompt: row.prompt,
42
+ enabled: row.enabled === 1,
43
+ createdAt: row.createdAt,
44
+ };
45
+ if (row.projectPath != null)
46
+ task.projectPath = row.projectPath;
47
+ if (row.lastRun != null)
48
+ task.lastRun = row.lastRun;
49
+ if (row.nextRun != null)
50
+ task.nextRun = row.nextRun;
51
+ return task;
52
+ }
53
+ function taskToParams(task) {
54
+ return {
55
+ id: task.id,
56
+ name: task.name,
57
+ cronExpression: task.cronExpression,
58
+ prompt: task.prompt,
59
+ enabled: task.enabled ? 1 : 0,
60
+ projectPath: task.projectPath ?? null,
61
+ lastRun: task.lastRun ?? null,
62
+ nextRun: task.nextRun ?? null,
63
+ createdAt: task.createdAt,
64
+ };
65
+ }
66
+ function rowToExec(row) {
67
+ const exec = {
68
+ id: row.id,
69
+ taskId: row.taskId,
70
+ startedAt: row.startedAt,
71
+ status: row.status,
72
+ };
73
+ if (row.finishedAt != null)
74
+ exec.finishedAt = row.finishedAt;
75
+ if (row.output != null)
76
+ exec.output = row.output;
77
+ if (row.error != null)
78
+ exec.error = row.error;
79
+ if (row.sessionId != null)
80
+ exec.sessionId = row.sessionId;
81
+ return exec;
82
+ }
83
+ // --- Tasks ---
84
+ export function getAllTasks() {
85
+ return stmtAllTasks.all().map(rowToTask);
86
+ }
87
+ export function getTask(id) {
88
+ const row = stmtTaskById.get(id);
89
+ return row ? rowToTask(row) : undefined;
90
+ }
91
+ export function createTask(data) {
92
+ const task = {
93
+ ...data,
94
+ id: crypto.randomUUID(),
95
+ createdAt: new Date().toISOString(),
96
+ };
97
+ stmtInsertTask.run(taskToParams(task));
98
+ return task;
99
+ }
100
+ export function updateTask(id, updates) {
101
+ const existing = getTask(id);
102
+ if (!existing)
103
+ return null;
104
+ const merged = { ...existing, ...updates, id };
105
+ stmtUpdateTask.run(taskToParams(merged));
106
+ return merged;
107
+ }
108
+ export function deleteTask(id) {
109
+ // CASCADE will delete related executions
110
+ const result = stmtDeleteTask.run(id);
111
+ return result.changes > 0;
112
+ }
113
+ // --- Executions ---
114
+ export function getTaskExecutions(taskId) {
115
+ return stmtExecsByTask.all(taskId).map(rowToExec);
116
+ }
117
+ export function createExecution(taskId, sessionId) {
118
+ const exec = {
119
+ id: crypto.randomUUID(),
120
+ taskId,
121
+ startedAt: new Date().toISOString(),
122
+ status: 'running',
123
+ sessionId,
124
+ };
125
+ stmtInsertExec.run({
126
+ id: exec.id,
127
+ taskId: exec.taskId,
128
+ startedAt: exec.startedAt,
129
+ finishedAt: null,
130
+ status: exec.status,
131
+ output: null,
132
+ error: null,
133
+ sessionId: sessionId ?? null,
134
+ });
135
+ // Trim old executions
136
+ stmtTrimExecs.run(taskId, taskId, MAX_EXECUTIONS_PER_TASK);
137
+ return exec;
138
+ }
139
+ export function updateExecution(id, updates) {
140
+ const row = stmtExecById.get(id);
141
+ if (!row)
142
+ return null;
143
+ const existing = rowToExec(row);
144
+ const merged = { ...existing, ...updates, id };
145
+ stmtUpdateExec.run({
146
+ id: merged.id,
147
+ finishedAt: merged.finishedAt ?? null,
148
+ status: merged.status,
149
+ output: merged.output ?? null,
150
+ error: merged.error ?? null,
151
+ sessionId: merged.sessionId ?? null,
152
+ });
153
+ return merged;
154
+ }
@@ -0,0 +1,103 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+ import fs from 'fs';
4
+ import Database from 'better-sqlite3';
5
+ const DATA_DIR = path.join(os.homedir(), '.claudit');
6
+ const DB_PATH = path.join(DATA_DIR, 'claudit.db');
7
+ if (!fs.existsSync(DATA_DIR)) {
8
+ fs.mkdirSync(DATA_DIR, { recursive: true });
9
+ }
10
+ const db = new Database(DB_PATH);
11
+ // Enable WAL mode for concurrent read/write (MCP + Web server)
12
+ db.pragma('journal_mode = WAL');
13
+ db.pragma('foreign_keys = ON');
14
+ db.pragma('busy_timeout = 5000');
15
+ // --- Schema ---
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS todos (
18
+ id TEXT PRIMARY KEY,
19
+ title TEXT NOT NULL,
20
+ description TEXT,
21
+ completed INTEGER NOT NULL DEFAULT 0,
22
+ priority TEXT NOT NULL DEFAULT 'medium',
23
+ sessionId TEXT,
24
+ sessionLabel TEXT,
25
+ createdAt TEXT NOT NULL,
26
+ completedAt TEXT,
27
+ -- provider fields (NULL when no provider)
28
+ providerId TEXT,
29
+ configId TEXT,
30
+ externalId TEXT,
31
+ externalUrl TEXT,
32
+ lastSyncedAt TEXT,
33
+ syncStatus TEXT,
34
+ syncError TEXT
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS cron_tasks (
38
+ id TEXT PRIMARY KEY,
39
+ name TEXT NOT NULL,
40
+ cronExpression TEXT NOT NULL,
41
+ prompt TEXT NOT NULL,
42
+ enabled INTEGER NOT NULL DEFAULT 1,
43
+ projectPath TEXT,
44
+ lastRun TEXT,
45
+ nextRun TEXT,
46
+ createdAt TEXT NOT NULL
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS cron_executions (
50
+ id TEXT PRIMARY KEY,
51
+ taskId TEXT NOT NULL REFERENCES cron_tasks(id) ON DELETE CASCADE,
52
+ startedAt TEXT NOT NULL,
53
+ finishedAt TEXT,
54
+ status TEXT NOT NULL DEFAULT 'running',
55
+ output TEXT,
56
+ error TEXT
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS provider_configs (
60
+ id TEXT PRIMARY KEY,
61
+ providerId TEXT NOT NULL,
62
+ name TEXT NOT NULL,
63
+ enabled INTEGER NOT NULL DEFAULT 1,
64
+ config TEXT NOT NULL DEFAULT '{}',
65
+ syncIntervalMinutes INTEGER,
66
+ lastSyncAt TEXT,
67
+ lastSyncError TEXT,
68
+ createdAt TEXT NOT NULL
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS managed_sessions (
72
+ sessionId TEXT PRIMARY KEY,
73
+ projectPath TEXT NOT NULL DEFAULT '',
74
+ displayName TEXT,
75
+ archived INTEGER NOT NULL DEFAULT 0,
76
+ pinned INTEGER NOT NULL DEFAULT 0,
77
+ createdAt TEXT NOT NULL
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS todo_groups (
81
+ id TEXT PRIMARY KEY,
82
+ name TEXT NOT NULL,
83
+ position INTEGER NOT NULL DEFAULT 0,
84
+ createdAt TEXT NOT NULL
85
+ );
86
+ `);
87
+ // Idempotent ALTER TABLE helper: only swallow "duplicate column" errors
88
+ function addColumnIfNotExists(sql) {
89
+ try {
90
+ db.exec(sql);
91
+ }
92
+ catch (e) {
93
+ if (!e.message?.includes('duplicate column'))
94
+ throw e;
95
+ }
96
+ }
97
+ addColumnIfNotExists('ALTER TABLE todos ADD COLUMN groupId TEXT REFERENCES todo_groups(id) ON DELETE SET NULL');
98
+ addColumnIfNotExists('ALTER TABLE todos ADD COLUMN position INTEGER NOT NULL DEFAULT 0');
99
+ addColumnIfNotExists('ALTER TABLE cron_executions ADD COLUMN sessionId TEXT');
100
+ export { db };
101
+ export function closeDb() {
102
+ db.close();
103
+ }
@@ -0,0 +1,11 @@
1
+ import { EventEmitter } from 'events';
2
+ class EventBus extends EventEmitter {
3
+ emitSessionEvent(event) {
4
+ this.emit('session', event);
5
+ }
6
+ onSessionEvent(handler) {
7
+ this.on('session', handler);
8
+ return () => this.off('session', handler);
9
+ }
10
+ }
11
+ export const eventBus = new EventBus();
@@ -0,0 +1,52 @@
1
+ import crypto from 'crypto';
2
+ import { db } from './database.js';
3
+ const stmtAll = db.prepare('SELECT * FROM todo_groups ORDER BY position ASC, createdAt ASC');
4
+ const stmtById = db.prepare('SELECT * FROM todo_groups WHERE id = ?');
5
+ const stmtInsert = db.prepare(`
6
+ INSERT INTO todo_groups (id, name, position, createdAt)
7
+ VALUES (@id, @name, @position, @createdAt)
8
+ `);
9
+ const stmtUpdate = db.prepare('UPDATE todo_groups SET name = ?, position = ? WHERE id = ?');
10
+ const stmtDelete = db.prepare('DELETE FROM todo_groups WHERE id = ?');
11
+ const stmtMaxPosition = db.prepare('SELECT COALESCE(MAX(position), 0) as maxPos FROM todo_groups');
12
+ function rowToGroup(row) {
13
+ return {
14
+ id: row.id,
15
+ name: row.name,
16
+ position: row.position,
17
+ createdAt: row.createdAt,
18
+ };
19
+ }
20
+ export function getAllGroups() {
21
+ return stmtAll.all().map(rowToGroup);
22
+ }
23
+ export function getGroup(id) {
24
+ const row = stmtById.get(id);
25
+ return row ? rowToGroup(row) : undefined;
26
+ }
27
+ export function createGroup(name) {
28
+ const maxPos = stmtMaxPosition.get()?.maxPos ?? 0;
29
+ const group = {
30
+ id: crypto.randomUUID(),
31
+ name,
32
+ position: maxPos + 1000,
33
+ createdAt: new Date().toISOString(),
34
+ };
35
+ stmtInsert.run(group);
36
+ return group;
37
+ }
38
+ export function updateGroup(id, updates) {
39
+ const existing = getGroup(id);
40
+ if (!existing)
41
+ return null;
42
+ const merged = {
43
+ ...existing,
44
+ ...updates,
45
+ };
46
+ stmtUpdate.run(merged.name, merged.position, id);
47
+ return merged;
48
+ }
49
+ export function deleteGroup(id) {
50
+ const result = stmtDelete.run(id);
51
+ return result.changes > 0;
52
+ }
@@ -0,0 +1,100 @@
1
+ import { getManagedSessionMap, getArchivedSessionIds, getPinnedSessionIds } from './managedSessions.js';
2
+ import { getActivePtySessions } from './ptyManager.js';
3
+ import { scanProjectSessions } from './sessionScanner.js';
4
+ let cache = null;
5
+ const CACHE_TTL = 30_000; // 30 seconds
6
+ /** Invalidate the session cache (e.g. after creating a new session) */
7
+ export function invalidateSessionCache() {
8
+ cache = null;
9
+ }
10
+ /** Get sessions grouped by project, with optional search filter */
11
+ export function getSessionIndex(query, hideEmpty, managedOnly, includeArchived) {
12
+ if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
13
+ return filterGroups(overlayRunningStatus(cache.data), query, hideEmpty, managedOnly, includeArchived);
14
+ }
15
+ const summaries = scanProjectSessions();
16
+ // Group by project
17
+ const groupMap = new Map();
18
+ for (const s of summaries) {
19
+ let group = groupMap.get(s.projectHash);
20
+ if (!group) {
21
+ group = { projectPath: s.projectPath, projectHash: s.projectHash, sessions: [] };
22
+ groupMap.set(s.projectHash, group);
23
+ }
24
+ group.sessions.push(s);
25
+ }
26
+ // Sort sessions within each group by timestamp desc
27
+ const groups = Array.from(groupMap.values());
28
+ for (const g of groups) {
29
+ g.sessions.sort((a, b) => b.timestamp - a.timestamp);
30
+ }
31
+ // Sort groups by most recent session
32
+ groups.sort((a, b) => {
33
+ const aTime = a.sessions[0]?.timestamp ?? 0;
34
+ const bTime = b.sessions[0]?.timestamp ?? 0;
35
+ return bTime - aTime;
36
+ });
37
+ cache = { data: groups, timestamp: Date.now() };
38
+ return filterGroups(overlayPinnedStatus(overlayRunningStatus(groups)), query, hideEmpty, managedOnly, includeArchived);
39
+ }
40
+ /** Overlay running status from active PTY sessions (not cached) */
41
+ function overlayRunningStatus(groups) {
42
+ const active = getActivePtySessions();
43
+ if (active.size === 0)
44
+ return groups;
45
+ return groups.map(g => ({
46
+ ...g,
47
+ sessions: g.sessions.map(s => active.has(s.sessionId) ? { ...s, status: 'running' } : s),
48
+ }));
49
+ }
50
+ /** Overlay pinned status and sort pinned sessions first within each group */
51
+ function overlayPinnedStatus(groups) {
52
+ const pinned = getPinnedSessionIds();
53
+ if (pinned.size === 0)
54
+ return groups;
55
+ return groups.map(g => {
56
+ const sessions = g.sessions.map(s => pinned.has(s.sessionId) ? { ...s, pinned: true } : s);
57
+ sessions.sort((a, b) => {
58
+ if (a.pinned && !b.pinned)
59
+ return -1;
60
+ if (!a.pinned && b.pinned)
61
+ return 1;
62
+ return 0; // preserve existing order within same pin status
63
+ });
64
+ return { ...g, sessions };
65
+ });
66
+ }
67
+ function filterGroups(groups, query, hideEmpty, managedOnly, includeArchived) {
68
+ let managedSet = null;
69
+ if (managedOnly) {
70
+ managedSet = new Set(getManagedSessionMap().keys());
71
+ }
72
+ const archivedIds = getArchivedSessionIds();
73
+ return groups
74
+ .map(g => ({
75
+ ...g,
76
+ sessions: g.sessions.filter(s => {
77
+ if (includeArchived) {
78
+ if (!archivedIds.has(s.sessionId))
79
+ return false;
80
+ }
81
+ else {
82
+ if (archivedIds.has(s.sessionId))
83
+ return false;
84
+ }
85
+ if (hideEmpty && s.messageCount === 0)
86
+ return false;
87
+ if (managedSet && !managedSet.has(s.sessionId))
88
+ return false;
89
+ if (query) {
90
+ const q = query.toLowerCase();
91
+ return (s.lastMessage.toLowerCase().includes(q) ||
92
+ s.projectPath.toLowerCase().includes(q) ||
93
+ s.sessionId.includes(q) ||
94
+ (s.displayName?.toLowerCase().includes(q) ?? false));
95
+ }
96
+ return true;
97
+ }),
98
+ }))
99
+ .filter(g => g.sessions.length > 0);
100
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ /**
5
+ * Generic JSON file store with atomic writes (temp file + rename).
6
+ */
7
+ export class JsonStore {
8
+ filePath;
9
+ fallback;
10
+ constructor(filePath, fallback) {
11
+ this.filePath = filePath;
12
+ this.fallback = fallback;
13
+ }
14
+ read() {
15
+ try {
16
+ if (fs.existsSync(this.filePath)) {
17
+ return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
18
+ }
19
+ }
20
+ catch {
21
+ // corrupt file — return fallback
22
+ }
23
+ return this.fallback;
24
+ }
25
+ write(data) {
26
+ const dir = path.dirname(this.filePath);
27
+ if (!fs.existsSync(dir)) {
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ }
30
+ const tmp = path.join(dir, `.tmp-${crypto.randomUUID()}`);
31
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
32
+ fs.renameSync(tmp, this.filePath);
33
+ }
34
+ /** Read, apply mutation, write back atomically. Returns the mutator's return value. */
35
+ update(fn) {
36
+ const data = this.read();
37
+ const result = fn(data);
38
+ this.write(data);
39
+ return result;
40
+ }
41
+ }