claudehq 1.0.3 → 1.0.5
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/core/claude-events.js +2 -1
- package/lib/core/config.js +31 -17
- package/lib/core/event-bus.js +0 -18
- package/lib/index.js +181 -74
- package/lib/routes/api.js +0 -399
- package/lib/routes/orchestration.js +417 -0
- package/lib/routes/spawner.js +335 -0
- package/lib/sessions/manager.js +36 -9
- package/lib/spawner/index.js +51 -0
- package/lib/spawner/path-validator.js +366 -0
- package/lib/spawner/projects-manager.js +421 -0
- package/lib/spawner/session-spawner.js +1010 -0
- package/package.json +1 -1
- package/public/index.html +512 -1371
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects Manager - Track and manage known project directories
|
|
3
|
+
*
|
|
4
|
+
* Provides autocomplete functionality for directory paths and
|
|
5
|
+
* tracks recently used directories for quick access.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
const { validateDirectoryPath, normalizePath } = require('./path-validator');
|
|
13
|
+
|
|
14
|
+
// Default data directory
|
|
15
|
+
const DEFAULT_DATA_DIR = path.join(os.homedir(), '.claude', 'tasks-board');
|
|
16
|
+
const DEFAULT_PROJECTS_FILE = path.join(DEFAULT_DATA_DIR, 'projects.json');
|
|
17
|
+
|
|
18
|
+
// Maximum number of projects to track
|
|
19
|
+
const MAX_TRACKED_PROJECTS = 100;
|
|
20
|
+
|
|
21
|
+
// Maximum autocomplete results
|
|
22
|
+
const MAX_AUTOCOMPLETE_RESULTS = 15;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a projects manager instance
|
|
26
|
+
* @param {Object} options - Configuration options
|
|
27
|
+
* @param {string} options.dataDir - Directory for data storage
|
|
28
|
+
* @param {string} options.projectsFile - Path to projects JSON file
|
|
29
|
+
* @returns {Object} Projects manager instance
|
|
30
|
+
*/
|
|
31
|
+
function createProjectsManager(options = {}) {
|
|
32
|
+
const dataDir = options.dataDir || DEFAULT_DATA_DIR;
|
|
33
|
+
const projectsFile = options.projectsFile || DEFAULT_PROJECTS_FILE;
|
|
34
|
+
|
|
35
|
+
// In-memory cache of projects
|
|
36
|
+
let projects = new Map();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ensure data directory exists
|
|
40
|
+
*/
|
|
41
|
+
function ensureDataDir() {
|
|
42
|
+
if (!fs.existsSync(dataDir)) {
|
|
43
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load projects from disk
|
|
49
|
+
*/
|
|
50
|
+
function loadProjects() {
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(projectsFile)) {
|
|
53
|
+
const content = fs.readFileSync(projectsFile, 'utf-8');
|
|
54
|
+
const data = JSON.parse(content);
|
|
55
|
+
|
|
56
|
+
projects.clear();
|
|
57
|
+
if (Array.isArray(data.projects)) {
|
|
58
|
+
for (const p of data.projects) {
|
|
59
|
+
projects.set(p.path, {
|
|
60
|
+
path: p.path,
|
|
61
|
+
name: p.name || path.basename(p.path),
|
|
62
|
+
lastUsed: p.lastUsed || Date.now(),
|
|
63
|
+
useCount: p.useCount || 1,
|
|
64
|
+
addedAt: p.addedAt || Date.now()
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error('Error loading projects:', e.message);
|
|
71
|
+
projects.clear();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Save projects to disk
|
|
77
|
+
*/
|
|
78
|
+
function saveProjects() {
|
|
79
|
+
try {
|
|
80
|
+
ensureDataDir();
|
|
81
|
+
|
|
82
|
+
// Convert to array and sort by lastUsed
|
|
83
|
+
const projectsArray = Array.from(projects.values())
|
|
84
|
+
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
85
|
+
.slice(0, MAX_TRACKED_PROJECTS);
|
|
86
|
+
|
|
87
|
+
fs.writeFileSync(projectsFile, JSON.stringify({
|
|
88
|
+
version: 1,
|
|
89
|
+
updatedAt: new Date().toISOString(),
|
|
90
|
+
projects: projectsArray
|
|
91
|
+
}, null, 2));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error('Error saving projects:', e.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Track a project directory
|
|
99
|
+
* @param {string} dirPath - Directory path to track
|
|
100
|
+
* @param {string} name - Optional display name
|
|
101
|
+
* @returns {Object} Result { success: boolean, project: object, error: string }
|
|
102
|
+
*/
|
|
103
|
+
function trackProject(dirPath, name = null) {
|
|
104
|
+
const validation = validateDirectoryPath(dirPath);
|
|
105
|
+
if (!validation.valid) {
|
|
106
|
+
return { success: false, error: validation.error };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const normalizedPath = validation.path;
|
|
110
|
+
const existing = projects.get(normalizedPath);
|
|
111
|
+
|
|
112
|
+
if (existing) {
|
|
113
|
+
// Update existing project
|
|
114
|
+
existing.lastUsed = Date.now();
|
|
115
|
+
existing.useCount++;
|
|
116
|
+
if (name) {
|
|
117
|
+
existing.name = name;
|
|
118
|
+
}
|
|
119
|
+
saveProjects();
|
|
120
|
+
return { success: true, project: existing };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Add new project
|
|
124
|
+
const project = {
|
|
125
|
+
path: normalizedPath,
|
|
126
|
+
name: name || path.basename(normalizedPath),
|
|
127
|
+
lastUsed: Date.now(),
|
|
128
|
+
useCount: 1,
|
|
129
|
+
addedAt: Date.now()
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
projects.set(normalizedPath, project);
|
|
133
|
+
|
|
134
|
+
// Trim if over limit
|
|
135
|
+
if (projects.size > MAX_TRACKED_PROJECTS) {
|
|
136
|
+
const sorted = Array.from(projects.entries())
|
|
137
|
+
.sort((a, b) => b[1].lastUsed - a[1].lastUsed);
|
|
138
|
+
projects = new Map(sorted.slice(0, MAX_TRACKED_PROJECTS));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
saveProjects();
|
|
142
|
+
return { success: true, project };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get a tracked project by path
|
|
147
|
+
* @param {string} dirPath - Directory path
|
|
148
|
+
* @returns {Object|null} Project or null
|
|
149
|
+
*/
|
|
150
|
+
function getProject(dirPath) {
|
|
151
|
+
const normalized = normalizePath(dirPath);
|
|
152
|
+
return projects.get(normalized) || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* List all tracked projects
|
|
157
|
+
* @param {Object} options - List options
|
|
158
|
+
* @param {number} options.limit - Max results (default: all)
|
|
159
|
+
* @param {string} options.sortBy - Sort field: 'lastUsed', 'useCount', 'name' (default: 'lastUsed')
|
|
160
|
+
* @returns {Array} Array of project objects
|
|
161
|
+
*/
|
|
162
|
+
function listProjects(options = {}) {
|
|
163
|
+
const { limit, sortBy = 'lastUsed' } = options;
|
|
164
|
+
|
|
165
|
+
let result = Array.from(projects.values());
|
|
166
|
+
|
|
167
|
+
// Sort
|
|
168
|
+
switch (sortBy) {
|
|
169
|
+
case 'useCount':
|
|
170
|
+
result.sort((a, b) => b.useCount - a.useCount);
|
|
171
|
+
break;
|
|
172
|
+
case 'name':
|
|
173
|
+
result.sort((a, b) => a.name.localeCompare(b.name));
|
|
174
|
+
break;
|
|
175
|
+
case 'lastUsed':
|
|
176
|
+
default:
|
|
177
|
+
result.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (limit && limit > 0) {
|
|
181
|
+
result = result.slice(0, limit);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Remove a tracked project
|
|
189
|
+
* @param {string} dirPath - Directory path
|
|
190
|
+
* @returns {Object} Result { success: boolean }
|
|
191
|
+
*/
|
|
192
|
+
function removeProject(dirPath) {
|
|
193
|
+
const normalized = normalizePath(dirPath);
|
|
194
|
+
const existed = projects.delete(normalized);
|
|
195
|
+
if (existed) {
|
|
196
|
+
saveProjects();
|
|
197
|
+
}
|
|
198
|
+
return { success: existed };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get filesystem directory completions
|
|
203
|
+
* @param {string} partial - Partial path to complete
|
|
204
|
+
* @returns {Array} Array of directory paths
|
|
205
|
+
*/
|
|
206
|
+
function getFilesystemCompletions(partial) {
|
|
207
|
+
if (!partial) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const results = [];
|
|
212
|
+
let searchDir;
|
|
213
|
+
let prefix = '';
|
|
214
|
+
|
|
215
|
+
const normalized = normalizePath(partial);
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const stats = fs.statSync(normalized);
|
|
219
|
+
if (stats.isDirectory()) {
|
|
220
|
+
// If the path is a complete directory, list its contents
|
|
221
|
+
searchDir = normalized;
|
|
222
|
+
prefix = '';
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {
|
|
225
|
+
// Path doesn't exist as-is, look for completions
|
|
226
|
+
searchDir = path.dirname(normalized);
|
|
227
|
+
prefix = path.basename(normalized).toLowerCase();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!searchDir) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const entries = fs.readdirSync(searchDir, { withFileTypes: true });
|
|
236
|
+
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
if (!entry.isDirectory()) continue;
|
|
239
|
+
if (entry.name.startsWith('.')) continue; // Skip hidden
|
|
240
|
+
|
|
241
|
+
if (!prefix || entry.name.toLowerCase().startsWith(prefix)) {
|
|
242
|
+
results.push({
|
|
243
|
+
path: path.join(searchDir, entry.name),
|
|
244
|
+
name: entry.name,
|
|
245
|
+
type: 'filesystem'
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Sort alphabetically
|
|
251
|
+
results.sort((a, b) => a.name.localeCompare(b.name));
|
|
252
|
+
} catch (e) {
|
|
253
|
+
// Ignore errors (permission denied, etc.)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return results.slice(0, MAX_AUTOCOMPLETE_RESULTS);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Fuzzy match projects by query
|
|
261
|
+
* @param {string} query - Search query
|
|
262
|
+
* @returns {Array} Matching projects
|
|
263
|
+
*/
|
|
264
|
+
function fuzzyMatchProjects(query) {
|
|
265
|
+
if (!query) {
|
|
266
|
+
return listProjects({ limit: MAX_AUTOCOMPLETE_RESULTS });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const lowerQuery = query.toLowerCase();
|
|
270
|
+
const matches = [];
|
|
271
|
+
|
|
272
|
+
for (const project of projects.values()) {
|
|
273
|
+
const nameMatch = project.name.toLowerCase().includes(lowerQuery);
|
|
274
|
+
const pathMatch = project.path.toLowerCase().includes(lowerQuery);
|
|
275
|
+
|
|
276
|
+
if (nameMatch || pathMatch) {
|
|
277
|
+
matches.push({
|
|
278
|
+
...project,
|
|
279
|
+
type: 'known',
|
|
280
|
+
score: nameMatch ? 2 : 1
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Sort by score then by lastUsed
|
|
286
|
+
matches.sort((a, b) => {
|
|
287
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
288
|
+
return b.lastUsed - a.lastUsed;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return matches.slice(0, MAX_AUTOCOMPLETE_RESULTS);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get autocomplete suggestions for a query
|
|
296
|
+
* @param {string} query - User query
|
|
297
|
+
* @param {Object} options - Options
|
|
298
|
+
* @param {number} options.limit - Max results (default: 15)
|
|
299
|
+
* @returns {Array} Array of suggestions
|
|
300
|
+
*/
|
|
301
|
+
function autocomplete(query, options = {}) {
|
|
302
|
+
const { limit = MAX_AUTOCOMPLETE_RESULTS } = options;
|
|
303
|
+
|
|
304
|
+
if (!query || !query.trim()) {
|
|
305
|
+
// Return recent projects
|
|
306
|
+
return listProjects({ limit }).map(p => ({
|
|
307
|
+
...p,
|
|
308
|
+
type: 'known'
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const trimmed = query.trim();
|
|
313
|
+
const results = [];
|
|
314
|
+
|
|
315
|
+
// Check if this looks like a path (starts with / or ~)
|
|
316
|
+
const isPathLike = trimmed.startsWith('/') || trimmed.startsWith('~');
|
|
317
|
+
|
|
318
|
+
if (isPathLike) {
|
|
319
|
+
// Get filesystem completions first
|
|
320
|
+
const fsResults = getFilesystemCompletions(trimmed);
|
|
321
|
+
results.push(...fsResults);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Also get fuzzy matches from known projects
|
|
325
|
+
const knownMatches = fuzzyMatchProjects(trimmed);
|
|
326
|
+
for (const match of knownMatches) {
|
|
327
|
+
// Don't duplicate filesystem results
|
|
328
|
+
if (!results.find(r => r.path === match.path)) {
|
|
329
|
+
results.push(match);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// If query is a path-like but no fs results, still try fs completion
|
|
334
|
+
if (!isPathLike && results.length < limit) {
|
|
335
|
+
const fsResults = getFilesystemCompletions(trimmed);
|
|
336
|
+
for (const r of fsResults) {
|
|
337
|
+
if (!results.find(existing => existing.path === r.path)) {
|
|
338
|
+
results.push(r);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return results.slice(0, limit);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get common parent directories from tracked projects
|
|
348
|
+
* @returns {Array} Array of common directories
|
|
349
|
+
*/
|
|
350
|
+
function getCommonDirectories() {
|
|
351
|
+
const dirCounts = new Map();
|
|
352
|
+
|
|
353
|
+
for (const project of projects.values()) {
|
|
354
|
+
// Get parent directories up to 3 levels
|
|
355
|
+
let current = path.dirname(project.path);
|
|
356
|
+
for (let i = 0; i < 3 && current !== path.dirname(current); i++) {
|
|
357
|
+
dirCounts.set(current, (dirCounts.get(current) || 0) + 1);
|
|
358
|
+
current = path.dirname(current);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Return directories with multiple projects
|
|
363
|
+
const common = [];
|
|
364
|
+
for (const [dir, count] of dirCounts) {
|
|
365
|
+
if (count >= 2) {
|
|
366
|
+
common.push({ path: dir, projectCount: count });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
common.sort((a, b) => b.projectCount - a.projectCount);
|
|
371
|
+
return common.slice(0, 10);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Clear all tracked projects
|
|
376
|
+
*/
|
|
377
|
+
function clearProjects() {
|
|
378
|
+
projects.clear();
|
|
379
|
+
saveProjects();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Initialize the manager
|
|
384
|
+
*/
|
|
385
|
+
function init() {
|
|
386
|
+
loadProjects();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Initialize on creation
|
|
390
|
+
init();
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
// Project CRUD
|
|
394
|
+
trackProject,
|
|
395
|
+
getProject,
|
|
396
|
+
listProjects,
|
|
397
|
+
removeProject,
|
|
398
|
+
clearProjects,
|
|
399
|
+
|
|
400
|
+
// Autocomplete
|
|
401
|
+
autocomplete,
|
|
402
|
+
getFilesystemCompletions,
|
|
403
|
+
fuzzyMatchProjects,
|
|
404
|
+
|
|
405
|
+
// Analysis
|
|
406
|
+
getCommonDirectories,
|
|
407
|
+
|
|
408
|
+
// Lifecycle
|
|
409
|
+
init,
|
|
410
|
+
loadProjects,
|
|
411
|
+
saveProjects
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
module.exports = {
|
|
416
|
+
createProjectsManager,
|
|
417
|
+
DEFAULT_DATA_DIR,
|
|
418
|
+
DEFAULT_PROJECTS_FILE,
|
|
419
|
+
MAX_TRACKED_PROJECTS,
|
|
420
|
+
MAX_AUTOCOMPLETE_RESULTS
|
|
421
|
+
};
|