@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 +21 -0
- package/README.md +73 -0
- package/package.json +69 -0
- package/server/bin/wild-workspace.mjs +95 -0
- package/server/src/activity.mjs +71 -0
- package/server/src/agent.mjs +335 -0
- package/server/src/config.mjs +236 -0
- package/server/src/daemon-bin.mjs +66 -0
- package/server/src/daemon.mjs +178 -0
- package/server/src/fs.mjs +136 -0
- package/server/src/inbox.mjs +81 -0
- package/server/src/index.mjs +635 -0
- package/server/src/preview.mjs +31 -0
- package/server/src/share.mjs +80 -0
- package/server/src/sync.mjs +176 -0
- package/web/dist/assets/index-DOwej8U4.js +89 -0
- package/web/dist/assets/index-DZkyDo10.css +1 -0
- package/web/dist/index.html +15 -0
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
|
+
}
|