@venturewild/workspace 0.1.2 → 0.1.4
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/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +75 -75
- package/server/bin/wild-workspace.mjs +725 -725
- package/server/src/agent.mjs +356 -356
- package/server/src/config.mjs +314 -302
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1330 -1330
- package/server/src/service.mjs +202 -32
- package/server/src/sync.mjs +248 -248
package/server/src/agent.mjs
CHANGED
|
@@ -1,356 +1,356 @@
|
|
|
1
|
-
// AI agent subprocess wrapper.
|
|
2
|
-
// AR-17: wrap, don't embed. We spawn `claude` and stream its output — we never
|
|
3
|
-
// embed an SDK or run our own agent loop.
|
|
4
|
-
//
|
|
5
|
-
// The stream-json parser is the heart of this file. `claude -p --output-format
|
|
6
|
-
// stream-json --include-partial-messages` emits NDJSON where each line is one of:
|
|
7
|
-
// - {type:"system",...} session init / status — ignored
|
|
8
|
-
// - {type:"rate_limit_event",...} ignored
|
|
9
|
-
// - {type:"stream_event",event:{}} the Anthropic streaming protocol, wrapped.
|
|
10
|
-
// This is our PRIMARY source: real-time text
|
|
11
|
-
// deltas, tool-call name + streamed input.
|
|
12
|
-
// - {type:"assistant",message:{}} the completed message — REDUNDANT with the
|
|
13
|
-
// stream events, used only as a fallback when
|
|
14
|
-
// --include-partial-messages is off.
|
|
15
|
-
// - {type:"user",message:{}} carries tool_result blocks.
|
|
16
|
-
// - {type:"result",...} final cost + usage + status.
|
|
17
|
-
|
|
18
|
-
import { spawn } from 'node:child_process';
|
|
19
|
-
import { promisify } from 'node:util';
|
|
20
|
-
import { execFile as execFileCb } from 'node:child_process';
|
|
21
|
-
import { EventEmitter } from 'node:events';
|
|
22
|
-
import { DEFAULT_AGENTS } from './config.mjs';
|
|
23
|
-
|
|
24
|
-
const execFile = promisify(execFileCb);
|
|
25
|
-
|
|
26
|
-
const PATH_LOOKUP_TIMEOUT_MS = 1500;
|
|
27
|
-
|
|
28
|
-
async function isOnPath(binary) {
|
|
29
|
-
const probe = process.platform === 'win32' ? 'where.exe' : 'which';
|
|
30
|
-
try {
|
|
31
|
-
const { stdout } = await execFile(probe, [binary], { timeout: PATH_LOOKUP_TIMEOUT_MS });
|
|
32
|
-
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
|
33
|
-
return lines.length > 0 ? lines[0].trim() : null;
|
|
34
|
-
} catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export async function detectAgents(candidates = DEFAULT_AGENTS) {
|
|
40
|
-
const results = await Promise.all(
|
|
41
|
-
candidates.map(async (agent) => {
|
|
42
|
-
const resolved = await isOnPath(agent.binary);
|
|
43
|
-
return { ...agent, available: Boolean(resolved), resolvedPath: resolved };
|
|
44
|
-
}),
|
|
45
|
-
);
|
|
46
|
-
return results;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Flatten a tool_result `content` field (string | array of blocks) to text. */
|
|
50
|
-
function flattenContent(content) {
|
|
51
|
-
if (typeof content === 'string') return content;
|
|
52
|
-
if (Array.isArray(content)) {
|
|
53
|
-
return content
|
|
54
|
-
.map((c) => (typeof c === 'string' ? c : c?.text || ''))
|
|
55
|
-
.join('');
|
|
56
|
-
}
|
|
57
|
-
return '';
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Build the argv for one `claude -p` turn. Extracted from `send()` so the
|
|
62
|
-
* resume / mode / add-dir logic is unit-testable without spawning a process.
|
|
63
|
-
*
|
|
64
|
-
* - `resumeSessionId` : carries the conversation forward. Claude Code
|
|
65
|
-
* rehydrates the prior turns from this id, so the agent remembers what
|
|
66
|
-
* was already said. Absent on the first turn of a conversation.
|
|
67
|
-
* - `mode` : 'plan' is read-only proposing; anything else is 'build', which
|
|
68
|
-
* can create/edit files + run commands (PRD C3 posture — the user's own
|
|
69
|
-
* machine, own agent, own API bill).
|
|
70
|
-
*/
|
|
71
|
-
export function buildClaudeArgs(baseArgs, ctx = {}) {
|
|
72
|
-
const args = [...baseArgs];
|
|
73
|
-
if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId);
|
|
74
|
-
if (ctx.cwd) args.push('--add-dir', ctx.cwd);
|
|
75
|
-
args.push('--permission-mode', ctx.mode === 'plan' ? 'plan' : 'bypassPermissions');
|
|
76
|
-
return args;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* AgentSession streams one chat turn through a wrapped subprocess.
|
|
81
|
-
*
|
|
82
|
-
* Lifecycle per AR-17:
|
|
83
|
-
* - one process per user turn (`claude -p` prints + exits)
|
|
84
|
-
* - emits 'chunk' — a normalized event the UI understands (see below)
|
|
85
|
-
* - emits 'stderr' on stderr output
|
|
86
|
-
* - emits 'end' on process exit with code
|
|
87
|
-
* - emits 'error' on spawn / runtime error
|
|
88
|
-
*
|
|
89
|
-
* Chunk protocol (the only contract the browser depends on):
|
|
90
|
-
* { type:'text', text } streamed assistant text
|
|
91
|
-
* { type:'thinking', text } streamed extended-thinking
|
|
92
|
-
* { type:'tool-use', id, name, input } a completed tool call
|
|
93
|
-
* { type:'tool-result', id, content, isError } that tool's result
|
|
94
|
-
* { type:'usage', usage:{...,cost_usd}, ... } final cost + token usage
|
|
95
|
-
* { type:'error', message } a run-level failure
|
|
96
|
-
*/
|
|
97
|
-
export class AgentSession extends EventEmitter {
|
|
98
|
-
constructor(agent, opts = {}) {
|
|
99
|
-
super();
|
|
100
|
-
this.agent = agent;
|
|
101
|
-
this.opts = opts;
|
|
102
|
-
this.proc = null;
|
|
103
|
-
this.closed = false;
|
|
104
|
-
// The claude session id seen on this turn. The server reads it after the
|
|
105
|
-
// turn ends and passes it to the next turn as --resume, so the agent
|
|
106
|
-
// actually remembers the conversation.
|
|
107
|
-
this.sessionId = null;
|
|
108
|
-
// stream-json parse state — fresh per session (one session per turn)
|
|
109
|
-
this._buffer = '';
|
|
110
|
-
this._sawStreamEvent = false;
|
|
111
|
-
this._blocks = new Map(); // content-block index -> { kind, id, name, jsonBuf }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
send(prompt, ctx = {}) {
|
|
115
|
-
if (this.closed) throw new Error('session closed');
|
|
116
|
-
// claude gets the full resume/mode/add-dir treatment; other agents (and
|
|
117
|
-
// the test echo agent) run with their bare args.
|
|
118
|
-
const args =
|
|
119
|
-
this.agent.id === 'claude'
|
|
120
|
-
? buildClaudeArgs(this.agent.args, ctx)
|
|
121
|
-
: [...this.agent.args];
|
|
122
|
-
// Prefer the resolved absolute path from detection; fall back to the bare
|
|
123
|
-
// name. claude ships a native binary, so shell:false spawns cleanly.
|
|
124
|
-
const command = this.agent.resolvedPath || this.agent.binary;
|
|
125
|
-
this.proc = spawn(command, args, {
|
|
126
|
-
cwd: ctx.cwd || this.opts.cwd || process.cwd(),
|
|
127
|
-
env: { ...process.env, ...(ctx.env || {}) },
|
|
128
|
-
shell: false,
|
|
129
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
|
-
windowsHide: true,
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
this.proc.stdout.setEncoding('utf8');
|
|
134
|
-
this.proc.stderr.setEncoding('utf8');
|
|
135
|
-
|
|
136
|
-
const streamFormat = this.agent.streamFormat || 'text';
|
|
137
|
-
|
|
138
|
-
this.proc.stdout.on('data', (chunk) => this._handleStdout(chunk, streamFormat));
|
|
139
|
-
this.proc.stderr.on('data', (chunk) => this.emit('stderr', chunk));
|
|
140
|
-
this.proc.on('error', (err) => this.emit('error', err));
|
|
141
|
-
this.proc.on('close', (code) => {
|
|
142
|
-
this.emit('end', { code });
|
|
143
|
-
this.proc = null;
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
this.proc.stdin.write(prompt);
|
|
148
|
-
this.proc.stdin.end();
|
|
149
|
-
} catch (e) {
|
|
150
|
-
this.emit('error', e);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
_handleStdout(chunk, streamFormat) {
|
|
155
|
-
if (streamFormat !== 'claude-stream-json') {
|
|
156
|
-
// Plain-text agents (and the test echo agent) stream straight through.
|
|
157
|
-
this.emit('chunk', { type: 'text', text: chunk });
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
this._buffer += chunk;
|
|
161
|
-
const lines = this._buffer.split('\n');
|
|
162
|
-
this._buffer = lines.pop() || '';
|
|
163
|
-
for (const line of lines) {
|
|
164
|
-
const trimmed = line.trim();
|
|
165
|
-
if (!trimmed) continue;
|
|
166
|
-
let evt;
|
|
167
|
-
try {
|
|
168
|
-
evt = JSON.parse(trimmed);
|
|
169
|
-
} catch {
|
|
170
|
-
// Non-JSON noise on stdout (rare). Drop it — don't pollute the chat.
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
this._handleClaudeEvent(evt);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
_handleClaudeEvent(evt) {
|
|
178
|
-
if (!evt || typeof evt !== 'object') return;
|
|
179
|
-
// Every claude stream-json line carries the session id (the `system` init
|
|
180
|
-
// line and the final `result` both do). Capturing it lets the next turn
|
|
181
|
-
// --resume this exact conversation.
|
|
182
|
-
if (typeof evt.session_id === 'string') this.sessionId = evt.session_id;
|
|
183
|
-
switch (evt.type) {
|
|
184
|
-
case 'stream_event':
|
|
185
|
-
this._sawStreamEvent = true;
|
|
186
|
-
this._handleStreamEvent(evt.event);
|
|
187
|
-
return;
|
|
188
|
-
case 'assistant':
|
|
189
|
-
// Redundant with stream_event when --include-partial-messages is on
|
|
190
|
-
// (it always is). Only the fallback path when partials are disabled.
|
|
191
|
-
if (!this._sawStreamEvent) this._handleAssistantFallback(evt.message);
|
|
192
|
-
return;
|
|
193
|
-
case 'user':
|
|
194
|
-
this._handleToolResults(evt.message);
|
|
195
|
-
return;
|
|
196
|
-
case 'result':
|
|
197
|
-
this._handleResult(evt);
|
|
198
|
-
return;
|
|
199
|
-
case 'error':
|
|
200
|
-
this.emit('chunk', {
|
|
201
|
-
type: 'error',
|
|
202
|
-
message: evt.message || evt.error?.message || 'agent error',
|
|
203
|
-
});
|
|
204
|
-
return;
|
|
205
|
-
default:
|
|
206
|
-
// system, rate_limit_event, anything new — ignored.
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** The wrapped Anthropic streaming protocol — our real-time source. */
|
|
212
|
-
_handleStreamEvent(ev) {
|
|
213
|
-
if (!ev || typeof ev !== 'object') return;
|
|
214
|
-
switch (ev.type) {
|
|
215
|
-
case 'content_block_start': {
|
|
216
|
-
const cb = ev.content_block || {};
|
|
217
|
-
if (cb.type === 'tool_use') {
|
|
218
|
-
// Tool input arrives as input_json_delta fragments — buffer them
|
|
219
|
-
// until content_block_stop, then emit one complete tool-use chunk.
|
|
220
|
-
this._blocks.set(ev.index, {
|
|
221
|
-
kind: 'tool',
|
|
222
|
-
id: cb.id,
|
|
223
|
-
name: cb.name,
|
|
224
|
-
jsonBuf: '',
|
|
225
|
-
});
|
|
226
|
-
} else {
|
|
227
|
-
this._blocks.set(ev.index, { kind: cb.type || 'text' });
|
|
228
|
-
}
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
case 'content_block_delta': {
|
|
232
|
-
const d = ev.delta || {};
|
|
233
|
-
if (d.type === 'text_delta' && d.text) {
|
|
234
|
-
this.emit('chunk', { type: 'text', text: d.text });
|
|
235
|
-
} else if (d.type === 'thinking_delta' && d.thinking) {
|
|
236
|
-
this.emit('chunk', { type: 'thinking', text: d.thinking });
|
|
237
|
-
} else if (d.type === 'input_json_delta') {
|
|
238
|
-
const blk = this._blocks.get(ev.index);
|
|
239
|
-
if (blk && blk.kind === 'tool') blk.jsonBuf += d.partial_json || '';
|
|
240
|
-
}
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
case 'content_block_stop': {
|
|
244
|
-
const blk = this._blocks.get(ev.index);
|
|
245
|
-
if (blk && blk.kind === 'tool') {
|
|
246
|
-
let input = {};
|
|
247
|
-
try {
|
|
248
|
-
input = blk.jsonBuf ? JSON.parse(blk.jsonBuf) : {};
|
|
249
|
-
} catch {
|
|
250
|
-
input = { _raw: blk.jsonBuf };
|
|
251
|
-
}
|
|
252
|
-
this.emit('chunk', {
|
|
253
|
-
type: 'tool-use',
|
|
254
|
-
id: blk.id,
|
|
255
|
-
name: blk.name,
|
|
256
|
-
input,
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
this._blocks.delete(ev.index);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
default:
|
|
263
|
-
// message_start / message_delta / message_stop — no UI effect.
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Fallback for `--include-partial-messages` off: parse the whole message. */
|
|
269
|
-
_handleAssistantFallback(message) {
|
|
270
|
-
const content = message?.content;
|
|
271
|
-
if (typeof content === 'string') {
|
|
272
|
-
if (content) this.emit('chunk', { type: 'text', text: content });
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (!Array.isArray(content)) return;
|
|
276
|
-
for (const block of content) {
|
|
277
|
-
if (block.type === 'text' && block.text) {
|
|
278
|
-
this.emit('chunk', { type: 'text', text: block.text });
|
|
279
|
-
} else if (block.type === 'thinking' && block.thinking) {
|
|
280
|
-
this.emit('chunk', { type: 'thinking', text: block.thinking });
|
|
281
|
-
} else if (block.type === 'tool_use') {
|
|
282
|
-
this.emit('chunk', {
|
|
283
|
-
type: 'tool-use',
|
|
284
|
-
id: block.id,
|
|
285
|
-
name: block.name,
|
|
286
|
-
input: block.input || {},
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** `user` events carry tool_result blocks — match them back to a tool card. */
|
|
293
|
-
_handleToolResults(message) {
|
|
294
|
-
const content = message?.content;
|
|
295
|
-
if (!Array.isArray(content)) return;
|
|
296
|
-
for (const block of content) {
|
|
297
|
-
if (block.type === 'tool_result') {
|
|
298
|
-
this.emit('chunk', {
|
|
299
|
-
type: 'tool-result',
|
|
300
|
-
id: block.tool_use_id,
|
|
301
|
-
content: flattenContent(block.content),
|
|
302
|
-
isError: block.is_error === true,
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** The final `result` event — authoritative cost + usage for the turn. */
|
|
309
|
-
_handleResult(evt) {
|
|
310
|
-
const u = evt.usage || {};
|
|
311
|
-
this.emit('chunk', {
|
|
312
|
-
type: 'usage',
|
|
313
|
-
usage: {
|
|
314
|
-
input_tokens: u.input_tokens || 0,
|
|
315
|
-
output_tokens: u.output_tokens || 0,
|
|
316
|
-
cache_read_input_tokens: u.cache_read_input_tokens || 0,
|
|
317
|
-
// cost_usd lives here so the ActivityBus can accumulate it directly.
|
|
318
|
-
cost_usd: typeof evt.total_cost_usd === 'number' ? evt.total_cost_usd : 0,
|
|
319
|
-
},
|
|
320
|
-
durationMs: evt.duration_ms,
|
|
321
|
-
numTurns: evt.num_turns,
|
|
322
|
-
});
|
|
323
|
-
if (evt.is_error || (evt.subtype && evt.subtype !== 'success')) {
|
|
324
|
-
this.emit('chunk', {
|
|
325
|
-
type: 'error',
|
|
326
|
-
message:
|
|
327
|
-
(typeof evt.result === 'string' && evt.result) ||
|
|
328
|
-
evt.subtype ||
|
|
329
|
-
'agent run failed',
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
cancel() {
|
|
335
|
-
if (this.proc && !this.proc.killed) {
|
|
336
|
-
try {
|
|
337
|
-
this.proc.kill();
|
|
338
|
-
} catch {}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
close() {
|
|
343
|
-
this.closed = true;
|
|
344
|
-
this.cancel();
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function pickDefaultAgent(detected) {
|
|
349
|
-
// Prefer claude; fall back to first available; fall back to claude (assume
|
|
350
|
-
// it'll be installed — the UI shows an install hint when it isn't).
|
|
351
|
-
const claude = detected.find((a) => a.id === 'claude' && a.available);
|
|
352
|
-
if (claude) return claude;
|
|
353
|
-
const anyAvailable = detected.find((a) => a.available);
|
|
354
|
-
if (anyAvailable) return anyAvailable;
|
|
355
|
-
return detected[0] || DEFAULT_AGENTS[0];
|
|
356
|
-
}
|
|
1
|
+
// AI agent subprocess wrapper.
|
|
2
|
+
// AR-17: wrap, don't embed. We spawn `claude` and stream its output — we never
|
|
3
|
+
// embed an SDK or run our own agent loop.
|
|
4
|
+
//
|
|
5
|
+
// The stream-json parser is the heart of this file. `claude -p --output-format
|
|
6
|
+
// stream-json --include-partial-messages` emits NDJSON where each line is one of:
|
|
7
|
+
// - {type:"system",...} session init / status — ignored
|
|
8
|
+
// - {type:"rate_limit_event",...} ignored
|
|
9
|
+
// - {type:"stream_event",event:{}} the Anthropic streaming protocol, wrapped.
|
|
10
|
+
// This is our PRIMARY source: real-time text
|
|
11
|
+
// deltas, tool-call name + streamed input.
|
|
12
|
+
// - {type:"assistant",message:{}} the completed message — REDUNDANT with the
|
|
13
|
+
// stream events, used only as a fallback when
|
|
14
|
+
// --include-partial-messages is off.
|
|
15
|
+
// - {type:"user",message:{}} carries tool_result blocks.
|
|
16
|
+
// - {type:"result",...} final cost + usage + status.
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
21
|
+
import { EventEmitter } from 'node:events';
|
|
22
|
+
import { DEFAULT_AGENTS } from './config.mjs';
|
|
23
|
+
|
|
24
|
+
const execFile = promisify(execFileCb);
|
|
25
|
+
|
|
26
|
+
const PATH_LOOKUP_TIMEOUT_MS = 1500;
|
|
27
|
+
|
|
28
|
+
async function isOnPath(binary) {
|
|
29
|
+
const probe = process.platform === 'win32' ? 'where.exe' : 'which';
|
|
30
|
+
try {
|
|
31
|
+
const { stdout } = await execFile(probe, [binary], { timeout: PATH_LOOKUP_TIMEOUT_MS });
|
|
32
|
+
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
|
33
|
+
return lines.length > 0 ? lines[0].trim() : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function detectAgents(candidates = DEFAULT_AGENTS) {
|
|
40
|
+
const results = await Promise.all(
|
|
41
|
+
candidates.map(async (agent) => {
|
|
42
|
+
const resolved = await isOnPath(agent.binary);
|
|
43
|
+
return { ...agent, available: Boolean(resolved), resolvedPath: resolved };
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Flatten a tool_result `content` field (string | array of blocks) to text. */
|
|
50
|
+
function flattenContent(content) {
|
|
51
|
+
if (typeof content === 'string') return content;
|
|
52
|
+
if (Array.isArray(content)) {
|
|
53
|
+
return content
|
|
54
|
+
.map((c) => (typeof c === 'string' ? c : c?.text || ''))
|
|
55
|
+
.join('');
|
|
56
|
+
}
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the argv for one `claude -p` turn. Extracted from `send()` so the
|
|
62
|
+
* resume / mode / add-dir logic is unit-testable without spawning a process.
|
|
63
|
+
*
|
|
64
|
+
* - `resumeSessionId` : carries the conversation forward. Claude Code
|
|
65
|
+
* rehydrates the prior turns from this id, so the agent remembers what
|
|
66
|
+
* was already said. Absent on the first turn of a conversation.
|
|
67
|
+
* - `mode` : 'plan' is read-only proposing; anything else is 'build', which
|
|
68
|
+
* can create/edit files + run commands (PRD C3 posture — the user's own
|
|
69
|
+
* machine, own agent, own API bill).
|
|
70
|
+
*/
|
|
71
|
+
export function buildClaudeArgs(baseArgs, ctx = {}) {
|
|
72
|
+
const args = [...baseArgs];
|
|
73
|
+
if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId);
|
|
74
|
+
if (ctx.cwd) args.push('--add-dir', ctx.cwd);
|
|
75
|
+
args.push('--permission-mode', ctx.mode === 'plan' ? 'plan' : 'bypassPermissions');
|
|
76
|
+
return args;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* AgentSession streams one chat turn through a wrapped subprocess.
|
|
81
|
+
*
|
|
82
|
+
* Lifecycle per AR-17:
|
|
83
|
+
* - one process per user turn (`claude -p` prints + exits)
|
|
84
|
+
* - emits 'chunk' — a normalized event the UI understands (see below)
|
|
85
|
+
* - emits 'stderr' on stderr output
|
|
86
|
+
* - emits 'end' on process exit with code
|
|
87
|
+
* - emits 'error' on spawn / runtime error
|
|
88
|
+
*
|
|
89
|
+
* Chunk protocol (the only contract the browser depends on):
|
|
90
|
+
* { type:'text', text } streamed assistant text
|
|
91
|
+
* { type:'thinking', text } streamed extended-thinking
|
|
92
|
+
* { type:'tool-use', id, name, input } a completed tool call
|
|
93
|
+
* { type:'tool-result', id, content, isError } that tool's result
|
|
94
|
+
* { type:'usage', usage:{...,cost_usd}, ... } final cost + token usage
|
|
95
|
+
* { type:'error', message } a run-level failure
|
|
96
|
+
*/
|
|
97
|
+
export class AgentSession extends EventEmitter {
|
|
98
|
+
constructor(agent, opts = {}) {
|
|
99
|
+
super();
|
|
100
|
+
this.agent = agent;
|
|
101
|
+
this.opts = opts;
|
|
102
|
+
this.proc = null;
|
|
103
|
+
this.closed = false;
|
|
104
|
+
// The claude session id seen on this turn. The server reads it after the
|
|
105
|
+
// turn ends and passes it to the next turn as --resume, so the agent
|
|
106
|
+
// actually remembers the conversation.
|
|
107
|
+
this.sessionId = null;
|
|
108
|
+
// stream-json parse state — fresh per session (one session per turn)
|
|
109
|
+
this._buffer = '';
|
|
110
|
+
this._sawStreamEvent = false;
|
|
111
|
+
this._blocks = new Map(); // content-block index -> { kind, id, name, jsonBuf }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
send(prompt, ctx = {}) {
|
|
115
|
+
if (this.closed) throw new Error('session closed');
|
|
116
|
+
// claude gets the full resume/mode/add-dir treatment; other agents (and
|
|
117
|
+
// the test echo agent) run with their bare args.
|
|
118
|
+
const args =
|
|
119
|
+
this.agent.id === 'claude'
|
|
120
|
+
? buildClaudeArgs(this.agent.args, ctx)
|
|
121
|
+
: [...this.agent.args];
|
|
122
|
+
// Prefer the resolved absolute path from detection; fall back to the bare
|
|
123
|
+
// name. claude ships a native binary, so shell:false spawns cleanly.
|
|
124
|
+
const command = this.agent.resolvedPath || this.agent.binary;
|
|
125
|
+
this.proc = spawn(command, args, {
|
|
126
|
+
cwd: ctx.cwd || this.opts.cwd || process.cwd(),
|
|
127
|
+
env: { ...process.env, ...(ctx.env || {}) },
|
|
128
|
+
shell: false,
|
|
129
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
|
+
windowsHide: true,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this.proc.stdout.setEncoding('utf8');
|
|
134
|
+
this.proc.stderr.setEncoding('utf8');
|
|
135
|
+
|
|
136
|
+
const streamFormat = this.agent.streamFormat || 'text';
|
|
137
|
+
|
|
138
|
+
this.proc.stdout.on('data', (chunk) => this._handleStdout(chunk, streamFormat));
|
|
139
|
+
this.proc.stderr.on('data', (chunk) => this.emit('stderr', chunk));
|
|
140
|
+
this.proc.on('error', (err) => this.emit('error', err));
|
|
141
|
+
this.proc.on('close', (code) => {
|
|
142
|
+
this.emit('end', { code });
|
|
143
|
+
this.proc = null;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
this.proc.stdin.write(prompt);
|
|
148
|
+
this.proc.stdin.end();
|
|
149
|
+
} catch (e) {
|
|
150
|
+
this.emit('error', e);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_handleStdout(chunk, streamFormat) {
|
|
155
|
+
if (streamFormat !== 'claude-stream-json') {
|
|
156
|
+
// Plain-text agents (and the test echo agent) stream straight through.
|
|
157
|
+
this.emit('chunk', { type: 'text', text: chunk });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this._buffer += chunk;
|
|
161
|
+
const lines = this._buffer.split('\n');
|
|
162
|
+
this._buffer = lines.pop() || '';
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const trimmed = line.trim();
|
|
165
|
+
if (!trimmed) continue;
|
|
166
|
+
let evt;
|
|
167
|
+
try {
|
|
168
|
+
evt = JSON.parse(trimmed);
|
|
169
|
+
} catch {
|
|
170
|
+
// Non-JSON noise on stdout (rare). Drop it — don't pollute the chat.
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
this._handleClaudeEvent(evt);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_handleClaudeEvent(evt) {
|
|
178
|
+
if (!evt || typeof evt !== 'object') return;
|
|
179
|
+
// Every claude stream-json line carries the session id (the `system` init
|
|
180
|
+
// line and the final `result` both do). Capturing it lets the next turn
|
|
181
|
+
// --resume this exact conversation.
|
|
182
|
+
if (typeof evt.session_id === 'string') this.sessionId = evt.session_id;
|
|
183
|
+
switch (evt.type) {
|
|
184
|
+
case 'stream_event':
|
|
185
|
+
this._sawStreamEvent = true;
|
|
186
|
+
this._handleStreamEvent(evt.event);
|
|
187
|
+
return;
|
|
188
|
+
case 'assistant':
|
|
189
|
+
// Redundant with stream_event when --include-partial-messages is on
|
|
190
|
+
// (it always is). Only the fallback path when partials are disabled.
|
|
191
|
+
if (!this._sawStreamEvent) this._handleAssistantFallback(evt.message);
|
|
192
|
+
return;
|
|
193
|
+
case 'user':
|
|
194
|
+
this._handleToolResults(evt.message);
|
|
195
|
+
return;
|
|
196
|
+
case 'result':
|
|
197
|
+
this._handleResult(evt);
|
|
198
|
+
return;
|
|
199
|
+
case 'error':
|
|
200
|
+
this.emit('chunk', {
|
|
201
|
+
type: 'error',
|
|
202
|
+
message: evt.message || evt.error?.message || 'agent error',
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
default:
|
|
206
|
+
// system, rate_limit_event, anything new — ignored.
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The wrapped Anthropic streaming protocol — our real-time source. */
|
|
212
|
+
_handleStreamEvent(ev) {
|
|
213
|
+
if (!ev || typeof ev !== 'object') return;
|
|
214
|
+
switch (ev.type) {
|
|
215
|
+
case 'content_block_start': {
|
|
216
|
+
const cb = ev.content_block || {};
|
|
217
|
+
if (cb.type === 'tool_use') {
|
|
218
|
+
// Tool input arrives as input_json_delta fragments — buffer them
|
|
219
|
+
// until content_block_stop, then emit one complete tool-use chunk.
|
|
220
|
+
this._blocks.set(ev.index, {
|
|
221
|
+
kind: 'tool',
|
|
222
|
+
id: cb.id,
|
|
223
|
+
name: cb.name,
|
|
224
|
+
jsonBuf: '',
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
this._blocks.set(ev.index, { kind: cb.type || 'text' });
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
case 'content_block_delta': {
|
|
232
|
+
const d = ev.delta || {};
|
|
233
|
+
if (d.type === 'text_delta' && d.text) {
|
|
234
|
+
this.emit('chunk', { type: 'text', text: d.text });
|
|
235
|
+
} else if (d.type === 'thinking_delta' && d.thinking) {
|
|
236
|
+
this.emit('chunk', { type: 'thinking', text: d.thinking });
|
|
237
|
+
} else if (d.type === 'input_json_delta') {
|
|
238
|
+
const blk = this._blocks.get(ev.index);
|
|
239
|
+
if (blk && blk.kind === 'tool') blk.jsonBuf += d.partial_json || '';
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
case 'content_block_stop': {
|
|
244
|
+
const blk = this._blocks.get(ev.index);
|
|
245
|
+
if (blk && blk.kind === 'tool') {
|
|
246
|
+
let input = {};
|
|
247
|
+
try {
|
|
248
|
+
input = blk.jsonBuf ? JSON.parse(blk.jsonBuf) : {};
|
|
249
|
+
} catch {
|
|
250
|
+
input = { _raw: blk.jsonBuf };
|
|
251
|
+
}
|
|
252
|
+
this.emit('chunk', {
|
|
253
|
+
type: 'tool-use',
|
|
254
|
+
id: blk.id,
|
|
255
|
+
name: blk.name,
|
|
256
|
+
input,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
this._blocks.delete(ev.index);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
default:
|
|
263
|
+
// message_start / message_delta / message_stop — no UI effect.
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Fallback for `--include-partial-messages` off: parse the whole message. */
|
|
269
|
+
_handleAssistantFallback(message) {
|
|
270
|
+
const content = message?.content;
|
|
271
|
+
if (typeof content === 'string') {
|
|
272
|
+
if (content) this.emit('chunk', { type: 'text', text: content });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!Array.isArray(content)) return;
|
|
276
|
+
for (const block of content) {
|
|
277
|
+
if (block.type === 'text' && block.text) {
|
|
278
|
+
this.emit('chunk', { type: 'text', text: block.text });
|
|
279
|
+
} else if (block.type === 'thinking' && block.thinking) {
|
|
280
|
+
this.emit('chunk', { type: 'thinking', text: block.thinking });
|
|
281
|
+
} else if (block.type === 'tool_use') {
|
|
282
|
+
this.emit('chunk', {
|
|
283
|
+
type: 'tool-use',
|
|
284
|
+
id: block.id,
|
|
285
|
+
name: block.name,
|
|
286
|
+
input: block.input || {},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** `user` events carry tool_result blocks — match them back to a tool card. */
|
|
293
|
+
_handleToolResults(message) {
|
|
294
|
+
const content = message?.content;
|
|
295
|
+
if (!Array.isArray(content)) return;
|
|
296
|
+
for (const block of content) {
|
|
297
|
+
if (block.type === 'tool_result') {
|
|
298
|
+
this.emit('chunk', {
|
|
299
|
+
type: 'tool-result',
|
|
300
|
+
id: block.tool_use_id,
|
|
301
|
+
content: flattenContent(block.content),
|
|
302
|
+
isError: block.is_error === true,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The final `result` event — authoritative cost + usage for the turn. */
|
|
309
|
+
_handleResult(evt) {
|
|
310
|
+
const u = evt.usage || {};
|
|
311
|
+
this.emit('chunk', {
|
|
312
|
+
type: 'usage',
|
|
313
|
+
usage: {
|
|
314
|
+
input_tokens: u.input_tokens || 0,
|
|
315
|
+
output_tokens: u.output_tokens || 0,
|
|
316
|
+
cache_read_input_tokens: u.cache_read_input_tokens || 0,
|
|
317
|
+
// cost_usd lives here so the ActivityBus can accumulate it directly.
|
|
318
|
+
cost_usd: typeof evt.total_cost_usd === 'number' ? evt.total_cost_usd : 0,
|
|
319
|
+
},
|
|
320
|
+
durationMs: evt.duration_ms,
|
|
321
|
+
numTurns: evt.num_turns,
|
|
322
|
+
});
|
|
323
|
+
if (evt.is_error || (evt.subtype && evt.subtype !== 'success')) {
|
|
324
|
+
this.emit('chunk', {
|
|
325
|
+
type: 'error',
|
|
326
|
+
message:
|
|
327
|
+
(typeof evt.result === 'string' && evt.result) ||
|
|
328
|
+
evt.subtype ||
|
|
329
|
+
'agent run failed',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
cancel() {
|
|
335
|
+
if (this.proc && !this.proc.killed) {
|
|
336
|
+
try {
|
|
337
|
+
this.proc.kill();
|
|
338
|
+
} catch {}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
close() {
|
|
343
|
+
this.closed = true;
|
|
344
|
+
this.cancel();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function pickDefaultAgent(detected) {
|
|
349
|
+
// Prefer claude; fall back to first available; fall back to claude (assume
|
|
350
|
+
// it'll be installed — the UI shows an install hint when it isn't).
|
|
351
|
+
const claude = detected.find((a) => a.id === 'claude' && a.available);
|
|
352
|
+
if (claude) return claude;
|
|
353
|
+
const anyAvailable = detected.find((a) => a.available);
|
|
354
|
+
if (anyAvailable) return anyAvailable;
|
|
355
|
+
return detected[0] || DEFAULT_AGENTS[0];
|
|
356
|
+
}
|