@venturewild/workspace 0.1.13 → 0.2.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.
Files changed (38) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -76
  4. package/server/bin/wild-workspace.mjs +825 -763
  5. package/server/src/agent.mjs +453 -386
  6. package/server/src/bazaar/core.mjs +579 -0
  7. package/server/src/bazaar/index.mjs +75 -0
  8. package/server/src/bazaar/mcp-server.mjs +328 -0
  9. package/server/src/bazaar/mock-tickup.mjs +97 -0
  10. package/server/src/bazaar/preview-server.mjs +95 -0
  11. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -0
  12. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -0
  13. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -0
  14. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -0
  15. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -0
  16. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -0
  17. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -0
  18. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -0
  19. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -0
  20. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -0
  21. package/server/src/canvas/core.mjs +324 -0
  22. package/server/src/canvas/index.mjs +42 -0
  23. package/server/src/canvas/mcp-server.mjs +253 -0
  24. package/server/src/config.mjs +365 -365
  25. package/server/src/daemon-supervisor.mjs +216 -216
  26. package/server/src/inbox.mjs +86 -86
  27. package/server/src/index.mjs +1948 -1726
  28. package/server/src/logpaths.mjs +98 -98
  29. package/server/src/pairing.mjs +9 -18
  30. package/server/src/service.mjs +419 -419
  31. package/server/src/share.mjs +182 -148
  32. package/server/src/sync.mjs +248 -248
  33. package/server/src/turn-mcp.mjs +46 -0
  34. package/web/dist/assets/index-DVWgeTl_.js +91 -0
  35. package/web/dist/assets/index-Dl0VT5e6.css +1 -0
  36. package/web/dist/index.html +2 -2
  37. package/web/dist/assets/index-Bj-mdLGj.css +0 -1
  38. package/web/dist/assets/index-CAzFAt7W.js +0 -89
@@ -1,386 +1,453 @@
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 os from 'node:os';
23
- import fs from 'node:fs';
24
- import { DEFAULT_AGENTS } from './config.mjs';
25
-
26
- const execFile = promisify(execFileCb);
27
-
28
- const PATH_LOOKUP_TIMEOUT_MS = 1500;
29
-
30
- // Where `claude` (and other user-installed CLIs) actually live on macOS/Linux.
31
- // The native installer drops it in ~/.local/bin; Homebrew uses /opt/homebrew/bin
32
- // (Apple Silicon) or /usr/local/bin; our no-sudo npm prefix is ~/.npm-global/bin.
33
- function toolDirs(home = os.homedir()) {
34
- return [`${home}/.local/bin`, '/opt/homebrew/bin', '/usr/local/bin', `${home}/.npm-global/bin`];
35
- }
36
-
37
- // GUI / launchd-launched processes inherit a MINIMAL PATH that omits all of the
38
- // above and never reads ~/.zshrc — so a server started by the macOS always-on
39
- // LaunchAgent can't find `claude` (spawn ENOENT). Idempotently add the real tool
40
- // dirs so detection + spawn work regardless of how the process was launched.
41
- export function ensureToolPath(env = process.env, { platform = process.platform, home = os.homedir() } = {}) {
42
- if (platform === 'win32') return env.PATH; // HKCU\Run inherits the full user PATH
43
- const have = (env.PATH || '').split(':').filter(Boolean);
44
- const add = toolDirs(home).filter((d) => !have.includes(d));
45
- if (add.length) env.PATH = [...have, ...add].join(':');
46
- return env.PATH;
47
- }
48
-
49
- async function isOnPath(binary) {
50
- ensureToolPath(); // make sure the tool dirs are on PATH before we look
51
- const probe = process.platform === 'win32' ? 'where.exe' : 'which';
52
- try {
53
- const { stdout } = await execFile(probe, [binary], { timeout: PATH_LOOKUP_TIMEOUT_MS });
54
- const lines = stdout.split(/\r?\n/).filter(Boolean);
55
- if (lines.length > 0) return lines[0].trim();
56
- } catch { /* fall through to a direct probe */ }
57
- // which/where can still miss a freshly-installed binary in a stripped launchd
58
- // environment — probe the known install dirs directly and return an ABSOLUTE
59
- // path, which spawn uses verbatim (no PATH needed at spawn time).
60
- if (process.platform !== 'win32') {
61
- for (const dir of toolDirs()) {
62
- const candidate = `${dir}/${binary}`;
63
- try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* next */ }
64
- }
65
- }
66
- return null;
67
- }
68
-
69
- export async function detectAgents(candidates = DEFAULT_AGENTS) {
70
- const results = await Promise.all(
71
- candidates.map(async (agent) => {
72
- const resolved = await isOnPath(agent.binary);
73
- return { ...agent, available: Boolean(resolved), resolvedPath: resolved };
74
- }),
75
- );
76
- return results;
77
- }
78
-
79
- /** Flatten a tool_result `content` field (string | array of blocks) to text. */
80
- function flattenContent(content) {
81
- if (typeof content === 'string') return content;
82
- if (Array.isArray(content)) {
83
- return content
84
- .map((c) => (typeof c === 'string' ? c : c?.text || ''))
85
- .join('');
86
- }
87
- return '';
88
- }
89
-
90
- /**
91
- * Build the argv for one `claude -p` turn. Extracted from `send()` so the
92
- * resume / mode / add-dir logic is unit-testable without spawning a process.
93
- *
94
- * - `resumeSessionId` : carries the conversation forward. Claude Code
95
- * rehydrates the prior turns from this id, so the agent remembers what
96
- * was already said. Absent on the first turn of a conversation.
97
- * - `mode` : 'plan' is read-only proposing; anything else is 'build', which
98
- * can create/edit files + run commands (PRD C3 posture — the user's own
99
- * machine, own agent, own API bill).
100
- */
101
- export function buildClaudeArgs(baseArgs, ctx = {}) {
102
- const args = [...baseArgs];
103
- if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId);
104
- if (ctx.cwd) args.push('--add-dir', ctx.cwd);
105
- args.push('--permission-mode', ctx.mode === 'plan' ? 'plan' : 'bypassPermissions');
106
- return args;
107
- }
108
-
109
- /**
110
- * AgentSession streams one chat turn through a wrapped subprocess.
111
- *
112
- * Lifecycle per AR-17:
113
- * - one process per user turn (`claude -p` prints + exits)
114
- * - emits 'chunk' a normalized event the UI understands (see below)
115
- * - emits 'stderr' on stderr output
116
- * - emits 'end' on process exit with code
117
- * - emits 'error' on spawn / runtime error
118
- *
119
- * Chunk protocol (the only contract the browser depends on):
120
- * { type:'text', text } streamed assistant text
121
- * { type:'thinking', text } streamed extended-thinking
122
- * { type:'tool-use', id, name, input } a completed tool call
123
- * { type:'tool-result', id, content, isError } that tool's result
124
- * { type:'usage', usage:{...,cost_usd}, ... } final cost + token usage
125
- * { type:'error', message } a run-level failure
126
- */
127
- export class AgentSession extends EventEmitter {
128
- constructor(agent, opts = {}) {
129
- super();
130
- this.agent = agent;
131
- this.opts = opts;
132
- this.proc = null;
133
- this.closed = false;
134
- // The claude session id seen on this turn. The server reads it after the
135
- // turn ends and passes it to the next turn as --resume, so the agent
136
- // actually remembers the conversation.
137
- this.sessionId = null;
138
- // stream-json parse state fresh per session (one session per turn)
139
- this._buffer = '';
140
- this._sawStreamEvent = false;
141
- this._blocks = new Map(); // content-block index -> { kind, id, name, jsonBuf }
142
- }
143
-
144
- send(prompt, ctx = {}) {
145
- if (this.closed) throw new Error('session closed');
146
- // claude gets the full resume/mode/add-dir treatment; other agents (and
147
- // the test echo agent) run with their bare args.
148
- const args =
149
- this.agent.id === 'claude'
150
- ? buildClaudeArgs(this.agent.args, ctx)
151
- : [...this.agent.args];
152
- // Prefer the resolved absolute path from detection; fall back to the bare
153
- // name. claude ships a native binary, so shell:false spawns cleanly.
154
- const command = this.agent.resolvedPath || this.agent.binary;
155
- this.proc = spawn(command, args, {
156
- cwd: ctx.cwd || this.opts.cwd || process.cwd(),
157
- env: { ...process.env, ...(ctx.env || {}) },
158
- shell: false,
159
- stdio: ['pipe', 'pipe', 'pipe'],
160
- windowsHide: true,
161
- });
162
-
163
- this.proc.stdout.setEncoding('utf8');
164
- this.proc.stderr.setEncoding('utf8');
165
-
166
- const streamFormat = this.agent.streamFormat || 'text';
167
-
168
- this.proc.stdout.on('data', (chunk) => this._handleStdout(chunk, streamFormat));
169
- this.proc.stderr.on('data', (chunk) => this.emit('stderr', chunk));
170
- this.proc.on('error', (err) => this.emit('error', err));
171
- this.proc.on('close', (code) => {
172
- this.emit('end', { code });
173
- this.proc = null;
174
- });
175
-
176
- try {
177
- this.proc.stdin.write(prompt);
178
- this.proc.stdin.end();
179
- } catch (e) {
180
- this.emit('error', e);
181
- }
182
- }
183
-
184
- _handleStdout(chunk, streamFormat) {
185
- if (streamFormat !== 'claude-stream-json') {
186
- // Plain-text agents (and the test echo agent) stream straight through.
187
- this.emit('chunk', { type: 'text', text: chunk });
188
- return;
189
- }
190
- this._buffer += chunk;
191
- const lines = this._buffer.split('\n');
192
- this._buffer = lines.pop() || '';
193
- for (const line of lines) {
194
- const trimmed = line.trim();
195
- if (!trimmed) continue;
196
- let evt;
197
- try {
198
- evt = JSON.parse(trimmed);
199
- } catch {
200
- // Non-JSON noise on stdout (rare). Drop it — don't pollute the chat.
201
- continue;
202
- }
203
- this._handleClaudeEvent(evt);
204
- }
205
- }
206
-
207
- _handleClaudeEvent(evt) {
208
- if (!evt || typeof evt !== 'object') return;
209
- // Every claude stream-json line carries the session id (the `system` init
210
- // line and the final `result` both do). Capturing it lets the next turn
211
- // --resume this exact conversation.
212
- if (typeof evt.session_id === 'string') this.sessionId = evt.session_id;
213
- switch (evt.type) {
214
- case 'stream_event':
215
- this._sawStreamEvent = true;
216
- this._handleStreamEvent(evt.event);
217
- return;
218
- case 'assistant':
219
- // Redundant with stream_event when --include-partial-messages is on
220
- // (it always is). Only the fallback path when partials are disabled.
221
- if (!this._sawStreamEvent) this._handleAssistantFallback(evt.message);
222
- return;
223
- case 'user':
224
- this._handleToolResults(evt.message);
225
- return;
226
- case 'result':
227
- this._handleResult(evt);
228
- return;
229
- case 'error':
230
- this.emit('chunk', {
231
- type: 'error',
232
- message: evt.message || evt.error?.message || 'agent error',
233
- });
234
- return;
235
- default:
236
- // system, rate_limit_event, anything new — ignored.
237
- return;
238
- }
239
- }
240
-
241
- /** The wrapped Anthropic streaming protocol — our real-time source. */
242
- _handleStreamEvent(ev) {
243
- if (!ev || typeof ev !== 'object') return;
244
- switch (ev.type) {
245
- case 'content_block_start': {
246
- const cb = ev.content_block || {};
247
- if (cb.type === 'tool_use') {
248
- // Tool input arrives as input_json_delta fragments — buffer them
249
- // until content_block_stop, then emit one complete tool-use chunk.
250
- this._blocks.set(ev.index, {
251
- kind: 'tool',
252
- id: cb.id,
253
- name: cb.name,
254
- jsonBuf: '',
255
- });
256
- } else {
257
- this._blocks.set(ev.index, { kind: cb.type || 'text' });
258
- }
259
- return;
260
- }
261
- case 'content_block_delta': {
262
- const d = ev.delta || {};
263
- if (d.type === 'text_delta' && d.text) {
264
- this.emit('chunk', { type: 'text', text: d.text });
265
- } else if (d.type === 'thinking_delta' && d.thinking) {
266
- this.emit('chunk', { type: 'thinking', text: d.thinking });
267
- } else if (d.type === 'input_json_delta') {
268
- const blk = this._blocks.get(ev.index);
269
- if (blk && blk.kind === 'tool') blk.jsonBuf += d.partial_json || '';
270
- }
271
- return;
272
- }
273
- case 'content_block_stop': {
274
- const blk = this._blocks.get(ev.index);
275
- if (blk && blk.kind === 'tool') {
276
- let input = {};
277
- try {
278
- input = blk.jsonBuf ? JSON.parse(blk.jsonBuf) : {};
279
- } catch {
280
- input = { _raw: blk.jsonBuf };
281
- }
282
- this.emit('chunk', {
283
- type: 'tool-use',
284
- id: blk.id,
285
- name: blk.name,
286
- input,
287
- });
288
- }
289
- this._blocks.delete(ev.index);
290
- return;
291
- }
292
- default:
293
- // message_start / message_delta / message_stop — no UI effect.
294
- return;
295
- }
296
- }
297
-
298
- /** Fallback for `--include-partial-messages` off: parse the whole message. */
299
- _handleAssistantFallback(message) {
300
- const content = message?.content;
301
- if (typeof content === 'string') {
302
- if (content) this.emit('chunk', { type: 'text', text: content });
303
- return;
304
- }
305
- if (!Array.isArray(content)) return;
306
- for (const block of content) {
307
- if (block.type === 'text' && block.text) {
308
- this.emit('chunk', { type: 'text', text: block.text });
309
- } else if (block.type === 'thinking' && block.thinking) {
310
- this.emit('chunk', { type: 'thinking', text: block.thinking });
311
- } else if (block.type === 'tool_use') {
312
- this.emit('chunk', {
313
- type: 'tool-use',
314
- id: block.id,
315
- name: block.name,
316
- input: block.input || {},
317
- });
318
- }
319
- }
320
- }
321
-
322
- /** `user` events carry tool_result blocks match them back to a tool card. */
323
- _handleToolResults(message) {
324
- const content = message?.content;
325
- if (!Array.isArray(content)) return;
326
- for (const block of content) {
327
- if (block.type === 'tool_result') {
328
- this.emit('chunk', {
329
- type: 'tool-result',
330
- id: block.tool_use_id,
331
- content: flattenContent(block.content),
332
- isError: block.is_error === true,
333
- });
334
- }
335
- }
336
- }
337
-
338
- /** The final `result` event — authoritative cost + usage for the turn. */
339
- _handleResult(evt) {
340
- const u = evt.usage || {};
341
- this.emit('chunk', {
342
- type: 'usage',
343
- usage: {
344
- input_tokens: u.input_tokens || 0,
345
- output_tokens: u.output_tokens || 0,
346
- cache_read_input_tokens: u.cache_read_input_tokens || 0,
347
- // cost_usd lives here so the ActivityBus can accumulate it directly.
348
- cost_usd: typeof evt.total_cost_usd === 'number' ? evt.total_cost_usd : 0,
349
- },
350
- durationMs: evt.duration_ms,
351
- numTurns: evt.num_turns,
352
- });
353
- if (evt.is_error || (evt.subtype && evt.subtype !== 'success')) {
354
- this.emit('chunk', {
355
- type: 'error',
356
- message:
357
- (typeof evt.result === 'string' && evt.result) ||
358
- evt.subtype ||
359
- 'agent run failed',
360
- });
361
- }
362
- }
363
-
364
- cancel() {
365
- if (this.proc && !this.proc.killed) {
366
- try {
367
- this.proc.kill();
368
- } catch {}
369
- }
370
- }
371
-
372
- close() {
373
- this.closed = true;
374
- this.cancel();
375
- }
376
- }
377
-
378
- export function pickDefaultAgent(detected) {
379
- // Prefer claude; fall back to first available; fall back to claude (assume
380
- // it'll be installed — the UI shows an install hint when it isn't).
381
- const claude = detected.find((a) => a.id === 'claude' && a.available);
382
- if (claude) return claude;
383
- const anyAvailable = detected.find((a) => a.available);
384
- if (anyAvailable) return anyAvailable;
385
- return detected[0] || DEFAULT_AGENTS[0];
386
- }
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 os from 'node:os';
23
+ import fs from 'node:fs';
24
+ import { DEFAULT_AGENTS } from './config.mjs';
25
+
26
+ const execFile = promisify(execFileCb);
27
+
28
+ const PATH_LOOKUP_TIMEOUT_MS = 1500;
29
+
30
+ // Where `claude` (and other user-installed CLIs) actually live on macOS/Linux.
31
+ // The native installer drops it in ~/.local/bin; Homebrew uses /opt/homebrew/bin
32
+ // (Apple Silicon) or /usr/local/bin; our no-sudo npm prefix is ~/.npm-global/bin.
33
+ function toolDirs(home = os.homedir()) {
34
+ return [`${home}/.local/bin`, '/opt/homebrew/bin', '/usr/local/bin', `${home}/.npm-global/bin`];
35
+ }
36
+
37
+ // GUI / launchd-launched processes inherit a MINIMAL PATH that omits all of the
38
+ // above and never reads ~/.zshrc — so a server started by the macOS always-on
39
+ // LaunchAgent can't find `claude` (spawn ENOENT). Idempotently add the real tool
40
+ // dirs so detection + spawn work regardless of how the process was launched.
41
+ export function ensureToolPath(env = process.env, { platform = process.platform, home = os.homedir() } = {}) {
42
+ if (platform === 'win32') return env.PATH; // HKCU\Run inherits the full user PATH
43
+ const have = (env.PATH || '').split(':').filter(Boolean);
44
+ const add = toolDirs(home).filter((d) => !have.includes(d));
45
+ if (add.length) env.PATH = [...have, ...add].join(':');
46
+ return env.PATH;
47
+ }
48
+
49
+ async function isOnPath(binary) {
50
+ ensureToolPath(); // make sure the tool dirs are on PATH before we look
51
+ const probe = process.platform === 'win32' ? 'where.exe' : 'which';
52
+ try {
53
+ const { stdout } = await execFile(probe, [binary], { timeout: PATH_LOOKUP_TIMEOUT_MS });
54
+ const lines = stdout.split(/\r?\n/).filter(Boolean);
55
+ if (lines.length > 0) return lines[0].trim();
56
+ } catch { /* fall through to a direct probe */ }
57
+ // which/where can still miss a freshly-installed binary in a stripped launchd
58
+ // environment — probe the known install dirs directly and return an ABSOLUTE
59
+ // path, which spawn uses verbatim (no PATH needed at spawn time).
60
+ if (process.platform !== 'win32') {
61
+ for (const dir of toolDirs()) {
62
+ const candidate = `${dir}/${binary}`;
63
+ try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* next */ }
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ export async function detectAgents(candidates = DEFAULT_AGENTS) {
70
+ const results = await Promise.all(
71
+ candidates.map(async (agent) => {
72
+ const resolved = await isOnPath(agent.binary);
73
+ return { ...agent, available: Boolean(resolved), resolvedPath: resolved };
74
+ }),
75
+ );
76
+ return results;
77
+ }
78
+
79
+ /** Flatten a tool_result `content` field (string | array of blocks) to text. */
80
+ function flattenContent(content) {
81
+ if (typeof content === 'string') return content;
82
+ if (Array.isArray(content)) {
83
+ return content
84
+ .map((c) => (typeof c === 'string' ? c : c?.text || ''))
85
+ .join('');
86
+ }
87
+ return '';
88
+ }
89
+
90
+ /**
91
+ * Build the argv for one `claude -p` turn. Extracted from `send()` so the
92
+ * resume / mode / add-dir logic is unit-testable without spawning a process.
93
+ *
94
+ * - `resumeSessionId` : carries the conversation forward. Claude Code
95
+ * rehydrates the prior turns from this id, so the agent remembers what
96
+ * was already said. Absent on the first turn of a conversation.
97
+ * - `mode` : 'plan' is read-only proposing; anything else is 'build', which
98
+ * can create/edit files + run commands (PRD C3 posture — the user's own
99
+ * machine, own agent, own API bill).
100
+ */
101
+ export function buildClaudeArgs(baseArgs, ctx = {}) {
102
+ const args = [...baseArgs];
103
+ if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId);
104
+ if (ctx.cwd) args.push('--add-dir', ctx.cwd);
105
+ // Bazaar: expose the marketplace tools to the agent + set the disposition to
106
+ // consult the shelf. --strict-mcp-config isolates to the bazaar server (the
107
+ // built-in Read/Write/Bash tools are unaffected), keeping the turn fast and
108
+ // deterministic without spawning the user's other MCP servers.
109
+ if (ctx.mcpConfigPath) {
110
+ args.push('--mcp-config', ctx.mcpConfigPath);
111
+ if (ctx.strictMcp) args.push('--strict-mcp-config');
112
+ }
113
+ if (ctx.appendSystemPrompt) args.push('--append-system-prompt', ctx.appendSystemPrompt);
114
+ args.push('--permission-mode', ctx.mode === 'plan' ? 'plan' : 'bypassPermissions');
115
+ return args;
116
+ }
117
+
118
+ // Bazaar MCP tools surface to claude as mcp__bazaar__<name>. We render these as
119
+ // rich first-class cards in the chat (not the generic tool card), so detect them.
120
+ const BAZAAR_PREFIX = 'mcp__bazaar__';
121
+ export function bazaarToolName(name) {
122
+ return typeof name === 'string' && name.startsWith(BAZAAR_PREFIX)
123
+ ? name.slice(BAZAAR_PREFIX.length)
124
+ : null;
125
+ }
126
+
127
+ // Canvas MCP tools surface as mcp__canvas__<name> (make_block/update_block/…). We
128
+ // detect them so a make_block result can mount the block on the user's canvas
129
+ // (and render a small confirmation), instead of a generic gear tool card.
130
+ const CANVAS_PREFIX = 'mcp__canvas__';
131
+ export function canvasToolName(name) {
132
+ return typeof name === 'string' && name.startsWith(CANVAS_PREFIX)
133
+ ? name.slice(CANVAS_PREFIX.length)
134
+ : null;
135
+ }
136
+
137
+ /**
138
+ * AgentSession streams one chat turn through a wrapped subprocess.
139
+ *
140
+ * Lifecycle per AR-17:
141
+ * - one process per user turn (`claude -p` prints + exits)
142
+ * - emits 'chunk' — a normalized event the UI understands (see below)
143
+ * - emits 'stderr' on stderr output
144
+ * - emits 'end' on process exit with code
145
+ * - emits 'error' on spawn / runtime error
146
+ *
147
+ * Chunk protocol (the only contract the browser depends on):
148
+ * { type:'text', text } streamed assistant text
149
+ * { type:'thinking', text } streamed extended-thinking
150
+ * { type:'tool-use', id, name, input } a completed tool call
151
+ * { type:'tool-result', id, content, isError } that tool's result
152
+ * { type:'usage', usage:{...,cost_usd}, ... } final cost + token usage
153
+ * { type:'error', message } a run-level failure
154
+ */
155
+ export class AgentSession extends EventEmitter {
156
+ constructor(agent, opts = {}) {
157
+ super();
158
+ this.agent = agent;
159
+ this.opts = opts;
160
+ this.proc = null;
161
+ this.closed = false;
162
+ // The claude session id seen on this turn. The server reads it after the
163
+ // turn ends and passes it to the next turn as --resume, so the agent
164
+ // actually remembers the conversation.
165
+ this.sessionId = null;
166
+ // stream-json parse state fresh per session (one session per turn)
167
+ this._buffer = '';
168
+ this._sawStreamEvent = false;
169
+ this._blocks = new Map(); // content-block index -> { kind, id, name, jsonBuf }
170
+ this._toolNames = new Map(); // tool_use_id -> tool name (to match results back)
171
+ this._suppressed = new Set(); // tool_use_ids whose card we hide (internal plumbing)
172
+ }
173
+
174
+ send(prompt, ctx = {}) {
175
+ if (this.closed) throw new Error('session closed');
176
+ // claude gets the full resume/mode/add-dir treatment; other agents (and
177
+ // the test echo agent) run with their bare args.
178
+ const args =
179
+ this.agent.id === 'claude'
180
+ ? buildClaudeArgs(this.agent.args, ctx)
181
+ : [...this.agent.args];
182
+ // Prefer the resolved absolute path from detection; fall back to the bare
183
+ // name. claude ships a native binary, so shell:false spawns cleanly.
184
+ const command = this.agent.resolvedPath || this.agent.binary;
185
+ this.proc = spawn(command, args, {
186
+ cwd: ctx.cwd || this.opts.cwd || process.cwd(),
187
+ env: { ...process.env, ...(ctx.env || {}) },
188
+ shell: false,
189
+ stdio: ['pipe', 'pipe', 'pipe'],
190
+ windowsHide: true,
191
+ });
192
+
193
+ this.proc.stdout.setEncoding('utf8');
194
+ this.proc.stderr.setEncoding('utf8');
195
+
196
+ const streamFormat = this.agent.streamFormat || 'text';
197
+
198
+ this.proc.stdout.on('data', (chunk) => this._handleStdout(chunk, streamFormat));
199
+ this.proc.stderr.on('data', (chunk) => this.emit('stderr', chunk));
200
+ this.proc.on('error', (err) => this.emit('error', err));
201
+ this.proc.on('close', (code) => {
202
+ this.emit('end', { code });
203
+ this.proc = null;
204
+ });
205
+
206
+ try {
207
+ this.proc.stdin.write(prompt);
208
+ this.proc.stdin.end();
209
+ } catch (e) {
210
+ this.emit('error', e);
211
+ }
212
+ }
213
+
214
+ _handleStdout(chunk, streamFormat) {
215
+ if (streamFormat !== 'claude-stream-json') {
216
+ // Plain-text agents (and the test echo agent) stream straight through.
217
+ this.emit('chunk', { type: 'text', text: chunk });
218
+ return;
219
+ }
220
+ this._buffer += chunk;
221
+ const lines = this._buffer.split('\n');
222
+ this._buffer = lines.pop() || '';
223
+ for (const line of lines) {
224
+ const trimmed = line.trim();
225
+ if (!trimmed) continue;
226
+ let evt;
227
+ try {
228
+ evt = JSON.parse(trimmed);
229
+ } catch {
230
+ // Non-JSON noise on stdout (rare). Drop it — don't pollute the chat.
231
+ continue;
232
+ }
233
+ this._handleClaudeEvent(evt);
234
+ }
235
+ }
236
+
237
+ _handleClaudeEvent(evt) {
238
+ if (!evt || typeof evt !== 'object') return;
239
+ // Every claude stream-json line carries the session id (the `system` init
240
+ // line and the final `result` both do). Capturing it lets the next turn
241
+ // --resume this exact conversation.
242
+ if (typeof evt.session_id === 'string') this.sessionId = evt.session_id;
243
+ switch (evt.type) {
244
+ case 'stream_event':
245
+ this._sawStreamEvent = true;
246
+ this._handleStreamEvent(evt.event);
247
+ return;
248
+ case 'assistant':
249
+ // Redundant with stream_event when --include-partial-messages is on
250
+ // (it always is). Only the fallback path when partials are disabled.
251
+ if (!this._sawStreamEvent) this._handleAssistantFallback(evt.message);
252
+ return;
253
+ case 'user':
254
+ this._handleToolResults(evt.message);
255
+ return;
256
+ case 'result':
257
+ this._handleResult(evt);
258
+ return;
259
+ case 'error':
260
+ this.emit('chunk', {
261
+ type: 'error',
262
+ message: evt.message || evt.error?.message || 'agent error',
263
+ });
264
+ return;
265
+ default:
266
+ // system, rate_limit_event, anything new ignored.
267
+ return;
268
+ }
269
+ }
270
+
271
+ /** The wrapped Anthropic streaming protocol — our real-time source. */
272
+ _handleStreamEvent(ev) {
273
+ if (!ev || typeof ev !== 'object') return;
274
+ switch (ev.type) {
275
+ case 'content_block_start': {
276
+ const cb = ev.content_block || {};
277
+ if (cb.type === 'tool_use') {
278
+ // Tool input arrives as input_json_delta fragments buffer them
279
+ // until content_block_stop, then emit one complete tool-use chunk.
280
+ this._blocks.set(ev.index, {
281
+ kind: 'tool',
282
+ id: cb.id,
283
+ name: cb.name,
284
+ jsonBuf: '',
285
+ });
286
+ if (cb.id) this._toolNames.set(cb.id, cb.name); // match the result later
287
+ } else {
288
+ this._blocks.set(ev.index, { kind: cb.type || 'text' });
289
+ }
290
+ return;
291
+ }
292
+ case 'content_block_delta': {
293
+ const d = ev.delta || {};
294
+ if (d.type === 'text_delta' && d.text) {
295
+ this.emit('chunk', { type: 'text', text: d.text });
296
+ } else if (d.type === 'thinking_delta' && d.thinking) {
297
+ this.emit('chunk', { type: 'thinking', text: d.thinking });
298
+ } else if (d.type === 'input_json_delta') {
299
+ const blk = this._blocks.get(ev.index);
300
+ if (blk && blk.kind === 'tool') blk.jsonBuf += d.partial_json || '';
301
+ }
302
+ return;
303
+ }
304
+ case 'content_block_stop': {
305
+ const blk = this._blocks.get(ev.index);
306
+ if (blk && blk.kind === 'tool') {
307
+ let input = {};
308
+ try {
309
+ input = blk.jsonBuf ? JSON.parse(blk.jsonBuf) : {};
310
+ } catch {
311
+ input = { _raw: blk.jsonBuf };
312
+ }
313
+ const bz = bazaarToolName(blk.name);
314
+ const cv = canvasToolName(blk.name);
315
+ if (bz) {
316
+ // Render the "reach onto the shelf" beat as a first-class bazaar card
317
+ // in its calling state, instead of a generic gear tool card.
318
+ this.emit('chunk', { type: 'bazaar', phase: 'call', tool: bz, id: blk.id, input });
319
+ } else if (cv) {
320
+ // The agent building a block — surfaced as a small canvas card; the
321
+ // result chunk carries the spec the UI mounts onto the canvas.
322
+ this.emit('chunk', { type: 'canvas', phase: 'call', tool: cv, id: blk.id, input });
323
+ } else if (blk.name === 'ToolSearch' && /mcp__(bazaar|canvas)__/.test(JSON.stringify(input))) {
324
+ // Claude Code defers MCP tools and loads them via ToolSearch — internal
325
+ // plumbing the user should never see. The bazaar/canvas cards show the work.
326
+ if (blk.id) this._suppressed.add(blk.id);
327
+ } else {
328
+ this.emit('chunk', { type: 'tool-use', id: blk.id, name: blk.name, input });
329
+ }
330
+ }
331
+ this._blocks.delete(ev.index);
332
+ return;
333
+ }
334
+ default:
335
+ // message_start / message_delta / message_stop — no UI effect.
336
+ return;
337
+ }
338
+ }
339
+
340
+ /** Fallback for `--include-partial-messages` off: parse the whole message. */
341
+ _handleAssistantFallback(message) {
342
+ const content = message?.content;
343
+ if (typeof content === 'string') {
344
+ if (content) this.emit('chunk', { type: 'text', text: content });
345
+ return;
346
+ }
347
+ if (!Array.isArray(content)) return;
348
+ for (const block of content) {
349
+ if (block.type === 'text' && block.text) {
350
+ this.emit('chunk', { type: 'text', text: block.text });
351
+ } else if (block.type === 'thinking' && block.thinking) {
352
+ this.emit('chunk', { type: 'thinking', text: block.thinking });
353
+ } else if (block.type === 'tool_use') {
354
+ this.emit('chunk', {
355
+ type: 'tool-use',
356
+ id: block.id,
357
+ name: block.name,
358
+ input: block.input || {},
359
+ });
360
+ }
361
+ }
362
+ }
363
+
364
+ /** `user` events carry tool_result blocks — match them back to a tool card. */
365
+ _handleToolResults(message) {
366
+ const content = message?.content;
367
+ if (!Array.isArray(content)) return;
368
+ for (const block of content) {
369
+ if (block.type === 'tool_result') {
370
+ if (this._suppressed.has(block.tool_use_id)) continue; // hidden plumbing
371
+ const flat = flattenContent(block.content);
372
+ const toolName = this._toolNames.get(block.tool_use_id);
373
+ const bz = bazaarToolName(toolName);
374
+ const cv = canvasToolName(toolName);
375
+ if (bz || cv) {
376
+ // Bazaar/canvas tool results carry a JSON envelope: parse it for the UI.
377
+ // (The agent still receives the full result through claude's own loop —
378
+ // this only changes how the browser renders it / mounts the block.)
379
+ let data = null;
380
+ try {
381
+ data = JSON.parse(flat);
382
+ } catch {
383
+ data = { kind: 'raw', text: flat };
384
+ }
385
+ this.emit('chunk', {
386
+ type: bz ? 'bazaar' : 'canvas',
387
+ phase: 'result',
388
+ tool: bz || cv,
389
+ id: block.tool_use_id,
390
+ data,
391
+ isError: block.is_error === true,
392
+ });
393
+ } else {
394
+ this.emit('chunk', {
395
+ type: 'tool-result',
396
+ id: block.tool_use_id,
397
+ content: flat,
398
+ isError: block.is_error === true,
399
+ });
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ /** The final `result` event — authoritative cost + usage for the turn. */
406
+ _handleResult(evt) {
407
+ const u = evt.usage || {};
408
+ this.emit('chunk', {
409
+ type: 'usage',
410
+ usage: {
411
+ input_tokens: u.input_tokens || 0,
412
+ output_tokens: u.output_tokens || 0,
413
+ cache_read_input_tokens: u.cache_read_input_tokens || 0,
414
+ // cost_usd lives here so the ActivityBus can accumulate it directly.
415
+ cost_usd: typeof evt.total_cost_usd === 'number' ? evt.total_cost_usd : 0,
416
+ },
417
+ durationMs: evt.duration_ms,
418
+ numTurns: evt.num_turns,
419
+ });
420
+ if (evt.is_error || (evt.subtype && evt.subtype !== 'success')) {
421
+ this.emit('chunk', {
422
+ type: 'error',
423
+ message:
424
+ (typeof evt.result === 'string' && evt.result) ||
425
+ evt.subtype ||
426
+ 'agent run failed',
427
+ });
428
+ }
429
+ }
430
+
431
+ cancel() {
432
+ if (this.proc && !this.proc.killed) {
433
+ try {
434
+ this.proc.kill();
435
+ } catch {}
436
+ }
437
+ }
438
+
439
+ close() {
440
+ this.closed = true;
441
+ this.cancel();
442
+ }
443
+ }
444
+
445
+ export function pickDefaultAgent(detected) {
446
+ // Prefer claude; fall back to first available; fall back to claude (assume
447
+ // it'll be installed — the UI shows an install hint when it isn't).
448
+ const claude = detected.find((a) => a.id === 'claude' && a.available);
449
+ if (claude) return claude;
450
+ const anyAvailable = detected.find((a) => a.available);
451
+ if (anyAvailable) return anyAvailable;
452
+ return detected[0] || DEFAULT_AGENTS[0];
453
+ }