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/store.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Store — JSON file persistence for session metadata.
3
+ *
4
+ * Sessions are kept alive in-memory (as StreamSession objects) by the Manager,
5
+ * but their metadata is persisted here so sessions can survive process restarts.
6
+ *
7
+ * The store saves:
8
+ * - Session config (name, model, workDir, etc.)
9
+ * - Claude session ID (for resuming after restart)
10
+ * - Interaction history (prompts + responses)
11
+ * - Cost tracking
12
+ *
13
+ * Data location: ~/.clearctx/ (user home directory)
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const { atomicWriteJson } = require('./atomic-io');
20
+
21
+ // Default data directory in user's home
22
+ const DEFAULT_DATA_DIR = path.join(os.homedir(), '.clearctx');
23
+
24
+ class Store {
25
+ /**
26
+ * @param {string} [dataDir] - Override data directory path
27
+ */
28
+ constructor(dataDir) {
29
+ this.dataDir = dataDir || DEFAULT_DATA_DIR;
30
+ this.sessionsFile = path.join(this.dataDir, 'sessions.json');
31
+
32
+ // Ensure directory exists
33
+ if (!fs.existsSync(this.dataDir)) {
34
+ fs.mkdirSync(this.dataDir, { recursive: true });
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Load all session records from disk.
40
+ * @returns {Object<string, object>} Map of name -> session data
41
+ */
42
+ loadAll() {
43
+ if (!fs.existsSync(this.sessionsFile)) {
44
+ return {};
45
+ }
46
+ try {
47
+ return JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8'));
48
+ } catch (err) {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Save all session records to disk.
55
+ * @param {Object<string, object>} sessions
56
+ */
57
+ saveAll(sessions) {
58
+ atomicWriteJson(this.sessionsFile, sessions);
59
+ }
60
+
61
+ /**
62
+ * Get a single session record.
63
+ * @param {string} name
64
+ * @returns {object|null}
65
+ */
66
+ get(name) {
67
+ const all = this.loadAll();
68
+ return all[name] || null;
69
+ }
70
+
71
+ /**
72
+ * Save/update a single session record.
73
+ * @param {string} name
74
+ * @param {object} data
75
+ */
76
+ set(name, data) {
77
+ const all = this.loadAll();
78
+ all[name] = { ...data, lastSaved: new Date().toISOString() };
79
+ this.saveAll(all);
80
+ }
81
+
82
+ /**
83
+ * Delete a session record.
84
+ * @param {string} name
85
+ */
86
+ delete(name) {
87
+ const all = this.loadAll();
88
+ delete all[name];
89
+ this.saveAll(all);
90
+ }
91
+
92
+ /**
93
+ * List all session records.
94
+ * @param {string} [statusFilter] - Optional status filter
95
+ * @returns {object[]}
96
+ */
97
+ list(statusFilter) {
98
+ const all = this.loadAll();
99
+ let list = Object.values(all);
100
+ if (statusFilter) {
101
+ list = list.filter(s => s.status === statusFilter);
102
+ }
103
+ return list;
104
+ }
105
+
106
+ /**
107
+ * Clean up old sessions (completed/failed/killed older than N days).
108
+ * @param {number} days
109
+ * @returns {string[]} Names of removed sessions
110
+ */
111
+ cleanup(days = 7) {
112
+ const all = this.loadAll();
113
+ const cutoff = new Date();
114
+ cutoff.setDate(cutoff.getDate() - days);
115
+ const cutoffISO = cutoff.toISOString();
116
+ const removed = [];
117
+
118
+ for (const [name, session] of Object.entries(all)) {
119
+ const lastActive = session.lastSaved || session.created;
120
+ if (lastActive < cutoffISO && ['completed', 'failed', 'killed', 'stopped'].includes(session.status)) {
121
+ removed.push(name);
122
+ delete all[name];
123
+ }
124
+ }
125
+
126
+ this.saveAll(all);
127
+ return removed;
128
+ }
129
+ }
130
+
131
+ module.exports = Store;
@@ -0,0 +1,463 @@
1
+ /**
2
+ * StreamSession — A single long-lived Claude Code session with streaming I/O.
3
+ *
4
+ * This is the CORE of the system. Instead of spawning a new process for each
5
+ * message (like the resume approach), StreamSession keeps ONE claude process
6
+ * alive and pipes messages in/out through stream-json protocol.
7
+ *
8
+ * IMPORTANT DISCOVERY: Claude CLI with stream-json mode buffers all output
9
+ * until the first user message is sent. The "init" event only arrives AFTER
10
+ * you send the first message. So start() just spawns the process, and the
11
+ * first send() handles both init and response.
12
+ *
13
+ * Protocol (NDJSON — newline-delimited JSON):
14
+ *
15
+ * INPUT (stdin):
16
+ * { "type": "user", "message": { "role": "user", "content": "your message" } }
17
+ *
18
+ * OUTPUT (stdout) — arrives after each user message:
19
+ * { "type": "system", "subtype": "init", "session_id": "...", "tools": [...] }
20
+ * { "type": "assistant", "message": { "content": [{ "type": "text", "text": "..." }] } }
21
+ * { "type": "result", "subtype": "success", "result": "full text", "session_id": "..." }
22
+ *
23
+ * Usage:
24
+ * const session = new StreamSession({ name: 'my-task', model: 'sonnet' });
25
+ * session.start(); // Just spawns process, returns immediately
26
+ * const r1 = await session.send('Fix the auth bug'); // First message triggers init too
27
+ * const r2 = await session.send('Now add tests'); // Instant follow-up
28
+ * session.stop();
29
+ */
30
+
31
+ const { spawn } = require('child_process');
32
+ const { EventEmitter } = require('events');
33
+
34
+ // How many milliseconds to wait for a response before timing out
35
+ const DEFAULT_TIMEOUT = 600000; // 10 minutes
36
+
37
+ class StreamSession extends EventEmitter {
38
+ /**
39
+ * Create a new StreamSession.
40
+ *
41
+ * @param {object} options - Session configuration
42
+ * @param {string} options.name - Human-readable session name
43
+ * @param {string} [options.model='sonnet'] - Model to use
44
+ * @param {string} [options.workDir] - Working directory for claude
45
+ * @param {string} [options.permissionMode='default'] - Permission mode
46
+ * @param {string[]} [options.allowedTools] - Restrict available tools
47
+ * @param {string} [options.systemPrompt] - Append to system prompt
48
+ * @param {number} [options.maxBudget] - Max budget in USD
49
+ * @param {string} [options.agent] - Agent to use
50
+ * @param {string} [options.sessionId] - Resume existing session
51
+ * @param {number} [options.timeout] - Response timeout in ms
52
+ */
53
+ constructor(options = {}) {
54
+ super();
55
+
56
+ this.name = options.name || `session-${Date.now()}`;
57
+ this.model = options.model || 'sonnet';
58
+ this.workDir = options.workDir || process.cwd();
59
+ this.permissionMode = options.permissionMode || 'default';
60
+ this.allowedTools = options.allowedTools || [];
61
+ this.systemPrompt = options.systemPrompt || '';
62
+ this.maxBudget = options.maxBudget || null;
63
+ this.agent = options.agent || null;
64
+ this.timeout = options.timeout || DEFAULT_TIMEOUT;
65
+
66
+ // Session state
67
+ this.id = options.sessionId || null; // Claude's session UUID (set after init)
68
+ this.process = null; // The child process
69
+ this.status = 'created'; // created, ready, busy, paused, stopped, error
70
+ this.interactions = []; // History of all send/receive pairs
71
+ this.totalCostUsd = 0; // Running cost total
72
+ this.totalTurns = 0; // Running turn total
73
+ this.initData = null; // Data from init event
74
+
75
+ // Internal streaming state
76
+ this._buffer = ''; // Incomplete line buffer
77
+ this._pendingResolve = null; // Resolve function for current send()
78
+ this._pendingReject = null; // Reject function for current send()
79
+ this._pendingTimeout = null; // Timeout handle
80
+ this._pendingPrompt = ''; // The prompt we're waiting for a response to
81
+ this._partialText = ''; // Accumulate partial assistant text
82
+ this._toolCalls = []; // Tool calls in current turn
83
+ this._gotInit = false; // Whether init event has been received
84
+ }
85
+
86
+ /**
87
+ * Start the Claude process. Just spawns it — does NOT wait for init.
88
+ * The init event arrives when the first message is sent.
89
+ *
90
+ * Call send() after this to interact.
91
+ */
92
+ start() {
93
+ if (this.status !== 'created') {
94
+ throw new Error(`Cannot start: session is "${this.status}"`);
95
+ }
96
+
97
+ // Build claude CLI arguments
98
+ const args = this._buildArgs();
99
+
100
+ // Spawn the process with piped I/O
101
+ this.process = spawn('claude', args, {
102
+ cwd: this.workDir,
103
+ stdio: ['pipe', 'pipe', 'pipe'],
104
+ windowsHide: true,
105
+ });
106
+
107
+ // Handle stdout (NDJSON stream)
108
+ this.process.stdout.on('data', (chunk) => {
109
+ this._handleData(chunk.toString());
110
+ });
111
+
112
+ // Handle stderr
113
+ this.process.stderr.on('data', (chunk) => {
114
+ const msg = chunk.toString().trim();
115
+ if (msg) {
116
+ this.emit('stderr', msg);
117
+ }
118
+ });
119
+
120
+ // Handle process exit
121
+ this.process.on('close', (code) => {
122
+ this.status = 'stopped';
123
+ this.process = null;
124
+
125
+ this.emit('close', code);
126
+
127
+ // Reject any pending send() promise
128
+ if (this._pendingReject) {
129
+ this._pendingReject(new Error(`Process exited while waiting for response (code: ${code})`));
130
+ this._pendingResolve = null;
131
+ this._pendingReject = null;
132
+ }
133
+ if (this._pendingTimeout) {
134
+ clearTimeout(this._pendingTimeout);
135
+ this._pendingTimeout = null;
136
+ }
137
+ });
138
+
139
+ this.process.on('error', (err) => {
140
+ this.status = 'error';
141
+ this.emit('error', err);
142
+
143
+ if (this._pendingReject) {
144
+ this._pendingReject(err);
145
+ this._pendingResolve = null;
146
+ this._pendingReject = null;
147
+ }
148
+ });
149
+
150
+ // Mark as ready to accept send() calls
151
+ this.status = 'ready';
152
+ this.emit('started');
153
+ }
154
+
155
+ /**
156
+ * Send a message and wait for the complete response.
157
+ *
158
+ * On the first call, this also triggers the init event from Claude.
159
+ * Subsequent calls get instant responses (no process restart).
160
+ *
161
+ * @param {string} message - The message to send
162
+ * @param {number} [timeout] - Override timeout for this message
163
+ * @returns {Promise<object>} Response { text, cost, turns, duration, sessionId, isError, toolCalls, raw }
164
+ */
165
+ send(message, timeout) {
166
+ return new Promise((resolve, reject) => {
167
+ if (this.status === 'paused') {
168
+ // Auto-resume if paused
169
+ this.status = 'ready';
170
+ }
171
+
172
+ if (this.status !== 'ready') {
173
+ reject(new Error(`Cannot send: session is "${this.status}" (must be "ready")`));
174
+ return;
175
+ }
176
+
177
+ if (!this.process || !this.process.stdin.writable) {
178
+ reject(new Error('Process is not running or stdin is closed.'));
179
+ return;
180
+ }
181
+
182
+ this.status = 'busy';
183
+ this._partialText = '';
184
+ this._toolCalls = [];
185
+ this._pendingPrompt = message;
186
+ this._pendingResolve = resolve;
187
+ this._pendingReject = reject;
188
+
189
+ // Set timeout
190
+ const timeoutMs = timeout || this.timeout;
191
+ this._pendingTimeout = setTimeout(() => {
192
+ if (this._pendingReject) {
193
+ this._pendingReject(new Error(`Response timeout after ${timeoutMs}ms`));
194
+ this._pendingResolve = null;
195
+ this._pendingReject = null;
196
+ this.status = 'ready';
197
+ }
198
+ }, timeoutMs);
199
+
200
+ // Send the message in stream-json format
201
+ const payload = JSON.stringify({
202
+ type: 'user',
203
+ message: { role: 'user', content: message },
204
+ }) + '\n';
205
+
206
+ this.process.stdin.write(payload);
207
+ this.emit('sent', { message });
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Stop the session gracefully.
213
+ * Closes stdin, which tells claude to exit.
214
+ */
215
+ stop() {
216
+ if (this.process) {
217
+ this.status = 'stopped';
218
+ if (this.process.stdin && this.process.stdin.writable) {
219
+ this.process.stdin.end();
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Force kill the session.
226
+ */
227
+ kill() {
228
+ if (this.process) {
229
+ this.status = 'stopped';
230
+ this.process.kill('SIGTERM');
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Pause the session (marks it, doesn't kill the process).
236
+ */
237
+ pause() {
238
+ if (this.status === 'ready') {
239
+ this.status = 'paused';
240
+ this.emit('paused');
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Resume a paused session.
246
+ */
247
+ resume() {
248
+ if (this.status === 'paused') {
249
+ this.status = 'ready';
250
+ this.emit('resumed');
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Get session info as a plain object.
256
+ */
257
+ toJSON() {
258
+ return {
259
+ name: this.name,
260
+ id: this.id,
261
+ status: this.status,
262
+ model: this.model,
263
+ workDir: this.workDir,
264
+ totalCostUsd: this.totalCostUsd,
265
+ totalTurns: this.totalTurns,
266
+ interactionCount: this.interactions.length,
267
+ pid: this.process ? this.process.pid : null,
268
+ };
269
+ }
270
+
271
+ // ===========================================================================
272
+ // Private methods
273
+ // ===========================================================================
274
+
275
+ /**
276
+ * Build claude CLI arguments for stream-json mode.
277
+ */
278
+ _buildArgs() {
279
+ const args = [
280
+ '-p', // Non-interactive print mode
281
+ '--input-format', 'stream-json', // Streaming input
282
+ '--output-format', 'stream-json', // Streaming output
283
+ '--verbose', // Required for stream-json output
284
+ '--model', this.model,
285
+ ];
286
+
287
+ // Resume existing session
288
+ if (this.id) {
289
+ args.push('--resume', this.id);
290
+ }
291
+
292
+ // Permission mode
293
+ if (this.permissionMode && this.permissionMode !== 'default') {
294
+ args.push('--permission-mode', this.permissionMode);
295
+ }
296
+
297
+ // Allowed tools
298
+ if (this.allowedTools.length > 0) {
299
+ args.push('--allowedTools', ...this.allowedTools);
300
+ }
301
+
302
+ // System prompt
303
+ if (this.systemPrompt) {
304
+ args.push('--append-system-prompt', this.systemPrompt);
305
+ }
306
+
307
+ // Budget limit
308
+ if (this.maxBudget) {
309
+ args.push('--max-budget-usd', String(this.maxBudget));
310
+ }
311
+
312
+ // Agent
313
+ if (this.agent) {
314
+ args.push('--agent', this.agent);
315
+ }
316
+
317
+ return args;
318
+ }
319
+
320
+ /**
321
+ * Handle incoming data from stdout.
322
+ * Parses NDJSON (newline-delimited JSON) events.
323
+ */
324
+ _handleData(data) {
325
+ this._buffer += data;
326
+
327
+ // Split on newlines, keeping incomplete last line in buffer
328
+ const lines = this._buffer.split('\n');
329
+ this._buffer = lines.pop();
330
+
331
+ for (const line of lines) {
332
+ if (!line.trim()) continue;
333
+ try {
334
+ const event = JSON.parse(line);
335
+ this._handleEvent(event);
336
+ } catch (e) {
337
+ this.emit('parse-error', { line, error: e.message });
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Handle a parsed event from the stream.
344
+ */
345
+ _handleEvent(event) {
346
+ this.emit('event', event);
347
+
348
+ switch (event.type) {
349
+ case 'system':
350
+ this._handleSystemEvent(event);
351
+ break;
352
+ case 'assistant':
353
+ this._handleAssistantEvent(event);
354
+ break;
355
+ case 'result':
356
+ this._handleResultEvent(event);
357
+ break;
358
+ default:
359
+ this.emit('unknown-event', event);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Handle system events (init, hooks, etc.).
365
+ */
366
+ _handleSystemEvent(event) {
367
+ if (event.subtype === 'init') {
368
+ this.id = event.session_id;
369
+ this._gotInit = true;
370
+
371
+ this.initData = {
372
+ sessionId: event.session_id,
373
+ tools: event.tools || [],
374
+ model: event.model,
375
+ mcpServers: event.mcp_servers || [],
376
+ agents: event.agents || [],
377
+ skills: event.skills || [],
378
+ };
379
+
380
+ this.emit('init', this.initData);
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Handle assistant message events.
386
+ */
387
+ _handleAssistantEvent(event) {
388
+ if (event.message && event.message.content) {
389
+ for (const block of event.message.content) {
390
+ if (block.type === 'text') {
391
+ this._partialText += block.text;
392
+ this.emit('text', block.text);
393
+ }
394
+ if (block.type === 'tool_use') {
395
+ this._toolCalls.push({
396
+ id: block.id,
397
+ name: block.name,
398
+ input: block.input,
399
+ });
400
+ this.emit('tool-use', { id: block.id, name: block.name, input: block.input });
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Handle result events (final response for a turn).
408
+ * This is what resolves the send() promise.
409
+ */
410
+ _handleResultEvent(event) {
411
+ // Clear timeout
412
+ if (this._pendingTimeout) {
413
+ clearTimeout(this._pendingTimeout);
414
+ this._pendingTimeout = null;
415
+ }
416
+
417
+ // Update session ID
418
+ if (event.session_id) {
419
+ this.id = event.session_id;
420
+ }
421
+
422
+ // Build response object
423
+ const response = {
424
+ text: event.result || this._partialText || '',
425
+ cost: event.total_cost_usd || 0,
426
+ turns: event.num_turns || 0,
427
+ duration: event.duration_ms || 0,
428
+ sessionId: event.session_id,
429
+ isError: event.is_error || false,
430
+ usage: event.usage || null,
431
+ toolCalls: [...this._toolCalls],
432
+ raw: event,
433
+ };
434
+
435
+ // Update running totals
436
+ this.totalCostUsd = event.total_cost_usd || this.totalCostUsd;
437
+ this.totalTurns += event.num_turns || 0;
438
+
439
+ // Record the interaction
440
+ this.interactions.push({
441
+ prompt: this._pendingPrompt || '',
442
+ response: response.text,
443
+ cost: response.cost,
444
+ turns: response.turns,
445
+ duration: response.duration,
446
+ timestamp: new Date().toISOString(),
447
+ });
448
+
449
+ // Back to ready for next message
450
+ this.status = 'ready';
451
+
452
+ this.emit('result', response);
453
+
454
+ // Resolve the send() promise
455
+ if (this._pendingResolve) {
456
+ this._pendingResolve(response);
457
+ this._pendingResolve = null;
458
+ this._pendingReject = null;
459
+ }
460
+ }
461
+ }
462
+
463
+ module.exports = StreamSession;