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/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +1006 -0
- package/STRATEGY.md +485 -0
- package/bin/cli.js +1756 -0
- package/bin/continuity-hook.js +118 -0
- package/bin/mcp.js +27 -0
- package/bin/setup.js +929 -0
- package/package.json +56 -0
- package/src/artifact-store.js +710 -0
- package/src/atomic-io.js +99 -0
- package/src/briefing-generator.js +451 -0
- package/src/continuity-hooks.js +253 -0
- package/src/contract-store.js +525 -0
- package/src/decision-journal.js +229 -0
- package/src/delegate.js +348 -0
- package/src/dependency-resolver.js +453 -0
- package/src/diff-engine.js +473 -0
- package/src/file-lock.js +161 -0
- package/src/index.js +61 -0
- package/src/lineage-graph.js +402 -0
- package/src/manager.js +510 -0
- package/src/mcp-server.js +3501 -0
- package/src/pattern-registry.js +221 -0
- package/src/pipeline-engine.js +618 -0
- package/src/prompts.js +1217 -0
- package/src/safety-net.js +170 -0
- package/src/session-snapshot.js +508 -0
- package/src/snapshot-engine.js +490 -0
- package/src/stale-detector.js +169 -0
- package/src/store.js +131 -0
- package/src/stream-session.js +463 -0
- package/src/team-hub.js +615 -0
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;
|