clearctx 3.0.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/src/manager.js ADDED
@@ -0,0 +1,510 @@
1
+ /**
2
+ * SessionManager — Multi-session orchestrator.
3
+ *
4
+ * Manages multiple StreamSession instances. Provides a high-level API for:
5
+ * - Spawning new sessions (stream or resume mode)
6
+ * - Sending messages to sessions
7
+ * - Pausing, resuming, forking, killing sessions
8
+ * - Tracking costs and interaction history
9
+ * - Persisting session state for restart recovery
10
+ *
11
+ * Usage (programmatic):
12
+ * const { SessionManager } = require('clearctx');
13
+ * const mgr = new SessionManager();
14
+ *
15
+ * // Spawn and interact
16
+ * const session = await mgr.spawn('fix-auth', { prompt: 'Fix the auth bug', model: 'sonnet' });
17
+ * const r1 = await mgr.send('fix-auth', 'Also add input validation');
18
+ * const r2 = await mgr.send('fix-auth', 'Now write tests');
19
+ *
20
+ * // Parallel tasks
21
+ * await Promise.all([
22
+ * mgr.spawn('task-a', { prompt: 'Build login page' }),
23
+ * mgr.spawn('task-b', { prompt: 'Build signup page' }),
24
+ * ]);
25
+ *
26
+ * // Check status
27
+ * console.log(mgr.list());
28
+ * mgr.stopAll();
29
+ */
30
+
31
+ const StreamSession = require('./stream-session');
32
+ const Store = require('./store');
33
+ const { spawn: spawnProcess, execSync } = require('child_process');
34
+
35
+ class SessionManager {
36
+ /**
37
+ * @param {object} [options]
38
+ * @param {string} [options.dataDir] - Override store data directory
39
+ * @param {string} [options.defaultModel='sonnet'] - Default model for new sessions
40
+ * @param {string} [options.defaultPermissionMode='default'] - Default permission mode
41
+ * @param {number} [options.defaultTimeout=600000] - Default response timeout
42
+ */
43
+ constructor(options = {}) {
44
+ this.store = new Store(options.dataDir);
45
+ this.defaultModel = options.defaultModel || 'sonnet';
46
+ this.defaultPermissionMode = options.defaultPermissionMode || 'default';
47
+ this.defaultTimeout = options.defaultTimeout || 600000;
48
+
49
+ // Live sessions (in-memory StreamSession instances)
50
+ this.sessions = new Map();
51
+ }
52
+
53
+ /**
54
+ * Spawn a new session and optionally send the first message.
55
+ *
56
+ * @param {string} name - Unique session name
57
+ * @param {object} options - Session config
58
+ * @param {string} [options.prompt] - Initial prompt (if provided, sends immediately)
59
+ * @param {string} [options.model] - Model to use
60
+ * @param {string} [options.workDir] - Working directory
61
+ * @param {string} [options.permissionMode] - Permission mode
62
+ * @param {string[]} [options.allowedTools] - Restrict tools
63
+ * @param {string} [options.systemPrompt] - System prompt append
64
+ * @param {number} [options.maxBudget] - Max budget USD
65
+ * @param {string} [options.agent] - Agent to use
66
+ * @param {number} [options.timeout] - Response timeout
67
+ * @param {string[]} [options.tags] - Tags for organizing
68
+ * @returns {Promise<object>} { session, response? } — response is included if prompt was given
69
+ */
70
+ async spawn(name, options = {}) {
71
+ // Check for name collision
72
+ if (this.sessions.has(name)) {
73
+ throw new Error(`Session "${name}" already exists and is alive. Kill it first or use a different name.`);
74
+ }
75
+
76
+ const existingRecord = this.store.get(name);
77
+ if (existingRecord && !['stopped', 'killed', 'failed'].includes(existingRecord.status)) {
78
+ throw new Error(`Session "${name}" exists in store with status "${existingRecord.status}". Kill or delete it first.`);
79
+ }
80
+
81
+ // Create StreamSession
82
+ const session = new StreamSession({
83
+ name,
84
+ model: options.model || this.defaultModel,
85
+ workDir: options.workDir || process.cwd(),
86
+ permissionMode: options.permissionMode || this.defaultPermissionMode,
87
+ allowedTools: options.allowedTools || [],
88
+ systemPrompt: options.systemPrompt || '',
89
+ maxBudget: options.maxBudget || null,
90
+ agent: options.agent || null,
91
+ timeout: options.timeout || this.defaultTimeout,
92
+ });
93
+
94
+ // Wire up events for persistence
95
+ this._wireEvents(session);
96
+
97
+ // Start the process (just spawns it, no waiting)
98
+ session.start();
99
+
100
+ // Store in memory
101
+ this.sessions.set(name, session);
102
+
103
+ // Persist to store
104
+ this._persist(name);
105
+
106
+ // If a prompt was given, send it right away
107
+ // The first send() triggers init + gets the response
108
+ let response = null;
109
+ if (options.prompt) {
110
+ response = await session.send(options.prompt);
111
+ this._persist(name);
112
+ }
113
+
114
+ return { session: session.toJSON(), initData: session.initData, response };
115
+ }
116
+
117
+ /**
118
+ * Send a message to an existing session.
119
+ *
120
+ * @param {string} name - Session name
121
+ * @param {string} message - The message to send
122
+ * @param {number} [timeout] - Override timeout
123
+ * @returns {Promise<object>} Response { text, cost, turns, duration, sessionId }
124
+ */
125
+ async send(name, message, timeout) {
126
+ const session = this._getSession(name);
127
+
128
+ // If session is paused, auto-resume
129
+ if (session.status === 'paused') {
130
+ session.resume();
131
+ }
132
+
133
+ const response = await session.send(message, timeout);
134
+ this._persist(name);
135
+ return response;
136
+ }
137
+
138
+ /**
139
+ * Pause a session (stops accepting messages but keeps process alive).
140
+ */
141
+ pause(name) {
142
+ const session = this._getSession(name);
143
+ session.pause();
144
+ this._persist(name);
145
+ }
146
+
147
+ /**
148
+ * Resume a paused session (can optionally send a message).
149
+ *
150
+ * @param {string} name - Session name
151
+ * @param {string} [message] - Optional message to send after resuming
152
+ * @returns {Promise<object|null>} Response if message was sent
153
+ */
154
+ async resume(name, message) {
155
+ // First check if session is alive in memory
156
+ if (this.sessions.has(name)) {
157
+ const session = this.sessions.get(name);
158
+ if (session.status === 'paused') {
159
+ session.resume();
160
+ }
161
+ if (message) {
162
+ const response = await session.send(message);
163
+ this._persist(name);
164
+ return response;
165
+ }
166
+ this._persist(name);
167
+ return null;
168
+ }
169
+
170
+ // Session not alive — try to restore from store using --resume
171
+ const record = this.store.get(name);
172
+ if (!record) {
173
+ throw new Error(`Session "${name}" not found.`);
174
+ }
175
+ if (!record.claudeSessionId) {
176
+ throw new Error(`Session "${name}" has no session ID. Cannot restore.`);
177
+ }
178
+
179
+ // Spawn a new StreamSession that resumes the old one
180
+ const session = new StreamSession({
181
+ name,
182
+ model: record.model || this.defaultModel,
183
+ workDir: record.workDir || process.cwd(),
184
+ permissionMode: record.permissionMode || this.defaultPermissionMode,
185
+ allowedTools: record.allowedTools || [],
186
+ systemPrompt: record.systemPrompt || '',
187
+ maxBudget: record.maxBudget || null,
188
+ agent: record.agent || null,
189
+ sessionId: record.claudeSessionId, // Resume existing conversation
190
+ timeout: record.timeout || this.defaultTimeout,
191
+ });
192
+
193
+ this._wireEvents(session);
194
+ session.start(); // Just spawns process, no await needed
195
+ this.sessions.set(name, session);
196
+
197
+ // Merge interaction history from store
198
+ session.interactions = record.interactions || [];
199
+ session.totalCostUsd = record.totalCostUsd || 0;
200
+ session.totalTurns = record.totalTurns || 0;
201
+
202
+ if (message) {
203
+ const response = await session.send(message);
204
+ this._persist(name);
205
+ return response;
206
+ }
207
+
208
+ this._persist(name);
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * Fork a session — creates a new session with the parent's context.
214
+ *
215
+ * @param {string} sourceName - Source session name
216
+ * @param {string} newName - New session name
217
+ * @param {object} [options] - Override options for the fork
218
+ * @param {string} [options.message] - Initial message for the fork
219
+ * @returns {Promise<object>} { session, response? }
220
+ */
221
+ async fork(sourceName, newName, options = {}) {
222
+ // Get the source session ID (from live session or store)
223
+ let sourceSessionId;
224
+ let sourceRecord;
225
+
226
+ if (this.sessions.has(sourceName)) {
227
+ const source = this.sessions.get(sourceName);
228
+ sourceSessionId = source.id;
229
+ sourceRecord = source.toJSON();
230
+ } else {
231
+ sourceRecord = this.store.get(sourceName);
232
+ if (!sourceRecord) {
233
+ throw new Error(`Source session "${sourceName}" not found.`);
234
+ }
235
+ sourceSessionId = sourceRecord.claudeSessionId;
236
+ }
237
+
238
+ if (!sourceSessionId) {
239
+ throw new Error(`Source session "${sourceName}" has no session ID.`);
240
+ }
241
+
242
+ // Use the resume-mode approach for fork (--resume + --fork-session)
243
+ // because stream mode doesn't support --fork-session natively
244
+ const model = options.model || sourceRecord.model || this.defaultModel;
245
+ const workDir = options.workDir || sourceRecord.workDir || process.cwd();
246
+ const message = options.message || 'Continue from the forked conversation.';
247
+
248
+ // Use single-shot resume with fork
249
+ const args = [
250
+ '-p',
251
+ '--output-format', 'json',
252
+ '--model', model,
253
+ '--resume', sourceSessionId,
254
+ '--fork-session',
255
+ ];
256
+
257
+ if (sourceRecord.permissionMode && sourceRecord.permissionMode !== 'default') {
258
+ args.push('--permission-mode', sourceRecord.permissionMode);
259
+ }
260
+
261
+ args.push(message);
262
+
263
+ try {
264
+ const result = execSync(
265
+ `claude ${args.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ')}`,
266
+ { cwd: workDir, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, timeout: 600000, windowsHide: true }
267
+ );
268
+
269
+ const parsed = JSON.parse(result);
270
+
271
+ // Now spawn a new stream session that resumes the forked ID
272
+ const forked = new StreamSession({
273
+ name: newName,
274
+ model,
275
+ workDir,
276
+ permissionMode: sourceRecord.permissionMode || this.defaultPermissionMode,
277
+ sessionId: parsed.session_id, // The forked session's ID
278
+ });
279
+
280
+ this._wireEvents(forked);
281
+ forked.start(); // Just spawns process
282
+ this.sessions.set(newName, forked);
283
+
284
+ // Record the fork's first interaction
285
+ forked.interactions.push({
286
+ prompt: message,
287
+ response: parsed.result || '',
288
+ cost: parsed.cost_usd || 0,
289
+ turns: parsed.num_turns || 0,
290
+ duration: parsed.duration_ms || 0,
291
+ timestamp: new Date().toISOString(),
292
+ type: 'fork',
293
+ });
294
+ forked.totalCostUsd = parsed.cost_usd || 0;
295
+ forked.totalTurns = parsed.num_turns || 0;
296
+
297
+ this._persist(newName);
298
+
299
+ return {
300
+ session: forked.toJSON(),
301
+ response: {
302
+ text: parsed.result || '',
303
+ cost: parsed.cost_usd || 0,
304
+ turns: parsed.num_turns || 0,
305
+ duration: parsed.duration_ms || 0,
306
+ sessionId: parsed.session_id,
307
+ },
308
+ };
309
+
310
+ } catch (err) {
311
+ throw new Error(`Fork failed: ${err.message}`);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Stop a session gracefully (closes stdin, process exits).
317
+ */
318
+ stop(name) {
319
+ const session = this._getSession(name);
320
+ session.stop();
321
+ this._persist(name);
322
+ this.sessions.delete(name);
323
+ }
324
+
325
+ /**
326
+ * Force kill a session.
327
+ */
328
+ kill(name) {
329
+ if (this.sessions.has(name)) {
330
+ const session = this.sessions.get(name);
331
+ session.kill();
332
+ this.sessions.delete(name);
333
+ }
334
+
335
+ // Update store
336
+ const record = this.store.get(name);
337
+ if (record) {
338
+ record.status = 'killed';
339
+ this.store.set(name, record);
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Stop all live sessions.
345
+ */
346
+ stopAll() {
347
+ for (const [name, session] of this.sessions) {
348
+ session.stop();
349
+ this._persist(name);
350
+ }
351
+ this.sessions.clear();
352
+ }
353
+
354
+ /**
355
+ * Get status of a session.
356
+ */
357
+ status(name) {
358
+ // Check live session first
359
+ if (this.sessions.has(name)) {
360
+ return this.sessions.get(name).toJSON();
361
+ }
362
+ // Fall back to store
363
+ const record = this.store.get(name);
364
+ if (!record) {
365
+ throw new Error(`Session "${name}" not found.`);
366
+ }
367
+ return record;
368
+ }
369
+
370
+ /**
371
+ * List all sessions (live + stored).
372
+ * @param {string} [statusFilter]
373
+ * @returns {object[]}
374
+ */
375
+ list(statusFilter) {
376
+ const result = new Map();
377
+
378
+ // Load from store first
379
+ const stored = this.store.list();
380
+ for (const record of stored) {
381
+ result.set(record.name, record);
382
+ }
383
+
384
+ // Override with live session data
385
+ for (const [name, session] of this.sessions) {
386
+ result.set(name, session.toJSON());
387
+ }
388
+
389
+ let list = Array.from(result.values());
390
+ if (statusFilter) {
391
+ list = list.filter(s => s.status === statusFilter);
392
+ }
393
+
394
+ return list;
395
+ }
396
+
397
+ /**
398
+ * Get interaction history for a session.
399
+ */
400
+ history(name) {
401
+ if (this.sessions.has(name)) {
402
+ return this.sessions.get(name).interactions;
403
+ }
404
+ const record = this.store.get(name);
405
+ if (!record) {
406
+ throw new Error(`Session "${name}" not found.`);
407
+ }
408
+ return record.interactions || [];
409
+ }
410
+
411
+ /**
412
+ * Get the last response from a session.
413
+ */
414
+ lastOutput(name) {
415
+ const hist = this.history(name);
416
+ if (hist.length === 0) return null;
417
+ return hist[hist.length - 1];
418
+ }
419
+
420
+ /**
421
+ * Delete a session from store permanently.
422
+ */
423
+ delete(name) {
424
+ if (this.sessions.has(name)) {
425
+ this.sessions.get(name).kill();
426
+ this.sessions.delete(name);
427
+ }
428
+ this.store.delete(name);
429
+ }
430
+
431
+ /**
432
+ * Clean up old sessions.
433
+ */
434
+ cleanup(days = 7) {
435
+ return this.store.cleanup(days);
436
+ }
437
+
438
+ /**
439
+ * Batch spawn — start multiple sessions from a spec array.
440
+ *
441
+ * @param {object[]} specs - Array of { name, prompt, model?, ... }
442
+ * @returns {Promise<object[]>} Results for each spawn
443
+ */
444
+ async batch(specs) {
445
+ // Spawn all in parallel
446
+ const promises = specs.map(spec =>
447
+ this.spawn(spec.name, spec).catch(err => ({
448
+ session: { name: spec.name, status: 'failed' },
449
+ error: err.message,
450
+ }))
451
+ );
452
+ return Promise.all(promises);
453
+ }
454
+
455
+ // ===========================================================================
456
+ // Private
457
+ // ===========================================================================
458
+
459
+ /**
460
+ * Get a live session or throw.
461
+ */
462
+ _getSession(name) {
463
+ const session = this.sessions.get(name);
464
+ if (!session) {
465
+ throw new Error(`Session "${name}" is not alive. Use resume() to restore it.`);
466
+ }
467
+ return session;
468
+ }
469
+
470
+ /**
471
+ * Wire session events for logging/persistence.
472
+ */
473
+ _wireEvents(session) {
474
+ session.on('error', (err) => {
475
+ this._persist(session.name);
476
+ });
477
+
478
+ session.on('close', (code) => {
479
+ this._persist(session.name);
480
+ this.sessions.delete(session.name);
481
+ });
482
+ }
483
+
484
+ /**
485
+ * Persist a session's current state to store.
486
+ */
487
+ _persist(name) {
488
+ const session = this.sessions.get(name);
489
+ if (!session) return;
490
+
491
+ this.store.set(name, {
492
+ name: session.name,
493
+ claudeSessionId: session.id,
494
+ status: session.status,
495
+ model: session.model,
496
+ workDir: session.workDir,
497
+ permissionMode: session.permissionMode,
498
+ allowedTools: session.allowedTools,
499
+ systemPrompt: session.systemPrompt,
500
+ maxBudget: session.maxBudget,
501
+ agent: session.agent,
502
+ totalCostUsd: session.totalCostUsd,
503
+ totalTurns: session.totalTurns,
504
+ interactions: session.interactions,
505
+ created: session.interactions[0]?.timestamp || new Date().toISOString(),
506
+ });
507
+ }
508
+ }
509
+
510
+ module.exports = SessionManager;