@venturewild/workspace 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VentureWild
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # wild-workspace — Claude Code Web
2
+
3
+ A Replit/Lovable-style **chat-first** browser UI that wraps the AI agent already installed on your machine (Claude Code by default; Gemini / GLM / Codex if present).
4
+
5
+ > v0.1.0 — initial scaffold, implements PRD §5.5 (workspace-platform-prd.md v0.10).
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ # install agent dependencies & web build
11
+ npm install
12
+ npm run build
13
+
14
+ # launch
15
+ node server/src/index.mjs
16
+ # or after `npm install -g .`
17
+ wild-workspace
18
+ ```
19
+
20
+ Opens `http://localhost:5173` in your default browser.
21
+
22
+ ## What's in here
23
+
24
+ ```
25
+ wild-workspace/
26
+ ├── server/ # Node.js Hono server + WebSocket bridge
27
+ │ ├── bin/
28
+ │ │ └── wild-workspace.mjs # CLI entry (the `bin` field)
29
+ │ └── src/
30
+ │ ├── index.mjs # server bootstrap
31
+ │ ├── config.mjs # config + role definitions
32
+ │ ├── agent.mjs # claude / gemini / glm / codex subprocess wrapper
33
+ │ ├── share.mjs # JWT share-token mint + verify
34
+ │ ├── inbox.mjs # .wild/inbox.md watcher
35
+ │ ├── fs.mjs # workspace file tree (collapsed by default)
36
+ │ ├── activity.mjs # AI activity event bus
37
+ │ ├── preview.mjs # dev-server port detection
38
+ │ └── routes/ # REST + WS endpoints
39
+ ├── web/ # React + Vite frontend
40
+ │ └── src/
41
+ │ ├── App.jsx # role-flagged React tree (partner / viewer / client)
42
+ │ ├── components/ # Chat, Preview, FileTree, Terminal, ShareDialog…
43
+ │ └── state/ # session + chat stores
44
+ └── docs/ # design notes
45
+ ```
46
+
47
+ ## The three roles (AR-19)
48
+
49
+ | Role | URL pattern | What they see |
50
+ |---|---|---|
51
+ | **partner** | `http://localhost:5173` | Chat + preview + file tree + terminal toggle + inbox + share + deploy |
52
+ | **viewer** | `https://workspace.venturewild.llc/<wsid>?t=<token>` | Chat history (read-only) + preview + presence + activity stream |
53
+ | **client** | `https://workspace.<client>.com` | Chat + preview + "request changes" only |
54
+
55
+ Same React tree, role-gated visibility (AR-19).
56
+
57
+ ## AR-17: wrap don't embed
58
+
59
+ We don't ship an AI agent. `server/src/agent.mjs` spawns `claude` (or `gemini` / `glm` / `codex` if installed) as a subprocess and pipes stdout/stderr through WebSocket to the chat UI. The wrapped agent's modes mirror automatically (AR-18).
60
+
61
+ ## Rich chat rendering
62
+
63
+ The chat is the product (AR-16), so it renders like one:
64
+
65
+ - **Markdown** — agent replies render as GitHub-flavored markdown (`Markdown.jsx`).
66
+ - **Syntax-highlighted code** — fenced code blocks via prism-react-renderer, with a copy button.
67
+ - **Tool cards** — each tool call (`Read`, `Edit`, `Bash`, …) renders as a compact card with a running/done/error status (`ToolCard.jsx`).
68
+ - **Inline diffs** — `Edit` / `Write` / `MultiEdit` show a red/green line diff right in the chat (`DiffView.jsx`).
69
+ - **Live streaming** — text streams token-by-token from the rebuilt `agent.mjs` stream-json parser, with a per-turn cost + token footer.
70
+
71
+ ## License
72
+
73
+ MIT — VentureWild.
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@venturewild/workspace",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "wild-workspace": "./server/bin/wild-workspace.mjs"
8
+ },
9
+ "files": [
10
+ "server/bin",
11
+ "server/src",
12
+ "web/dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "scripts": {
20
+ "dev": "concurrently -k -n server,web -c blue,magenta \"npm:dev:server\" \"npm:dev:web\"",
21
+ "dev:server": "node --watch server/src/index.mjs",
22
+ "dev:web": "vite --config web/vite.config.mjs",
23
+ "build": "npm run build:web",
24
+ "build:web": "vite build --config web/vite.config.mjs",
25
+ "prepublishOnly": "npm run build",
26
+ "start": "node server/src/index.mjs",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "lint": "eslint . --ext .mjs,.js,.jsx || echo 'skipping (eslint not configured)'"
30
+ },
31
+ "dependencies": {
32
+ "@hono/node-server": "^1.13.0",
33
+ "chokidar": "^4.0.0",
34
+ "hono": "^4.6.0",
35
+ "jose": "^5.9.0",
36
+ "mime-types": "^2.1.0",
37
+ "nanoid": "^5.0.0",
38
+ "open": "^10.1.0",
39
+ "ws": "^8.18.0"
40
+ },
41
+ "devDependencies": {
42
+ "@vitejs/plugin-react": "^4.3.0",
43
+ "concurrently": "^9.0.0",
44
+ "diff": "^9.0.0",
45
+ "playwright": "^1.48.0",
46
+ "prism-react-renderer": "^2.4.1",
47
+ "react": "^18.3.0",
48
+ "react-dom": "^18.3.0",
49
+ "react-markdown": "^10.1.0",
50
+ "remark-gfm": "^4.0.1",
51
+ "supertest": "^7.0.0",
52
+ "vite": "^5.4.0",
53
+ "vitest": "^2.1.0"
54
+ },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "https://github.com/chunin1103/wild-workspace"
58
+ },
59
+ "keywords": [
60
+ "ai",
61
+ "claude",
62
+ "claude-code",
63
+ "workspace",
64
+ "replit",
65
+ "lovable",
66
+ "wild",
67
+ "venturewild"
68
+ ]
69
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // `wild-workspace` CLI entry — the bin field in package.json.
3
+ // Forwards to server/src/index.mjs.
4
+
5
+ import path from 'node:path';
6
+ import url from 'node:url';
7
+ import { createServer } from '../src/index.mjs';
8
+ import { APP_VERSION } from '../src/config.mjs';
9
+
10
+ const __filename = url.fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ function printUsage() {
14
+ console.log(`wild-workspace v${APP_VERSION}
15
+
16
+ Usage:
17
+ wild-workspace start the workspace server in the current directory
18
+ wild-workspace --port 5173 override port (default 5173)
19
+ wild-workspace --no-open don't auto-open browser
20
+ wild-workspace --host 0.0.0.0 bind to all interfaces (for share-by-URL hosting)
21
+ wild-workspace install register the bmo-sync daemon as a background service [v1.x]
22
+ wild-workspace share (interactive helper) issue a viewer URL [v1.x — use UI for now]
23
+ wild-workspace --help this message
24
+ wild-workspace --version print version
25
+
26
+ Environment:
27
+ WILD_WORKSPACE_PORT, WILD_WORKSPACE_HOST,
28
+ WILD_WORKSPACE_DIR, WILD_WORKSPACE_DATA_DIR,
29
+ WILD_WORKSPACE_PARTNER_TOKEN, WILD_WORKSPACE_SHARE_SECRET,
30
+ WILD_WORKSPACE_NO_OPEN=1
31
+ `);
32
+ }
33
+
34
+ function parseArgs(argv) {
35
+ const opts = {};
36
+ const positional = [];
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const arg = argv[i];
39
+ if (arg === '--help' || arg === '-h') opts.help = true;
40
+ else if (arg === '--version' || arg === '-v') opts.version = true;
41
+ else if (arg === '--no-open') opts.openBrowser = false;
42
+ else if (arg === '--port') { opts.port = Number(argv[++i]); }
43
+ else if (arg === '--host') { opts.host = argv[++i]; }
44
+ else if (arg === '--workspace') { opts.workspaceDir = argv[++i]; }
45
+ else if (arg.startsWith('--')) {
46
+ // ignore unknown flags
47
+ } else {
48
+ positional.push(arg);
49
+ }
50
+ }
51
+ opts.positional = positional;
52
+ return opts;
53
+ }
54
+
55
+ async function main() {
56
+ const opts = parseArgs(process.argv.slice(2));
57
+ if (opts.help) return printUsage();
58
+ if (opts.version) { console.log(APP_VERSION); return; }
59
+
60
+ if (opts.positional[0] === 'install') {
61
+ console.log('`wild-workspace install` is a v1.x feature (bmo-sync daemon).');
62
+ console.log('For now: run `wild-workspace` directly; sync is via OneDrive/bmo-sync separately.');
63
+ return;
64
+ }
65
+ if (opts.positional[0] === 'share') {
66
+ console.log('Use the in-app Share button to issue viewer URLs. CLI share command is v1.x.');
67
+ return;
68
+ }
69
+
70
+ const server = await createServer(opts);
71
+ const { config } = server;
72
+ console.log(`\n wild-workspace v${APP_VERSION}`);
73
+ console.log(` workspace : ${config.workspaceDir}`);
74
+ console.log(` url : http://${config.host}:${config.port}`);
75
+ console.log(` agent : ${server.getActiveAgent()?.label || '(none detected — install Claude Code: npm i -g @anthropic-ai/claude-code)'}\n`);
76
+
77
+ if (config.openBrowser) {
78
+ try {
79
+ const open = (await import('open')).default;
80
+ open(`http://${config.host}:${config.port}`);
81
+ } catch {}
82
+ }
83
+
84
+ process.on('SIGINT', async () => {
85
+ console.log('\nshutting down…');
86
+ await server.stop();
87
+ process.exit(0);
88
+ });
89
+ process.on('SIGTERM', async () => { await server.stop(); process.exit(0); });
90
+ }
91
+
92
+ main().catch((err) => {
93
+ console.error('wild-workspace failed:', err);
94
+ process.exit(1);
95
+ });
@@ -0,0 +1,71 @@
1
+ // Activity event bus — live AI activity stream + presence (real-time).
2
+ // All paired clients see the same activity. Implemented as in-process pub/sub for v1.
3
+ // v1.1 wires the same channel to bmo-sync's SSE event stream.
4
+
5
+ import { EventEmitter } from 'node:events';
6
+ import { nanoid } from 'nanoid';
7
+
8
+ export class ActivityBus extends EventEmitter {
9
+ constructor() {
10
+ super();
11
+ this.setMaxListeners(0);
12
+ this.recent = [];
13
+ this.maxRecent = 200;
14
+ this.presence = new Map(); // sessionId -> { sessionId, role, label, focus, lastSeen }
15
+ this.usage = { tokensIn: 0, tokensOut: 0, costUsd: 0 };
16
+ }
17
+
18
+ publish(event) {
19
+ const enriched = {
20
+ id: nanoid(10),
21
+ ts: Date.now(),
22
+ ...event,
23
+ };
24
+ this.recent.push(enriched);
25
+ if (this.recent.length > this.maxRecent) this.recent.shift();
26
+ if (event.type === 'usage' && event.usage) {
27
+ this.usage.tokensIn += event.usage.input_tokens || 0;
28
+ this.usage.tokensOut += event.usage.output_tokens || 0;
29
+ if (typeof event.usage.cost_usd === 'number') {
30
+ this.usage.costUsd += event.usage.cost_usd;
31
+ }
32
+ }
33
+ this.emit('event', enriched);
34
+ }
35
+
36
+ joinPresence({ sessionId, role, label }) {
37
+ const entry = {
38
+ sessionId: sessionId || nanoid(8),
39
+ role: role || 'partner',
40
+ label: label || role || 'partner',
41
+ focus: null,
42
+ lastSeen: Date.now(),
43
+ };
44
+ this.presence.set(entry.sessionId, entry);
45
+ this.publish({ type: 'presence-join', sessionId: entry.sessionId, role: entry.role });
46
+ return entry;
47
+ }
48
+
49
+ leavePresence(sessionId) {
50
+ if (this.presence.has(sessionId)) {
51
+ this.presence.delete(sessionId);
52
+ this.publish({ type: 'presence-leave', sessionId });
53
+ }
54
+ }
55
+
56
+ updateFocus(sessionId, focus) {
57
+ const entry = this.presence.get(sessionId);
58
+ if (!entry) return;
59
+ entry.focus = focus;
60
+ entry.lastSeen = Date.now();
61
+ this.publish({ type: 'presence-focus', sessionId, focus });
62
+ }
63
+
64
+ snapshot() {
65
+ return {
66
+ recent: this.recent.slice(-50),
67
+ presence: [...this.presence.values()],
68
+ usage: { ...this.usage },
69
+ };
70
+ }
71
+ }
@@ -0,0 +1,335 @@
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
+ * AgentSession streams one chat turn through a wrapped subprocess.
62
+ *
63
+ * Lifecycle per AR-17:
64
+ * - one process per user turn (`claude -p` prints + exits)
65
+ * - emits 'chunk' — a normalized event the UI understands (see below)
66
+ * - emits 'stderr' on stderr output
67
+ * - emits 'end' on process exit with code
68
+ * - emits 'error' on spawn / runtime error
69
+ *
70
+ * Chunk protocol (the only contract the browser depends on):
71
+ * { type:'text', text } streamed assistant text
72
+ * { type:'thinking', text } streamed extended-thinking
73
+ * { type:'tool-use', id, name, input } a completed tool call
74
+ * { type:'tool-result', id, content, isError } that tool's result
75
+ * { type:'usage', usage:{...,cost_usd}, ... } final cost + token usage
76
+ * { type:'error', message } a run-level failure
77
+ */
78
+ export class AgentSession extends EventEmitter {
79
+ constructor(agent, opts = {}) {
80
+ super();
81
+ this.agent = agent;
82
+ this.opts = opts;
83
+ this.proc = null;
84
+ this.closed = false;
85
+ // stream-json parse state — fresh per session (one session per turn)
86
+ this._buffer = '';
87
+ this._sawStreamEvent = false;
88
+ this._blocks = new Map(); // content-block index -> { kind, id, name, jsonBuf }
89
+ }
90
+
91
+ send(prompt, ctx = {}) {
92
+ if (this.closed) throw new Error('session closed');
93
+ const args = [...this.agent.args];
94
+ if (this.agent.id === 'claude') {
95
+ if (ctx.cwd) args.push('--add-dir', ctx.cwd);
96
+ // Mode -> permission posture.
97
+ // plan : read-only planning, the agent proposes but doesn't act.
98
+ // build : the agent must be able to create/edit files and run commands,
99
+ // or "chat to build something" (PRD G1) is a dead demo. The
100
+ // workspace is the user's own machine, own agent, own API bill
101
+ // — the same posture as the PRD's C3 "agent runs with the
102
+ // user's permissions" note. This is the Replit/Lovable model.
103
+ args.push('--permission-mode', ctx.mode === 'plan' ? 'plan' : 'bypassPermissions');
104
+ }
105
+ // Prefer the resolved absolute path from detection; fall back to the bare
106
+ // name. claude ships a native binary, so shell:false spawns cleanly.
107
+ const command = this.agent.resolvedPath || this.agent.binary;
108
+ this.proc = spawn(command, args, {
109
+ cwd: ctx.cwd || this.opts.cwd || process.cwd(),
110
+ env: { ...process.env, ...(ctx.env || {}) },
111
+ shell: false,
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ windowsHide: true,
114
+ });
115
+
116
+ this.proc.stdout.setEncoding('utf8');
117
+ this.proc.stderr.setEncoding('utf8');
118
+
119
+ const streamFormat = this.agent.streamFormat || 'text';
120
+
121
+ this.proc.stdout.on('data', (chunk) => this._handleStdout(chunk, streamFormat));
122
+ this.proc.stderr.on('data', (chunk) => this.emit('stderr', chunk));
123
+ this.proc.on('error', (err) => this.emit('error', err));
124
+ this.proc.on('close', (code) => {
125
+ this.emit('end', { code });
126
+ this.proc = null;
127
+ });
128
+
129
+ try {
130
+ this.proc.stdin.write(prompt);
131
+ this.proc.stdin.end();
132
+ } catch (e) {
133
+ this.emit('error', e);
134
+ }
135
+ }
136
+
137
+ _handleStdout(chunk, streamFormat) {
138
+ if (streamFormat !== 'claude-stream-json') {
139
+ // Plain-text agents (and the test echo agent) stream straight through.
140
+ this.emit('chunk', { type: 'text', text: chunk });
141
+ return;
142
+ }
143
+ this._buffer += chunk;
144
+ const lines = this._buffer.split('\n');
145
+ this._buffer = lines.pop() || '';
146
+ for (const line of lines) {
147
+ const trimmed = line.trim();
148
+ if (!trimmed) continue;
149
+ let evt;
150
+ try {
151
+ evt = JSON.parse(trimmed);
152
+ } catch {
153
+ // Non-JSON noise on stdout (rare). Drop it — don't pollute the chat.
154
+ continue;
155
+ }
156
+ this._handleClaudeEvent(evt);
157
+ }
158
+ }
159
+
160
+ _handleClaudeEvent(evt) {
161
+ if (!evt || typeof evt !== 'object') return;
162
+ switch (evt.type) {
163
+ case 'stream_event':
164
+ this._sawStreamEvent = true;
165
+ this._handleStreamEvent(evt.event);
166
+ return;
167
+ case 'assistant':
168
+ // Redundant with stream_event when --include-partial-messages is on
169
+ // (it always is). Only the fallback path when partials are disabled.
170
+ if (!this._sawStreamEvent) this._handleAssistantFallback(evt.message);
171
+ return;
172
+ case 'user':
173
+ this._handleToolResults(evt.message);
174
+ return;
175
+ case 'result':
176
+ this._handleResult(evt);
177
+ return;
178
+ case 'error':
179
+ this.emit('chunk', {
180
+ type: 'error',
181
+ message: evt.message || evt.error?.message || 'agent error',
182
+ });
183
+ return;
184
+ default:
185
+ // system, rate_limit_event, anything new — ignored.
186
+ return;
187
+ }
188
+ }
189
+
190
+ /** The wrapped Anthropic streaming protocol — our real-time source. */
191
+ _handleStreamEvent(ev) {
192
+ if (!ev || typeof ev !== 'object') return;
193
+ switch (ev.type) {
194
+ case 'content_block_start': {
195
+ const cb = ev.content_block || {};
196
+ if (cb.type === 'tool_use') {
197
+ // Tool input arrives as input_json_delta fragments — buffer them
198
+ // until content_block_stop, then emit one complete tool-use chunk.
199
+ this._blocks.set(ev.index, {
200
+ kind: 'tool',
201
+ id: cb.id,
202
+ name: cb.name,
203
+ jsonBuf: '',
204
+ });
205
+ } else {
206
+ this._blocks.set(ev.index, { kind: cb.type || 'text' });
207
+ }
208
+ return;
209
+ }
210
+ case 'content_block_delta': {
211
+ const d = ev.delta || {};
212
+ if (d.type === 'text_delta' && d.text) {
213
+ this.emit('chunk', { type: 'text', text: d.text });
214
+ } else if (d.type === 'thinking_delta' && d.thinking) {
215
+ this.emit('chunk', { type: 'thinking', text: d.thinking });
216
+ } else if (d.type === 'input_json_delta') {
217
+ const blk = this._blocks.get(ev.index);
218
+ if (blk && blk.kind === 'tool') blk.jsonBuf += d.partial_json || '';
219
+ }
220
+ return;
221
+ }
222
+ case 'content_block_stop': {
223
+ const blk = this._blocks.get(ev.index);
224
+ if (blk && blk.kind === 'tool') {
225
+ let input = {};
226
+ try {
227
+ input = blk.jsonBuf ? JSON.parse(blk.jsonBuf) : {};
228
+ } catch {
229
+ input = { _raw: blk.jsonBuf };
230
+ }
231
+ this.emit('chunk', {
232
+ type: 'tool-use',
233
+ id: blk.id,
234
+ name: blk.name,
235
+ input,
236
+ });
237
+ }
238
+ this._blocks.delete(ev.index);
239
+ return;
240
+ }
241
+ default:
242
+ // message_start / message_delta / message_stop — no UI effect.
243
+ return;
244
+ }
245
+ }
246
+
247
+ /** Fallback for `--include-partial-messages` off: parse the whole message. */
248
+ _handleAssistantFallback(message) {
249
+ const content = message?.content;
250
+ if (typeof content === 'string') {
251
+ if (content) this.emit('chunk', { type: 'text', text: content });
252
+ return;
253
+ }
254
+ if (!Array.isArray(content)) return;
255
+ for (const block of content) {
256
+ if (block.type === 'text' && block.text) {
257
+ this.emit('chunk', { type: 'text', text: block.text });
258
+ } else if (block.type === 'thinking' && block.thinking) {
259
+ this.emit('chunk', { type: 'thinking', text: block.thinking });
260
+ } else if (block.type === 'tool_use') {
261
+ this.emit('chunk', {
262
+ type: 'tool-use',
263
+ id: block.id,
264
+ name: block.name,
265
+ input: block.input || {},
266
+ });
267
+ }
268
+ }
269
+ }
270
+
271
+ /** `user` events carry tool_result blocks — match them back to a tool card. */
272
+ _handleToolResults(message) {
273
+ const content = message?.content;
274
+ if (!Array.isArray(content)) return;
275
+ for (const block of content) {
276
+ if (block.type === 'tool_result') {
277
+ this.emit('chunk', {
278
+ type: 'tool-result',
279
+ id: block.tool_use_id,
280
+ content: flattenContent(block.content),
281
+ isError: block.is_error === true,
282
+ });
283
+ }
284
+ }
285
+ }
286
+
287
+ /** The final `result` event — authoritative cost + usage for the turn. */
288
+ _handleResult(evt) {
289
+ const u = evt.usage || {};
290
+ this.emit('chunk', {
291
+ type: 'usage',
292
+ usage: {
293
+ input_tokens: u.input_tokens || 0,
294
+ output_tokens: u.output_tokens || 0,
295
+ cache_read_input_tokens: u.cache_read_input_tokens || 0,
296
+ // cost_usd lives here so the ActivityBus can accumulate it directly.
297
+ cost_usd: typeof evt.total_cost_usd === 'number' ? evt.total_cost_usd : 0,
298
+ },
299
+ durationMs: evt.duration_ms,
300
+ numTurns: evt.num_turns,
301
+ });
302
+ if (evt.is_error || (evt.subtype && evt.subtype !== 'success')) {
303
+ this.emit('chunk', {
304
+ type: 'error',
305
+ message:
306
+ (typeof evt.result === 'string' && evt.result) ||
307
+ evt.subtype ||
308
+ 'agent run failed',
309
+ });
310
+ }
311
+ }
312
+
313
+ cancel() {
314
+ if (this.proc && !this.proc.killed) {
315
+ try {
316
+ this.proc.kill();
317
+ } catch {}
318
+ }
319
+ }
320
+
321
+ close() {
322
+ this.closed = true;
323
+ this.cancel();
324
+ }
325
+ }
326
+
327
+ export function pickDefaultAgent(detected) {
328
+ // Prefer claude; fall back to first available; fall back to claude (assume
329
+ // it'll be installed — the UI shows an install hint when it isn't).
330
+ const claude = detected.find((a) => a.id === 'claude' && a.available);
331
+ if (claude) return claude;
332
+ const anyAvailable = detected.find((a) => a.available);
333
+ if (anyAvailable) return anyAvailable;
334
+ return detected[0] || DEFAULT_AGENTS[0];
335
+ }