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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Spawner API Routes - HTTP route handlers for session spawning
3
+ *
4
+ * Provides REST API endpoints for:
5
+ * - Spawning new Claude Code sessions
6
+ * - Managing existing sessions
7
+ * - Projects autocomplete and tracking
8
+ */
9
+
10
+ /**
11
+ * Create spawner route handlers
12
+ * @param {Object} deps - Dependencies
13
+ * @param {Object} deps.spawner - Session spawner instance
14
+ * @returns {Object} Route handlers
15
+ */
16
+ function createSpawnerRoutes(deps) {
17
+ const { spawner } = deps;
18
+
19
+ return {
20
+ // =========================================================================
21
+ // Session Spawning
22
+ // =========================================================================
23
+
24
+ /**
25
+ * POST /api/spawner/sessions - Spawn a new session
26
+ */
27
+ handleSpawnSession(req, res) {
28
+ let body = '';
29
+ req.on('data', chunk => body += chunk);
30
+ req.on('end', async () => {
31
+ try {
32
+ const {
33
+ name,
34
+ cwd,
35
+ model,
36
+ skipPermissions,
37
+ continue: continueConversation,
38
+ initialPrompt,
39
+ metadata
40
+ } = JSON.parse(body);
41
+
42
+ const result = await spawner.spawnSession({
43
+ name,
44
+ cwd,
45
+ model,
46
+ skipPermissions,
47
+ continue: continueConversation,
48
+ initialPrompt,
49
+ metadata
50
+ });
51
+
52
+ if (result.error) {
53
+ res.writeHead(400, { 'Content-Type': 'application/json' });
54
+ res.end(JSON.stringify({ ok: false, error: result.error }));
55
+ } else {
56
+ res.writeHead(201, { 'Content-Type': 'application/json' });
57
+ res.end(JSON.stringify({ ok: true, session: result.session }));
58
+ }
59
+ } catch (e) {
60
+ res.writeHead(400, { 'Content-Type': 'application/json' });
61
+ res.end(JSON.stringify({ ok: false, error: e.message }));
62
+ }
63
+ });
64
+ },
65
+
66
+ /**
67
+ * GET /api/spawner/sessions - List all sessions
68
+ */
69
+ handleListSessions(req, res) {
70
+ const sessions = spawner.listSessions();
71
+ res.writeHead(200, { 'Content-Type': 'application/json' });
72
+ res.end(JSON.stringify({ ok: true, sessions }));
73
+ },
74
+
75
+ /**
76
+ * GET /api/spawner/sessions/:id - Get a specific session
77
+ */
78
+ handleGetSession(req, res, sessionId) {
79
+ const session = spawner.getSession(sessionId);
80
+ if (session) {
81
+ res.writeHead(200, { 'Content-Type': 'application/json' });
82
+ res.end(JSON.stringify({ ok: true, session }));
83
+ } else {
84
+ res.writeHead(404, { 'Content-Type': 'application/json' });
85
+ res.end(JSON.stringify({ ok: false, error: 'Session not found' }));
86
+ }
87
+ },
88
+
89
+ /**
90
+ * PATCH /api/spawner/sessions/:id - Update a session
91
+ */
92
+ handleUpdateSession(req, res, sessionId) {
93
+ let body = '';
94
+ req.on('data', chunk => body += chunk);
95
+ req.on('end', () => {
96
+ try {
97
+ const updates = JSON.parse(body);
98
+ const result = spawner.updateSession(sessionId, updates);
99
+
100
+ if (result.error) {
101
+ res.writeHead(404, { 'Content-Type': 'application/json' });
102
+ res.end(JSON.stringify({ ok: false, error: result.error }));
103
+ } else {
104
+ res.writeHead(200, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify({ ok: true, session: result.session }));
106
+ }
107
+ } catch (e) {
108
+ res.writeHead(400, { 'Content-Type': 'application/json' });
109
+ res.end(JSON.stringify({ ok: false, error: e.message }));
110
+ }
111
+ });
112
+ },
113
+
114
+ /**
115
+ * DELETE /api/spawner/sessions/:id - Kill and remove a session
116
+ */
117
+ handleKillSession(req, res, sessionId) {
118
+ spawner.killSession(sessionId).then((result) => {
119
+ if (result.error) {
120
+ res.writeHead(404, { 'Content-Type': 'application/json' });
121
+ res.end(JSON.stringify({ ok: false, error: result.error }));
122
+ } else {
123
+ res.writeHead(200, { 'Content-Type': 'application/json' });
124
+ res.end(JSON.stringify({ ok: true }));
125
+ }
126
+ }).catch((e) => {
127
+ res.writeHead(500, { 'Content-Type': 'application/json' });
128
+ res.end(JSON.stringify({ ok: false, error: e.message }));
129
+ });
130
+ },
131
+
132
+ /**
133
+ * POST /api/spawner/sessions/:id/restart - Restart a session
134
+ */
135
+ handleRestartSession(req, res, sessionId) {
136
+ spawner.restartSession(sessionId).then((result) => {
137
+ if (result.error) {
138
+ res.writeHead(400, { 'Content-Type': 'application/json' });
139
+ res.end(JSON.stringify({ ok: false, error: result.error }));
140
+ } else {
141
+ res.writeHead(200, { 'Content-Type': 'application/json' });
142
+ res.end(JSON.stringify({ ok: true, session: result.session }));
143
+ }
144
+ }).catch((e) => {
145
+ res.writeHead(500, { 'Content-Type': 'application/json' });
146
+ res.end(JSON.stringify({ ok: false, error: e.message }));
147
+ });
148
+ },
149
+
150
+ /**
151
+ * POST /api/spawner/sessions/:id/prompt - Send a prompt to a session
152
+ */
153
+ handleSendPrompt(req, res, sessionId) {
154
+ let body = '';
155
+ req.on('data', chunk => body += chunk);
156
+ req.on('end', async () => {
157
+ try {
158
+ const { prompt } = JSON.parse(body);
159
+ if (!prompt) {
160
+ res.writeHead(400, { 'Content-Type': 'application/json' });
161
+ res.end(JSON.stringify({ ok: false, error: 'Prompt is required' }));
162
+ return;
163
+ }
164
+
165
+ const result = await spawner.sendPrompt(sessionId, prompt);
166
+ if (result.error) {
167
+ res.writeHead(400, { 'Content-Type': 'application/json' });
168
+ res.end(JSON.stringify({ ok: false, error: result.error }));
169
+ } else {
170
+ res.writeHead(200, { 'Content-Type': 'application/json' });
171
+ res.end(JSON.stringify({ ok: true }));
172
+ }
173
+ } catch (e) {
174
+ res.writeHead(400, { 'Content-Type': 'application/json' });
175
+ res.end(JSON.stringify({ ok: false, error: e.message }));
176
+ }
177
+ });
178
+ },
179
+
180
+ /**
181
+ * POST /api/spawner/sessions/:id/cancel - Cancel current operation
182
+ */
183
+ handleCancelSession(req, res, sessionId) {
184
+ spawner.cancelSession(sessionId).then((result) => {
185
+ if (result.error) {
186
+ res.writeHead(400, { 'Content-Type': 'application/json' });
187
+ res.end(JSON.stringify({ ok: false, error: result.error }));
188
+ } else {
189
+ res.writeHead(200, { 'Content-Type': 'application/json' });
190
+ res.end(JSON.stringify({ ok: true }));
191
+ }
192
+ }).catch((e) => {
193
+ res.writeHead(500, { 'Content-Type': 'application/json' });
194
+ res.end(JSON.stringify({ ok: false, error: e.message }));
195
+ });
196
+ },
197
+
198
+ /**
199
+ * POST /api/spawner/sessions/:id/permission - Respond to permission prompt
200
+ */
201
+ handlePermissionResponse(req, res, sessionId) {
202
+ let body = '';
203
+ req.on('data', chunk => body += chunk);
204
+ req.on('end', async () => {
205
+ try {
206
+ const { response } = JSON.parse(body);
207
+ if (!response) {
208
+ res.writeHead(400, { 'Content-Type': 'application/json' });
209
+ res.end(JSON.stringify({ ok: false, error: 'Response is required' }));
210
+ return;
211
+ }
212
+
213
+ const result = await spawner.respondToPermission(sessionId, response);
214
+ if (result.error) {
215
+ res.writeHead(400, { 'Content-Type': 'application/json' });
216
+ res.end(JSON.stringify({ ok: false, error: result.error }));
217
+ } else {
218
+ res.writeHead(200, { 'Content-Type': 'application/json' });
219
+ res.end(JSON.stringify({ ok: true }));
220
+ }
221
+ } catch (e) {
222
+ res.writeHead(400, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify({ ok: false, error: e.message }));
224
+ }
225
+ });
226
+ },
227
+
228
+ // =========================================================================
229
+ // Projects
230
+ // =========================================================================
231
+
232
+ /**
233
+ * GET /api/spawner/projects - List tracked projects
234
+ */
235
+ handleListProjects(req, res, url) {
236
+ const limit = parseInt(url.searchParams.get('limit') || '50');
237
+ const sortBy = url.searchParams.get('sortBy') || 'lastUsed';
238
+
239
+ const projects = spawner.projectsManager.listProjects({ limit, sortBy });
240
+ res.writeHead(200, { 'Content-Type': 'application/json' });
241
+ res.end(JSON.stringify({ ok: true, projects }));
242
+ },
243
+
244
+ /**
245
+ * GET /api/spawner/projects/autocomplete - Get autocomplete suggestions
246
+ */
247
+ handleAutocomplete(req, res, url) {
248
+ const query = url.searchParams.get('q') || '';
249
+ const limit = parseInt(url.searchParams.get('limit') || '15');
250
+
251
+ const suggestions = spawner.projectsManager.autocomplete(query, { limit });
252
+ res.writeHead(200, { 'Content-Type': 'application/json' });
253
+ res.end(JSON.stringify({ ok: true, suggestions }));
254
+ },
255
+
256
+ /**
257
+ * POST /api/spawner/projects - Track a new project
258
+ */
259
+ handleTrackProject(req, res) {
260
+ let body = '';
261
+ req.on('data', chunk => body += chunk);
262
+ req.on('end', () => {
263
+ try {
264
+ const { path, name } = JSON.parse(body);
265
+ if (!path) {
266
+ res.writeHead(400, { 'Content-Type': 'application/json' });
267
+ res.end(JSON.stringify({ ok: false, error: 'Path is required' }));
268
+ return;
269
+ }
270
+
271
+ const result = spawner.projectsManager.trackProject(path, name);
272
+ if (result.error) {
273
+ res.writeHead(400, { 'Content-Type': 'application/json' });
274
+ res.end(JSON.stringify({ ok: false, error: result.error }));
275
+ } else {
276
+ res.writeHead(201, { 'Content-Type': 'application/json' });
277
+ res.end(JSON.stringify({ ok: true, project: result.project }));
278
+ }
279
+ } catch (e) {
280
+ res.writeHead(400, { 'Content-Type': 'application/json' });
281
+ res.end(JSON.stringify({ ok: false, error: e.message }));
282
+ }
283
+ });
284
+ },
285
+
286
+ /**
287
+ * DELETE /api/spawner/projects - Remove a tracked project
288
+ */
289
+ handleRemoveProject(req, res) {
290
+ let body = '';
291
+ req.on('data', chunk => body += chunk);
292
+ req.on('end', () => {
293
+ try {
294
+ const { path } = JSON.parse(body);
295
+ if (!path) {
296
+ res.writeHead(400, { 'Content-Type': 'application/json' });
297
+ res.end(JSON.stringify({ ok: false, error: 'Path is required' }));
298
+ return;
299
+ }
300
+
301
+ const result = spawner.projectsManager.removeProject(path);
302
+ res.writeHead(200, { 'Content-Type': 'application/json' });
303
+ res.end(JSON.stringify({ ok: true, removed: result.success }));
304
+ } catch (e) {
305
+ res.writeHead(400, { 'Content-Type': 'application/json' });
306
+ res.end(JSON.stringify({ ok: false, error: e.message }));
307
+ }
308
+ });
309
+ },
310
+
311
+ /**
312
+ * GET /api/spawner/projects/common - Get common directories
313
+ */
314
+ handleGetCommonDirectories(req, res) {
315
+ const directories = spawner.projectsManager.getCommonDirectories();
316
+ res.writeHead(200, { 'Content-Type': 'application/json' });
317
+ res.end(JSON.stringify({ ok: true, directories }));
318
+ },
319
+
320
+ // =========================================================================
321
+ // Health
322
+ // =========================================================================
323
+
324
+ /**
325
+ * POST /api/spawner/refresh - Force health check
326
+ */
327
+ handleRefresh(req, res) {
328
+ spawner.checkHealth();
329
+ res.writeHead(200, { 'Content-Type': 'application/json' });
330
+ res.end(JSON.stringify({ ok: true, sessions: spawner.listSessions() }));
331
+ }
332
+ };
333
+ }
334
+
335
+ module.exports = { createSpawnerRoutes };
@@ -290,20 +290,24 @@ function linkClaudeSession(claudeSessionId, managedSessionId) {
290
290
  }
291
291
  }
292
292
 
293
- function discoverSession(event) {
293
+ function discoverSession(event, options = {}) {
294
294
  const id = crypto.randomUUID();
295
295
  const projectName = event.cwd ? path.basename(event.cwd) : 'Unknown Project';
296
296
 
297
+ // During initialization, set status to OFFLINE since we don't know if session is active
298
+ // During real-time discovery, set to WORKING since we're receiving live events
299
+ const initialStatus = options.isInitialization ? SESSION_STATUS.OFFLINE : SESSION_STATUS.WORKING;
300
+
297
301
  const session = {
298
302
  id,
299
303
  name: projectName,
300
304
  tmuxSession: null,
301
- status: SESSION_STATUS.WORKING,
305
+ status: initialStatus,
302
306
  claudeSessionId: event.sessionId,
303
307
  createdAt: Date.now(),
304
308
  lastActivity: Date.now(),
305
309
  cwd: event.cwd || null,
306
- currentTool: event.tool || null,
310
+ currentTool: options.isInitialization ? null : (event.tool || null),
307
311
  discovered: true
308
312
  };
309
313
 
@@ -318,22 +322,36 @@ function discoverSession(event) {
318
322
  return session;
319
323
  }
320
324
 
321
- function updateSessionFromEvent(event) {
325
+ function updateSessionFromEvent(event, options = {}) {
322
326
  if (!event.sessionId) return;
323
327
 
324
- const hiddenIds = loadHiddenSessions();
325
- if (hiddenIds.includes(event.sessionId)) {
326
- console.log(` Auto-unhiding session ${event.sessionId.substring(0, 8)}... (received activity)`);
327
- unhideSession(event.sessionId);
328
+ // Skip auto-unhide during initialization (loading historical events)
329
+ // Only auto-unhide on new real-time events
330
+ if (!options.skipAutoUnhide) {
331
+ const hiddenIds = loadHiddenSessions();
332
+ if (hiddenIds.includes(event.sessionId)) {
333
+ console.log(` Auto-unhiding session ${event.sessionId.substring(0, 8)}... (received activity)`);
334
+ unhideSession(event.sessionId);
335
+ }
328
336
  }
329
337
 
330
338
  let managedSession = findManagedSessionByClaudeId(event.sessionId);
331
339
 
332
340
  if (!managedSession) {
333
- managedSession = discoverSession(event);
341
+ managedSession = discoverSession(event, { isInitialization: options.skipAutoUnhide });
334
342
  }
335
343
 
336
344
  const prevStatus = managedSession.status;
345
+
346
+ // During initialization, only update metadata (cwd), not status
347
+ // Status updates from historical events would show stale "working" state
348
+ if (options.skipAutoUnhide) {
349
+ // This is initialization - only update cwd if provided
350
+ if (event.cwd) managedSession.cwd = event.cwd;
351
+ return;
352
+ }
353
+
354
+ // Real-time event - update everything
337
355
  managedSession.lastActivity = Date.now();
338
356
  if (event.cwd) managedSession.cwd = event.cwd;
339
357
 
@@ -466,6 +484,15 @@ function updateManagedSession(id, updates) {
466
484
 
467
485
  if (updates.name) {
468
486
  session.name = updates.name;
487
+
488
+ // Also update customNames to ensure the new name takes effect
489
+ // (customNames overrides session.name in getManagedSessions)
490
+ const customNames = loadCustomNames();
491
+ customNames[id] = updates.name;
492
+ if (session.claudeSessionId) {
493
+ customNames[session.claudeSessionId] = updates.name;
494
+ }
495
+ saveCustomNames(customNames);
469
496
  }
470
497
 
471
498
  broadcastManagedSessions();
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Spawner Module - Session spawning and management for Claude Code
3
+ *
4
+ * This module provides the ability to spawn and manage Claude Code sessions
5
+ * from the dashboard without needing to open a terminal.
6
+ *
7
+ * Main exports:
8
+ * - createSessionSpawner: Create a session spawner instance
9
+ * - createProjectsManager: Create a projects manager for directory tracking
10
+ * - Path validation utilities
11
+ */
12
+
13
+ const { createSessionSpawner, SESSION_STATUS, CLAUDE_MODELS, DEFAULT_CONFIG } = require('./session-spawner');
14
+ const { createProjectsManager, MAX_TRACKED_PROJECTS, MAX_AUTOCOMPLETE_RESULTS } = require('./projects-manager');
15
+ const {
16
+ validateDirectoryPath,
17
+ validateFilePath,
18
+ validateTmuxSessionName,
19
+ isValidTmuxSessionName,
20
+ containsShellMetacharacters,
21
+ isPathTraversalAttempt,
22
+ isPathWithin,
23
+ normalizePath,
24
+ sanitizeForFilename,
25
+ MAX_PATH_LENGTH
26
+ } = require('./path-validator');
27
+
28
+ module.exports = {
29
+ // Main factory functions
30
+ createSessionSpawner,
31
+ createProjectsManager,
32
+
33
+ // Path validation
34
+ validateDirectoryPath,
35
+ validateFilePath,
36
+ validateTmuxSessionName,
37
+ isValidTmuxSessionName,
38
+ containsShellMetacharacters,
39
+ isPathTraversalAttempt,
40
+ isPathWithin,
41
+ normalizePath,
42
+ sanitizeForFilename,
43
+
44
+ // Constants
45
+ SESSION_STATUS,
46
+ CLAUDE_MODELS,
47
+ DEFAULT_CONFIG,
48
+ MAX_PATH_LENGTH,
49
+ MAX_TRACKED_PROJECTS,
50
+ MAX_AUTOCOMPLETE_RESULTS
51
+ };