@abide/claude-code 0.5.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/bin/cli.ts +56 -0
- package/package.json +57 -0
- package/src/BRIDGE_PORT.ts +4 -0
- package/src/CAPABILITY_TOOLS.ts +10 -0
- package/src/ClaudePermissions.ts +11 -0
- package/src/PermissionMode.ts +4 -0
- package/src/StreamMessage.ts +12 -0
- package/src/VERSION.ts +6 -0
- package/src/appMcpServer.ts +19 -0
- package/src/appMcpServers.ts +15 -0
- package/src/browser/assistant.ts +316 -0
- package/src/claudeCliArgs.ts +41 -0
- package/src/cliEngine.ts +94 -0
- package/src/engine.ts +91 -0
- package/src/framesFromMessages.ts +54 -0
- package/src/launch.ts +40 -0
- package/src/mcpServerNameForApp.ts +0 -0
- package/src/promptFromMessages.ts +26 -0
- package/src/sanitizeMcpName.ts +12 -0
- package/src/serve.ts +147 -0
package/bin/cli.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { CAPABILITY_TOOLS } from '../src/CAPABILITY_TOOLS.ts'
|
|
3
|
+
import { launch } from '../src/launch.ts'
|
|
4
|
+
import type { PermissionMode } from '../src/PermissionMode.ts'
|
|
5
|
+
import { serve } from '../src/serve.ts'
|
|
6
|
+
|
|
7
|
+
/* Local abide dev server default — mirrors abide's DEFAULT_PORT. The package owns
|
|
8
|
+
its own default so it needs no internal abide import. */
|
|
9
|
+
const DEFAULT_APP_PORT = 3000
|
|
10
|
+
|
|
11
|
+
const [, , command, ...rest] = process.argv
|
|
12
|
+
|
|
13
|
+
// Reads `--name=value` or `--name value` from argv.
|
|
14
|
+
function parseFlag(name: string): string | undefined {
|
|
15
|
+
const prefix = `--${name}=`
|
|
16
|
+
const match = rest.find((arg) => arg.startsWith(prefix))
|
|
17
|
+
if (match) {
|
|
18
|
+
return match.slice(prefix.length)
|
|
19
|
+
}
|
|
20
|
+
const index = rest.indexOf(`--${name}`)
|
|
21
|
+
if (index !== -1 && index + 1 < rest.length) {
|
|
22
|
+
return rest[index + 1]
|
|
23
|
+
}
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const url = parseFlag('url') ?? `http://localhost:${DEFAULT_APP_PORT}`
|
|
28
|
+
const mcpToken = parseFlag('mcp-token')
|
|
29
|
+
|
|
30
|
+
if (command === 'serve') {
|
|
31
|
+
const port = parseFlag('port')
|
|
32
|
+
/* Each `--allow <capability>` maps to the one built-in tool it enables, via
|
|
33
|
+
the closed CAPABILITY_TOOLS vocabulary — an unknown capability resolves to no
|
|
34
|
+
tool, so the page can never widen this set. */
|
|
35
|
+
const tools = rest
|
|
36
|
+
.map((arg, index) =>
|
|
37
|
+
arg === '--allow'
|
|
38
|
+
? CAPABILITY_TOOLS[rest[index + 1] as keyof typeof CAPABILITY_TOOLS]
|
|
39
|
+
: undefined,
|
|
40
|
+
)
|
|
41
|
+
.filter((tool) => tool !== undefined)
|
|
42
|
+
const server = serve({
|
|
43
|
+
url,
|
|
44
|
+
port: port ? Number(port) : undefined,
|
|
45
|
+
token: parseFlag('token'),
|
|
46
|
+
mcpToken,
|
|
47
|
+
tools,
|
|
48
|
+
// End the process once the page goes away (a reload reconnects within the grace).
|
|
49
|
+
onIdle: () => process.exit(0),
|
|
50
|
+
})
|
|
51
|
+
console.error(`abide assistant bridge on http://127.0.0.1:${server.port} -> ${url}`)
|
|
52
|
+
} else {
|
|
53
|
+
// Default action: the interactive TUI against the local (or --url) app.
|
|
54
|
+
const permissionMode = parseFlag('permission-mode') as PermissionMode | undefined
|
|
55
|
+
await launch({ url, mcpToken, ...(permissionMode ? { permissionMode } : {}) })
|
|
56
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@abide/claude-code",
|
|
3
|
+
"version": "0.5.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Claude Code engine for abide's agent() — drive Claude Code (your own auth, no API key) over your app's MCP surface",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Brian Cray",
|
|
8
|
+
"homepage": "https://github.com/briancray/abide#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/briancray/abide.git",
|
|
12
|
+
"directory": "packages/claude-code"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/briancray/abide/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"abide",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"agent",
|
|
21
|
+
"mcp",
|
|
22
|
+
"llm"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.3.0"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./src/engine.ts",
|
|
33
|
+
"./engine": "./src/engine.ts",
|
|
34
|
+
"./serve": "./src/serve.ts",
|
|
35
|
+
"./launch": "./src/launch.ts",
|
|
36
|
+
"./browser/assistant": "./src/browser/assistant.ts"
|
|
37
|
+
},
|
|
38
|
+
"bin": {
|
|
39
|
+
"claude-code": "./bin/cli.ts"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src",
|
|
43
|
+
"bin"
|
|
44
|
+
],
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@anthropic-ai/claude-agent-sdk": ">=0.3.0",
|
|
47
|
+
"@abide/abide": ">=0.27.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@anthropic-ai/claude-agent-sdk": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.168"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* Closed set of site-requestable capabilities, each mapped to the Claude Code
|
|
2
|
+
built-in tool it enables. Network-read only — nothing that touches the user's
|
|
3
|
+
shell or filesystem. Dangerous tools (Bash/Write/Edit) are absent by
|
|
4
|
+
construction, so neither the page API nor the serve flag vocabulary can name
|
|
5
|
+
them; the page can only request from this set, and the user still runs the
|
|
6
|
+
visible command to grant it. */
|
|
7
|
+
export const CAPABILITY_TOOLS = {
|
|
8
|
+
webSearch: 'WebSearch',
|
|
9
|
+
webFetch: 'WebFetch',
|
|
10
|
+
} as const
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PermissionMode } from './PermissionMode.ts'
|
|
2
|
+
|
|
3
|
+
/* Permission policy for a local-Claude run: the session mode plus allow/ask/deny
|
|
4
|
+
tool-rule lists (same shape as .claude/settings.json's `permissions`). The CLI
|
|
5
|
+
engine maps `defaultMode` to `--permission-mode` and the rules to `--settings`. */
|
|
6
|
+
export type ClaudePermissions = {
|
|
7
|
+
defaultMode?: PermissionMode
|
|
8
|
+
allow?: string[]
|
|
9
|
+
ask?: string[]
|
|
10
|
+
deny?: string[]
|
|
11
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/* Session permission mode — the values Claude Code's `--permission-mode` accepts.
|
|
2
|
+
Mirrors the SDK's PermissionMode without importing it, so the CLI-driven faces
|
|
3
|
+
(serve/launch) don't pull @anthropic-ai/claude-agent-sdk. */
|
|
4
|
+
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* The subset of Claude message shapes both engines read. The SDK's query()
|
|
2
|
+
stream and the CLI's `--output-format stream-json` lines carry the same schema,
|
|
3
|
+
so one mapper (framesFromMessages) serves both. Loosely typed — only the fields
|
|
4
|
+
the mapping touches; each engine casts its raw source to this at the boundary. */
|
|
5
|
+
export type StreamMessage =
|
|
6
|
+
| { type: 'stream_event'; event: { type: string; delta?: { type: string; text?: string } } }
|
|
7
|
+
| {
|
|
8
|
+
type: 'assistant'
|
|
9
|
+
message: { content: Array<{ type: string; id?: string; name?: string; input?: unknown }> }
|
|
10
|
+
}
|
|
11
|
+
| { type: 'user'; message: { content: unknown } }
|
|
12
|
+
| { type: 'result'; subtype: string }
|
package/src/VERSION.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import packageJson from '../package.json' with { type: 'json' }
|
|
2
|
+
|
|
3
|
+
/* This package's own version, inlined into the bundle. The browser `command` pins
|
|
4
|
+
`bunx @abide/claude-code@VERSION` to it so a stale global bunx cache can't start a
|
|
5
|
+
bridge mismatched with the @abide/claude-code the app actually ships. */
|
|
6
|
+
export const VERSION = packageJson.version
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* The single MCP server entry wiring Claude Code to a abide app's machine
|
|
2
|
+
surface. Plain data — no SDK types — so it serializes equally into the engine's
|
|
3
|
+
query() options and the `claude --mcp-config` JSON the TUI launcher writes. The
|
|
4
|
+
registration KEY (the prefix) is chosen by the caller via mcpServerNameForApp;
|
|
5
|
+
this only describes the transport. */
|
|
6
|
+
export function appMcpServer(origin: string, mcpToken?: string) {
|
|
7
|
+
return {
|
|
8
|
+
type: 'http' as const,
|
|
9
|
+
url: `${origin}/__abide/mcp`,
|
|
10
|
+
...(mcpToken ? { headers: { Authorization: `Bearer ${mcpToken}` } } : {}),
|
|
11
|
+
/* Force the app's verbs into the turn-1 prompt. Without this the SDK
|
|
12
|
+
defers MCP tools behind tool search, so the model never sees them upfront
|
|
13
|
+
and reports it only has its built-ins. This app's MCP surface is the whole
|
|
14
|
+
point of the assistant, so it must always load — and the flag blocks
|
|
15
|
+
startup until the server connects (5s cap), surfacing a dead endpoint
|
|
16
|
+
instead of silently yielding an empty toolset. */
|
|
17
|
+
alwaysLoad: true,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { appMcpServer } from './appMcpServer.ts'
|
|
2
|
+
import { mcpServerNameForApp } from './mcpServerNameForApp.ts'
|
|
3
|
+
|
|
4
|
+
/* The app's MCP server map, keyed under its discovered `mcp__<name>__*` prefix.
|
|
5
|
+
The "discover the name, then key the transport under it" pair is the actual
|
|
6
|
+
contract every face shares — the engine spreads this map into query options, the
|
|
7
|
+
TUI launcher stringifies it into `--mcp-config` — so it lives here once and the
|
|
8
|
+
prefix can't drift between the headless and interactive paths. */
|
|
9
|
+
export async function appMcpServers(
|
|
10
|
+
origin: string,
|
|
11
|
+
mcpToken?: string,
|
|
12
|
+
): Promise<Record<string, ReturnType<typeof appMcpServer>>> {
|
|
13
|
+
const name = await mcpServerNameForApp(origin, mcpToken)
|
|
14
|
+
return { [name]: appMcpServer(origin, mcpToken) }
|
|
15
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type { AgentFrame, NeutralMessage } from '@abide/abide/server/agent'
|
|
2
|
+
import { createSubscriber } from '@abide/abide/shared/createSubscriber'
|
|
3
|
+
import { BRIDGE_PORT } from '../BRIDGE_PORT.ts'
|
|
4
|
+
import type { CAPABILITY_TOOLS } from '../CAPABILITY_TOOLS.ts'
|
|
5
|
+
import { VERSION } from '../VERSION.ts'
|
|
6
|
+
|
|
7
|
+
/* Site-requestable capabilities — the closed vocabulary from CAPABILITY_TOOLS.
|
|
8
|
+
The page can request from this set; it can never name a shell/fs tool. */
|
|
9
|
+
type AssistantCapability = keyof typeof CAPABILITY_TOOLS
|
|
10
|
+
|
|
11
|
+
/* The accumulated assistant turn — text-so-far plus the tools it has touched.
|
|
12
|
+
`ask()` yields this (not raw deltas) so abide's subscribe() can surface the
|
|
13
|
+
whole snapshot as its `latest`, which a delta stream couldn't. */
|
|
14
|
+
type AssistantReply = {
|
|
15
|
+
text: string
|
|
16
|
+
tools: { id: string; name: string; ok?: boolean }[]
|
|
17
|
+
done: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EMPTY_REPLY: AssistantReply = { text: '', tools: [], done: false }
|
|
21
|
+
|
|
22
|
+
/* The shape subscribe() reads: an AsyncIterable carrying a stable `name` (the
|
|
23
|
+
registry/dedupe key). Declared structurally so the package needn't depend on
|
|
24
|
+
abide's internal Subscribable type. */
|
|
25
|
+
type Subscribable<T> = AsyncIterable<T> & { name: string }
|
|
26
|
+
|
|
27
|
+
/* Fold one frame into the running snapshot, returning a NEW object each call so
|
|
28
|
+
subscribe()'s reactive read always sees a changed value. */
|
|
29
|
+
function applyFrame(reply: AssistantReply, frame: AgentFrame): AssistantReply {
|
|
30
|
+
if (frame.type === 'text') {
|
|
31
|
+
return { ...reply, text: reply.text + frame.delta }
|
|
32
|
+
}
|
|
33
|
+
if (frame.type === 'tool_use') {
|
|
34
|
+
return { ...reply, tools: [...reply.tools, { id: frame.id, name: frame.name }] }
|
|
35
|
+
}
|
|
36
|
+
if (frame.type === 'tool_result') {
|
|
37
|
+
return {
|
|
38
|
+
...reply,
|
|
39
|
+
tools: reply.tools.map((tool) =>
|
|
40
|
+
tool.id === frame.id ? { ...tool, ok: frame.ok } : tool,
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { ...reply, done: true }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* What the UI should do about the assistant. A host (a abide bundle) injects a
|
|
48
|
+
handshake into the URL fragment to say it manages the bridge — `<port>.<token>`
|
|
49
|
+
when it's running, or `unavailable` when it manages one but `claude` isn't
|
|
50
|
+
installed. Absent = a plain browser, where the user starts the bridge via `command`. */
|
|
51
|
+
type AssistantStatus = 'ready' | 'starting' | 'manual' | 'unavailable'
|
|
52
|
+
|
|
53
|
+
type BundleHandshake = { managed: boolean; unavailable: boolean; port?: number; token?: string }
|
|
54
|
+
|
|
55
|
+
function bundleHandshake(): BundleHandshake {
|
|
56
|
+
const hash = typeof location === 'undefined' ? '' : location.hash
|
|
57
|
+
const match = hash.match(/abide-assistant=([^&]+)/)
|
|
58
|
+
if (!match) {
|
|
59
|
+
return { managed: false, unavailable: false }
|
|
60
|
+
}
|
|
61
|
+
// the group always captures when the regex matches; ?? '' keeps the index
|
|
62
|
+
// access defined under a consumer's noUncheckedIndexedAccess
|
|
63
|
+
const value = decodeURIComponent(match[1] ?? '')
|
|
64
|
+
if (value === 'unavailable') {
|
|
65
|
+
return { managed: true, unavailable: true }
|
|
66
|
+
}
|
|
67
|
+
const [port, token] = value.split('.')
|
|
68
|
+
return { managed: true, unavailable: false, port: Number(port), token }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type AssistantConfig = {
|
|
72
|
+
// The abide site whose MCP the local agent drives. Defaults to location.origin.
|
|
73
|
+
url?: string
|
|
74
|
+
port?: number
|
|
75
|
+
// Browser↔bridge secret; sent on the handshake, rendered into `command`.
|
|
76
|
+
token?: string
|
|
77
|
+
/* Behavioral instruction sent per-chat over the socket — shapes output within
|
|
78
|
+
granted tools, never expands them, so it's safe from the page (not in `command`). */
|
|
79
|
+
systemPrompt?: string
|
|
80
|
+
// Capability REQUEST surfaced in `command` for the user to grant by running it.
|
|
81
|
+
capabilities?: AssistantCapability[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type AssistantHandle = {
|
|
85
|
+
// Reactive: true while the loopback socket is open and serving.
|
|
86
|
+
readonly available: boolean
|
|
87
|
+
/* What the UI should do: 'ready' (show chat), 'starting' (a host is bringing
|
|
88
|
+
the bridge up), 'manual' (browser — show `command`), 'unavailable' (a host
|
|
89
|
+
manages the bridge but `claude` isn't installed — show an install hint). */
|
|
90
|
+
readonly status: AssistantStatus
|
|
91
|
+
// The copy-paste `serve` command — only in 'manual' mode; undefined when a host manages the bridge.
|
|
92
|
+
readonly command: string | undefined
|
|
93
|
+
/* The assistant's reply to `messages`, as a Subscribable of accumulating
|
|
94
|
+
snapshots: `subscribe(assistant.ask(messages))` drives the turn reactively.
|
|
95
|
+
Keyed by messages+bridge, so re-renders share one run and the LLM doesn't
|
|
96
|
+
re-fire until the conversation actually changes. */
|
|
97
|
+
ask(messages: NeutralMessage[]): Subscribable<AssistantReply>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* One WebSocket per bridge address, shared across handles/components. Presence is
|
|
101
|
+
the connection being open; chat turns are multiplexed over it, id-tagged so a
|
|
102
|
+
turn yields only its own frames. */
|
|
103
|
+
type Connection = {
|
|
104
|
+
readonly connected: boolean
|
|
105
|
+
// createSubscriber tap: reading it in a reactive scope opens the socket; last reader closes it.
|
|
106
|
+
track: () => void
|
|
107
|
+
send(messages: NeutralMessage[], systemPrompt?: string): AsyncIterable<AgentFrame>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const connections = new Map<string, Connection>()
|
|
111
|
+
|
|
112
|
+
function createConnection(url: string): Connection {
|
|
113
|
+
let socket: WebSocket | undefined
|
|
114
|
+
let connected = false
|
|
115
|
+
let nextId = 0
|
|
116
|
+
// createSubscriber's start/stop fire once, so presence is a boolean, not a count.
|
|
117
|
+
let watched = false
|
|
118
|
+
let update: (() => void) | undefined
|
|
119
|
+
// Resolves when the current socket opens; one per open(), awaited by send() before it writes.
|
|
120
|
+
let opened: Promise<void> | undefined
|
|
121
|
+
// id -> deliver a frame, or undefined to signal the stream ended because the socket dropped.
|
|
122
|
+
const inflight = new Map<number, (frame: AgentFrame | undefined) => void>()
|
|
123
|
+
|
|
124
|
+
// The socket stays alive while a presence reader watches OR a turn is in flight.
|
|
125
|
+
function closeIfIdle() {
|
|
126
|
+
if (!watched && inflight.size === 0) {
|
|
127
|
+
socket?.close()
|
|
128
|
+
socket = undefined
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function open() {
|
|
133
|
+
const ws = new WebSocket(url)
|
|
134
|
+
socket = ws
|
|
135
|
+
let onOpen: () => void
|
|
136
|
+
opened = new Promise((resolve) => {
|
|
137
|
+
onOpen = resolve
|
|
138
|
+
})
|
|
139
|
+
ws.onopen = () => {
|
|
140
|
+
connected = true
|
|
141
|
+
update?.()
|
|
142
|
+
onOpen()
|
|
143
|
+
}
|
|
144
|
+
ws.onmessage = (event) => {
|
|
145
|
+
const { id, frame } = JSON.parse(event.data as string) as {
|
|
146
|
+
id: number
|
|
147
|
+
frame: AgentFrame
|
|
148
|
+
}
|
|
149
|
+
inflight.get(id)?.(frame)
|
|
150
|
+
}
|
|
151
|
+
ws.onclose = () => {
|
|
152
|
+
connected = false
|
|
153
|
+
update?.()
|
|
154
|
+
// End every in-flight stream; reconnect only while a presence reader is watching.
|
|
155
|
+
inflight.forEach((deliver) => {
|
|
156
|
+
deliver(undefined)
|
|
157
|
+
})
|
|
158
|
+
inflight.clear()
|
|
159
|
+
if (watched) {
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
if (watched) {
|
|
162
|
+
open()
|
|
163
|
+
}
|
|
164
|
+
}, 1000)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ensureOpen(): Promise<void> {
|
|
170
|
+
if (!socket) {
|
|
171
|
+
open()
|
|
172
|
+
}
|
|
173
|
+
return connected ? Promise.resolve() : (opened ?? Promise.resolve())
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const track = createSubscriber((u) => {
|
|
177
|
+
update = u
|
|
178
|
+
watched = true
|
|
179
|
+
ensureOpen()
|
|
180
|
+
return () => {
|
|
181
|
+
watched = false
|
|
182
|
+
update = undefined
|
|
183
|
+
closeIfIdle()
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
get connected() {
|
|
189
|
+
return connected
|
|
190
|
+
},
|
|
191
|
+
track,
|
|
192
|
+
async *send(messages, systemPrompt) {
|
|
193
|
+
const id = nextId++
|
|
194
|
+
const queue: (AgentFrame | undefined)[] = []
|
|
195
|
+
let wake: (() => void) | undefined
|
|
196
|
+
inflight.set(id, (frame) => {
|
|
197
|
+
queue.push(frame)
|
|
198
|
+
wake?.()
|
|
199
|
+
})
|
|
200
|
+
try {
|
|
201
|
+
await ensureOpen()
|
|
202
|
+
socket?.send(JSON.stringify({ id, messages, systemPrompt }))
|
|
203
|
+
for (;;) {
|
|
204
|
+
while (queue.length > 0) {
|
|
205
|
+
const frame = queue.shift()
|
|
206
|
+
// undefined = socket dropped; a `done` frame = clean end of this turn.
|
|
207
|
+
if (frame === undefined) {
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
yield frame
|
|
211
|
+
if (frame.type === 'done') {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
await new Promise<void>((resolve) => {
|
|
216
|
+
wake = resolve
|
|
217
|
+
})
|
|
218
|
+
wake = undefined
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
inflight.delete(id)
|
|
222
|
+
closeIfIdle()
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getConnection(url: string): Connection {
|
|
229
|
+
const cached = connections.get(url)
|
|
230
|
+
if (cached) {
|
|
231
|
+
return cached
|
|
232
|
+
}
|
|
233
|
+
const connection = createConnection(url)
|
|
234
|
+
connections.set(url, connection)
|
|
235
|
+
return connection
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function siteOrigin(url?: string): string {
|
|
239
|
+
if (url) {
|
|
240
|
+
return url
|
|
241
|
+
}
|
|
242
|
+
return typeof location === 'undefined' ? '' : location.origin
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/*
|
|
246
|
+
Browser-side handle to a local Claude Code assistant bridge over a loopback
|
|
247
|
+
WebSocket: reactive presence, a chat stream, and the copy-paste command that
|
|
248
|
+
starts the bridge. `capabilities` and `systemPrompt` are the site's REQUEST
|
|
249
|
+
surface — they never grant local power (tools/permissions live with `serve`, on
|
|
250
|
+
the user's machine). Render the assistant only when `available`; otherwise show
|
|
251
|
+
`command` as the first-run hint.
|
|
252
|
+
*/
|
|
253
|
+
export function assistant(config: AssistantConfig = {}): AssistantHandle {
|
|
254
|
+
const host = bundleHandshake()
|
|
255
|
+
const origin = siteOrigin(config.url)
|
|
256
|
+
// A managed host's injected port/token win as defaults; explicit config still overrides.
|
|
257
|
+
const port = config.port ?? host.port ?? BRIDGE_PORT
|
|
258
|
+
const token = config.token ?? host.token
|
|
259
|
+
const wsUrl = `ws://127.0.0.1:${port}${token ? `?token=${token}` : ''}`
|
|
260
|
+
|
|
261
|
+
// True only when a bridge is actually reachable — never when a managed host reports it can't run one.
|
|
262
|
+
function isAvailable(): boolean {
|
|
263
|
+
if (typeof window === 'undefined' || host.unavailable) {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
const connection = getConnection(wsUrl)
|
|
267
|
+
connection.track()
|
|
268
|
+
return connection.connected
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
get available() {
|
|
273
|
+
return isAvailable()
|
|
274
|
+
},
|
|
275
|
+
get status() {
|
|
276
|
+
if (host.unavailable) {
|
|
277
|
+
return 'unavailable'
|
|
278
|
+
}
|
|
279
|
+
if (isAvailable()) {
|
|
280
|
+
return 'ready'
|
|
281
|
+
}
|
|
282
|
+
// A managed host is bringing the bridge up; a plain browser needs the user to.
|
|
283
|
+
return host.managed ? 'starting' : 'manual'
|
|
284
|
+
},
|
|
285
|
+
get command() {
|
|
286
|
+
// A host manages the bridge — nothing for the user to run.
|
|
287
|
+
if (host.managed) {
|
|
288
|
+
return undefined
|
|
289
|
+
}
|
|
290
|
+
// Pin to this bundle's version so a stale global bunx cache can't run a mismatched bridge.
|
|
291
|
+
const parts = [`bunx @abide/claude-code@${VERSION} serve --url ${origin}`]
|
|
292
|
+
if (port !== BRIDGE_PORT) {
|
|
293
|
+
parts.push(`--port ${port}`)
|
|
294
|
+
}
|
|
295
|
+
if (config.token) {
|
|
296
|
+
parts.push(`--token ${config.token}`)
|
|
297
|
+
}
|
|
298
|
+
for (const capability of config.capabilities ?? []) {
|
|
299
|
+
parts.push(`--allow ${capability}`)
|
|
300
|
+
}
|
|
301
|
+
return parts.join(' ')
|
|
302
|
+
},
|
|
303
|
+
ask(messages: NeutralMessage[]): Subscribable<AssistantReply> {
|
|
304
|
+
const frames = getConnection(wsUrl).send(messages, config.systemPrompt)
|
|
305
|
+
async function* snapshots() {
|
|
306
|
+
let reply = EMPTY_REPLY
|
|
307
|
+
for await (const frame of frames) {
|
|
308
|
+
reply = applyFrame(reply, frame)
|
|
309
|
+
yield reply
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// name = bridge + conversation: same messages dedupe to one run across readers/re-renders.
|
|
313
|
+
return Object.assign(snapshots(), { name: `${wsUrl} ${JSON.stringify(messages)}` })
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ClaudePermissions } from './ClaudePermissions.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The one mapping from the package's MCP + permission contract to `claude` CLI
|
|
5
|
+
flags, shared by both binary-spawning faces (cliEngine headless, launch
|
|
6
|
+
interactive) so the isolation triple and the permission grammar can't drift
|
|
7
|
+
between them: `--mcp-config` wires the app server, `--strict-mcp-config` +
|
|
8
|
+
`--setting-sources ''` isolate from the host's ambient servers and settings,
|
|
9
|
+
`--permission-mode` carries the session mode and `--settings` the
|
|
10
|
+
allow/ask/deny rules. `headless` pairs bypassPermissions with the explicit
|
|
11
|
+
`--dangerously-skip-permissions` opt-in the print mode requires; the
|
|
12
|
+
interactive TUI omits it so claude can confirm the bypass with the user.
|
|
13
|
+
*/
|
|
14
|
+
export function claudeCliArgs({
|
|
15
|
+
servers,
|
|
16
|
+
permissions,
|
|
17
|
+
headless,
|
|
18
|
+
}: {
|
|
19
|
+
servers: Record<string, unknown>
|
|
20
|
+
permissions?: ClaudePermissions
|
|
21
|
+
headless: boolean
|
|
22
|
+
}): string[] {
|
|
23
|
+
const args = [
|
|
24
|
+
'--mcp-config',
|
|
25
|
+
JSON.stringify({ mcpServers: servers }),
|
|
26
|
+
'--strict-mcp-config',
|
|
27
|
+
'--setting-sources',
|
|
28
|
+
'',
|
|
29
|
+
]
|
|
30
|
+
const { defaultMode, ...rules } = permissions ?? {}
|
|
31
|
+
if (defaultMode) {
|
|
32
|
+
args.push('--permission-mode', defaultMode)
|
|
33
|
+
}
|
|
34
|
+
if (headless && defaultMode === 'bypassPermissions') {
|
|
35
|
+
args.push('--dangerously-skip-permissions')
|
|
36
|
+
}
|
|
37
|
+
if (Object.keys(rules).length) {
|
|
38
|
+
args.push('--settings', JSON.stringify({ permissions: rules }))
|
|
39
|
+
}
|
|
40
|
+
return args
|
|
41
|
+
}
|
package/src/cliEngine.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { AgentEngine } from '@abide/abide/server/agent'
|
|
2
|
+
import { appMcpServers } from './appMcpServers.ts'
|
|
3
|
+
import type { ClaudePermissions } from './ClaudePermissions.ts'
|
|
4
|
+
import { claudeCliArgs } from './claudeCliArgs.ts'
|
|
5
|
+
import { framesFromMessages } from './framesFromMessages.ts'
|
|
6
|
+
import { promptFromMessages } from './promptFromMessages.ts'
|
|
7
|
+
import type { StreamMessage } from './StreamMessage.ts'
|
|
8
|
+
|
|
9
|
+
type CliEngineConfig = {
|
|
10
|
+
permissions?: ClaudePermissions
|
|
11
|
+
/* Built-in tools the model may use, on top of the app's verbs. `[]` restricts
|
|
12
|
+
it to only `mcp__<app>__*` (no shell/fs); omit to keep Claude's default set. */
|
|
13
|
+
tools?: string[]
|
|
14
|
+
mcpToken?: string
|
|
15
|
+
systemPrompt?: string
|
|
16
|
+
abortController?: AbortController
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Splits claude's stdout (JSONL stream-json) into parsed messages.
|
|
20
|
+
async function* readStreamJson(stdout: ReadableStream<Uint8Array>): AsyncIterable<StreamMessage> {
|
|
21
|
+
const reader = stdout.getReader()
|
|
22
|
+
const decoder = new TextDecoder()
|
|
23
|
+
let buffer = ''
|
|
24
|
+
for (;;) {
|
|
25
|
+
const { value, done } = await reader.read()
|
|
26
|
+
if (done) {
|
|
27
|
+
break
|
|
28
|
+
}
|
|
29
|
+
buffer += decoder.decode(value, { stream: true })
|
|
30
|
+
let newline = buffer.indexOf('\n')
|
|
31
|
+
while (newline !== -1) {
|
|
32
|
+
const line = buffer.slice(0, newline).trim()
|
|
33
|
+
buffer = buffer.slice(newline + 1)
|
|
34
|
+
if (line) {
|
|
35
|
+
yield JSON.parse(line) as StreamMessage
|
|
36
|
+
}
|
|
37
|
+
newline = buffer.indexOf('\n')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
A local-Claude engine: drives the user's installed `claude` binary headlessly
|
|
44
|
+
(`-p --output-format stream-json`) over the app's MCP, instead of the bundled SDK.
|
|
45
|
+
This is what keeps the serve bridge light — `bunx @abide/claude-code` needs only
|
|
46
|
+
Bun and the `claude` already on PATH, not @anthropic-ai/claude-agent-sdk. It maps
|
|
47
|
+
the same MCP contract (appMcpServers) and isolation (`--strict-mcp-config`,
|
|
48
|
+
`--setting-sources ''`) to CLI flags, and aborts — killing the child — when the
|
|
49
|
+
consumer stops iterating or the caller's controller fires.
|
|
50
|
+
*/
|
|
51
|
+
export function cliEngine(config: CliEngineConfig = {}): AgentEngine {
|
|
52
|
+
return async function* ({ messages, origin }) {
|
|
53
|
+
const servers = await appMcpServers(origin, config.mcpToken)
|
|
54
|
+
const serverName = Object.keys(servers)[0]
|
|
55
|
+
const controller = config.abortController ?? new AbortController()
|
|
56
|
+
|
|
57
|
+
const args = [
|
|
58
|
+
'-p',
|
|
59
|
+
'--output-format',
|
|
60
|
+
'stream-json',
|
|
61
|
+
'--verbose',
|
|
62
|
+
'--include-partial-messages',
|
|
63
|
+
...claudeCliArgs({ servers, permissions: config.permissions, headless: true }),
|
|
64
|
+
]
|
|
65
|
+
/* tools `[]` (the serve default) → allow only the app's mcp tools; a list
|
|
66
|
+
adds those built-ins; undefined keeps Claude's default toolset. */
|
|
67
|
+
if (config.tools) {
|
|
68
|
+
args.push('--allowedTools', ...config.tools, `mcp__${serverName}`)
|
|
69
|
+
}
|
|
70
|
+
if (config.systemPrompt) {
|
|
71
|
+
args.push('--append-system-prompt', config.systemPrompt)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const child = Bun.spawn({
|
|
75
|
+
cmd: ['claude', ...args],
|
|
76
|
+
stdin: 'pipe',
|
|
77
|
+
stdout: 'pipe',
|
|
78
|
+
stderr: 'inherit',
|
|
79
|
+
})
|
|
80
|
+
// Abort (e.g. serve's socket close) kills the spawned claude with the page.
|
|
81
|
+
const onAbort = () => child.kill()
|
|
82
|
+
controller.signal.addEventListener('abort', onAbort)
|
|
83
|
+
// Await the write (it returns a Promise when backpressured) before closing,
|
|
84
|
+
// so a prompt larger than the pipe buffer isn't truncated.
|
|
85
|
+
await child.stdin.write(promptFromMessages(messages))
|
|
86
|
+
await child.stdin.end()
|
|
87
|
+
try {
|
|
88
|
+
yield* framesFromMessages(readStreamJson(child.stdout))
|
|
89
|
+
} finally {
|
|
90
|
+
controller.signal.removeEventListener('abort', onAbort)
|
|
91
|
+
child.kill()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { AgentEngine } from '@abide/abide/server/agent'
|
|
2
|
+
import type { Options, Settings } from '@anthropic-ai/claude-agent-sdk'
|
|
3
|
+
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
4
|
+
import { appMcpServers } from './appMcpServers.ts'
|
|
5
|
+
import { framesFromMessages } from './framesFromMessages.ts'
|
|
6
|
+
import { promptFromMessages } from './promptFromMessages.ts'
|
|
7
|
+
import type { StreamMessage } from './StreamMessage.ts'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
The SDK-backed Claude Code engine for abide's `agent()`. `engine(config)` returns
|
|
11
|
+
an AgentEngine that drives the @anthropic-ai/claude-agent-sdk headless, pointed at
|
|
12
|
+
the app's own MCP endpoint, and relays its event stream as AgentFrames.
|
|
13
|
+
|
|
14
|
+
// src/server/rpc/chat.ts
|
|
15
|
+
import { agent } from '@abide/abide/server/agent'
|
|
16
|
+
import { jsonl } from '@abide/abide/server/jsonl'
|
|
17
|
+
import { engine } from '@abide/claude-code'
|
|
18
|
+
const chatEngine = engine({ permissions: { defaultMode: 'bypassPermissions' } })
|
|
19
|
+
export const chat = POST(({ messages }) => jsonl(agent(chatEngine, messages)), { inputSchema })
|
|
20
|
+
|
|
21
|
+
Use this for a deployed server running Claude Code on its own auth, where there's
|
|
22
|
+
no interactive `claude` on PATH — it requires the @anthropic-ai/claude-agent-sdk
|
|
23
|
+
peer (which bundles its own runtime). For a local assistant against the user's
|
|
24
|
+
installed `claude` (the serve bridge / TUI), the cliEngine drives the binary
|
|
25
|
+
directly and needs no SDK.
|
|
26
|
+
|
|
27
|
+
Auth rides whatever Claude Code is logged in with. Permission is decided
|
|
28
|
+
server-side via `permissions` — the same `defaultMode` + allow/ask/deny block as
|
|
29
|
+
.claude/settings.json — governing how Claude Code treats its own built-ins.
|
|
30
|
+
*/
|
|
31
|
+
type ClaudeCodeConfig = {
|
|
32
|
+
// Permission policy, forwarded to the SDK as inline settings + permissionMode.
|
|
33
|
+
permissions?: Settings['permissions']
|
|
34
|
+
// Built-in tools the model may see, or `[]` to drop them so only mcp__<app>__* remains.
|
|
35
|
+
tools?: Options['tools']
|
|
36
|
+
// Bearer for the app's /__abide/mcp endpoint, if it's gated by app.handle/authorize.
|
|
37
|
+
mcpToken?: string
|
|
38
|
+
// Cancels the run early; the engine also aborts when the consumer stops iterating.
|
|
39
|
+
abortController?: AbortController
|
|
40
|
+
// Escape hatch for any other SDK option; spread first so engine-owned keys win.
|
|
41
|
+
options?: Partial<Options>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function engine(config: ClaudeCodeConfig = {}): AgentEngine {
|
|
45
|
+
/* Split the settings-shaped permission block: `defaultMode` is the session
|
|
46
|
+
mode (a top-level SDK option, the only thing the bypass guard checks) while the
|
|
47
|
+
allow/ask/deny rules ride in `settings.permissions`. */
|
|
48
|
+
const { defaultMode, ...permissionRules } = config.permissions ?? {}
|
|
49
|
+
return async function* ({ messages, origin }) {
|
|
50
|
+
const prompt = promptFromMessages(messages)
|
|
51
|
+
// The app's MCP server, keyed under its discovered `mcp__<name>__*` prefix.
|
|
52
|
+
const appServers = await appMcpServers(origin, config.mcpToken)
|
|
53
|
+
// Aborted in the finally so the SDK stops and kills its Claude process when
|
|
54
|
+
// the consumer stops iterating; a caller controller can cancel from outside.
|
|
55
|
+
const controller = config.abortController ?? new AbortController()
|
|
56
|
+
|
|
57
|
+
const stream = query({
|
|
58
|
+
prompt,
|
|
59
|
+
options: {
|
|
60
|
+
// No skills by default — a site-inline agent shouldn't surface the host's workflows.
|
|
61
|
+
skills: [],
|
|
62
|
+
// Caller extras first; every engine-owned key below overrides them.
|
|
63
|
+
...config.options,
|
|
64
|
+
mcpServers: { ...config.options?.mcpServers, ...appServers },
|
|
65
|
+
/* Isolate from the deploy host's ambient settings and MCP servers
|
|
66
|
+
(project .mcp.json, user settings, plugins, cloud connectors) — they'd
|
|
67
|
+
otherwise merge into and could widen this policy. */
|
|
68
|
+
settingSources: [],
|
|
69
|
+
strictMcpConfig: true,
|
|
70
|
+
// Always stream token deltas (as stream_event) so text arrives live.
|
|
71
|
+
includePartialMessages: true,
|
|
72
|
+
abortController: controller,
|
|
73
|
+
...(config.tools ? { tools: config.tools } : {}),
|
|
74
|
+
...(Object.keys(permissionRules).length
|
|
75
|
+
? { settings: { permissions: permissionRules } }
|
|
76
|
+
: {}),
|
|
77
|
+
...(defaultMode ? { permissionMode: defaultMode } : {}),
|
|
78
|
+
// The SDK requires this explicit opt-in alongside bypassPermissions.
|
|
79
|
+
...(defaultMode === 'bypassPermissions'
|
|
80
|
+
? { allowDangerouslySkipPermissions: true }
|
|
81
|
+
: {}),
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
yield* framesFromMessages(stream as AsyncIterable<StreamMessage>)
|
|
87
|
+
} finally {
|
|
88
|
+
controller.abort()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AgentFrame } from '@abide/abide/server/agent'
|
|
2
|
+
import type { StreamMessage } from './StreamMessage.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Maps a Claude message stream to abide AgentFrames. Shared by both engines — the
|
|
6
|
+
SDK's query() stream and the CLI's stream-json lines have the same schema, so the
|
|
7
|
+
discrimination lives here once. Text is emitted from `stream_event` deltas (live
|
|
8
|
+
tokens); the complete `assistant` message repeats that text in full, so it's kept
|
|
9
|
+
only for its fully-formed tool_use blocks (partial tool inputs mid-stream aren't
|
|
10
|
+
valid JSON yet). Tool outcomes return as tool_result blocks on a user turn —
|
|
11
|
+
`ok: !is_error` surfaces a blocked/denied tool the same as a failed one.
|
|
12
|
+
*/
|
|
13
|
+
export async function* framesFromMessages(
|
|
14
|
+
messages: AsyncIterable<StreamMessage>,
|
|
15
|
+
): AsyncIterable<AgentFrame> {
|
|
16
|
+
// tool_use id → name, so a tool_result (which carries only the id) can name its call.
|
|
17
|
+
const toolNames = new Map<string, string>()
|
|
18
|
+
for await (const message of messages) {
|
|
19
|
+
if (message.type === 'stream_event') {
|
|
20
|
+
const { event } = message
|
|
21
|
+
if (
|
|
22
|
+
event.type === 'content_block_delta' &&
|
|
23
|
+
event.delta?.type === 'text_delta' &&
|
|
24
|
+
event.delta.text
|
|
25
|
+
) {
|
|
26
|
+
yield { type: 'text', delta: event.delta.text }
|
|
27
|
+
}
|
|
28
|
+
} else if (message.type === 'assistant') {
|
|
29
|
+
for (const block of message.message.content) {
|
|
30
|
+
if (block.type === 'tool_use' && block.id && block.name) {
|
|
31
|
+
toolNames.set(block.id, block.name)
|
|
32
|
+
yield { type: 'tool_use', id: block.id, name: block.name, input: block.input }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} else if (message.type === 'user') {
|
|
36
|
+
const { content } = message.message
|
|
37
|
+
if (Array.isArray(content)) {
|
|
38
|
+
for (const block of content) {
|
|
39
|
+
if (block.type === 'tool_result') {
|
|
40
|
+
yield {
|
|
41
|
+
type: 'tool_result',
|
|
42
|
+
id: block.tool_use_id,
|
|
43
|
+
name: toolNames.get(block.tool_use_id) ?? '',
|
|
44
|
+
ok: !block.is_error,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} else if (message.type === 'result') {
|
|
50
|
+
// `success` is a clean finish; every error subtype is an abnormal stop.
|
|
51
|
+
yield { type: 'done', stop: message.subtype === 'success' ? 'end' : 'error' }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/launch.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { appMcpServers } from './appMcpServers.ts'
|
|
2
|
+
import { claudeCliArgs } from './claudeCliArgs.ts'
|
|
3
|
+
import type { PermissionMode } from './PermissionMode.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Launches the interactive `claude` TUI wired to a abide app's MCP surface. Unlike
|
|
7
|
+
the engine (headless query()), this spawns the real binary — so it touches no SDK
|
|
8
|
+
— and maps the same MCP contract to CLI flags: `--mcp-config` for the app server,
|
|
9
|
+
`--strict-mcp-config` + `--setting-sources ''` to isolate from the host's ambient
|
|
10
|
+
servers and settings, `--permission-mode` for the session policy.
|
|
11
|
+
|
|
12
|
+
Inherits stdio (it's a TUI) and forwards Ctrl+C so the child tears down cleanly,
|
|
13
|
+
then mirrors its exit code — `never` because the process is replaced.
|
|
14
|
+
*/
|
|
15
|
+
type LaunchConfig = {
|
|
16
|
+
// The abide app whose MCP the TUI drives (local dev server or a deployed origin).
|
|
17
|
+
url: string
|
|
18
|
+
mcpToken?: string
|
|
19
|
+
permissionMode?: PermissionMode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function launch(config: LaunchConfig): Promise<never> {
|
|
23
|
+
const servers = await appMcpServers(config.url, config.mcpToken)
|
|
24
|
+
const args = [
|
|
25
|
+
'claude',
|
|
26
|
+
...claudeCliArgs({
|
|
27
|
+
servers,
|
|
28
|
+
permissions: config.permissionMode ? { defaultMode: config.permissionMode } : undefined,
|
|
29
|
+
headless: false,
|
|
30
|
+
}),
|
|
31
|
+
]
|
|
32
|
+
const child = Bun.spawn({ cmd: args, stdio: ['inherit', 'inherit', 'inherit'] })
|
|
33
|
+
const forward = (signal: NodeJS.Signals) => {
|
|
34
|
+
child.kill(signal)
|
|
35
|
+
setTimeout(() => child.kill('SIGKILL'), 3000).unref()
|
|
36
|
+
}
|
|
37
|
+
process.on('SIGINT', () => forward('SIGINT'))
|
|
38
|
+
process.on('SIGTERM', () => forward('SIGTERM'))
|
|
39
|
+
process.exit(await child.exited)
|
|
40
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NeutralMessage } from '@abide/abide/server/agent'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Claude (SDK or CLI) takes a single prompt and owns assistant/tool turns through
|
|
5
|
+
its own session, which abide doesn't resume here. So prior turns are flattened
|
|
6
|
+
into the prompt as a labelled transcript rather than dropped — the model keeps
|
|
7
|
+
the conversation's context without session state. A lone user turn passes through
|
|
8
|
+
as its bare text. Tool-result turns are internal to the prior run and omitted.
|
|
9
|
+
*/
|
|
10
|
+
export function promptFromMessages(messages: NeutralMessage[]): string {
|
|
11
|
+
if (messages.length === 1 && messages[0]?.role === 'user') {
|
|
12
|
+
return messages[0].text
|
|
13
|
+
}
|
|
14
|
+
return messages
|
|
15
|
+
.map((message) => {
|
|
16
|
+
if (message.role === 'user') {
|
|
17
|
+
return `User: ${message.text}`
|
|
18
|
+
}
|
|
19
|
+
if (message.role === 'assistant' && message.text) {
|
|
20
|
+
return `Assistant: ${message.text}`
|
|
21
|
+
}
|
|
22
|
+
return ''
|
|
23
|
+
})
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join('\n\n')
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Normalizes an app's MCP server name into a token legal in the
|
|
2
|
+
`mcp__<name>__<tool>` prefix. Drops the npm scope's leading `@` but keeps the
|
|
3
|
+
scope for uniqueness (`@acme/shop` -> `acme_shop`); every other non-word run
|
|
4
|
+
collapses to `_`, and edge underscores are trimmed. Deterministic — same input,
|
|
5
|
+
same token — so permission rules authored against the prefix stay valid across
|
|
6
|
+
deploys. */
|
|
7
|
+
export function sanitizeMcpName(name: string): string {
|
|
8
|
+
return name
|
|
9
|
+
.replace(/^@/, '')
|
|
10
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
11
|
+
.replace(/^_+|_+$/g, '')
|
|
12
|
+
}
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { NeutralMessage } from '@abide/abide/server/agent'
|
|
2
|
+
import { BRIDGE_PORT } from './BRIDGE_PORT.ts'
|
|
3
|
+
import type { ClaudePermissions } from './ClaudePermissions.ts'
|
|
4
|
+
import { cliEngine } from './cliEngine.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Runs on the USER's machine, on loopback, so a remote abide site's browser can
|
|
8
|
+
drive the user's local Claude Code over its own MCP surface. A separate process
|
|
9
|
+
from any deployed server — apps that don't use this pay nothing. Drives the
|
|
10
|
+
installed `claude` binary (via cliEngine), so it needs only Bun + claude on PATH,
|
|
11
|
+
no @anthropic-ai/claude-agent-sdk.
|
|
12
|
+
|
|
13
|
+
The page reaches the bridge over a single WebSocket: presence IS the connection
|
|
14
|
+
being open (no polling), and chat frames ride the same socket, id-tagged so
|
|
15
|
+
concurrent turns don't interleave. This is the loopback channel only — Claude
|
|
16
|
+
still talks to the app's MCP over HTTP (the `mcp__<app>__*` server).
|
|
17
|
+
|
|
18
|
+
The trust boundary: capabilities (`tools`/`permissions`) are chosen HERE, by the
|
|
19
|
+
user starting the bridge — never sent from the page, which could only ever author
|
|
20
|
+
a request, not a grant. Defaults are locked down: `tools: []` exposes only the
|
|
21
|
+
site's `mcp__<app>__*` verbs (no shell/fs) and sidesteps the headless no-TTY
|
|
22
|
+
permission prompt, with prompting left at `default`.
|
|
23
|
+
*/
|
|
24
|
+
type ServeConfig = {
|
|
25
|
+
// The abide site whose MCP this agent drives, and (by default) the only origin allowed in.
|
|
26
|
+
url: string
|
|
27
|
+
port?: number
|
|
28
|
+
// Origins permitted to reach this bridge. Defaults to [url]; doubles as a DNS-rebind guard.
|
|
29
|
+
allowOrigins?: string[]
|
|
30
|
+
// Optional browser↔bridge secret echoed by the page on the handshake; rejects other sites.
|
|
31
|
+
token?: string
|
|
32
|
+
// Optional bridge→site MCP bearer, distinct from `token`.
|
|
33
|
+
mcpToken?: string
|
|
34
|
+
// Built-in tools the local agent may use, on top of the site's verbs. Default [] — site verbs only.
|
|
35
|
+
tools?: string[]
|
|
36
|
+
permissions?: ClaudePermissions
|
|
37
|
+
/* Called once the last subscriber has been gone for `idleGraceMs` (default
|
|
38
|
+
30s). The bin passes `() => process.exit(0)` so `bunx serve` ends when the page
|
|
39
|
+
closes; a reload within the grace reconnects and cancels it. Only armed after
|
|
40
|
+
the first connection, so the bridge waits indefinitely for the page to first
|
|
41
|
+
appear. Omit to keep the bridge resident. */
|
|
42
|
+
onIdle?: () => void
|
|
43
|
+
idleGraceMs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// One chat turn from the page: `id` correlates the frames streamed back on the shared socket.
|
|
47
|
+
type ChatRequest = { id: number; messages: NeutralMessage[]; systemPrompt?: string }
|
|
48
|
+
|
|
49
|
+
/* Per-connection state: the abort controllers for this socket's in-flight turns,
|
|
50
|
+
so closing the page can cancel each run — killing its spawned Claude process
|
|
51
|
+
rather than leaving it running on the user's machine. */
|
|
52
|
+
type WsData = { controllers: Set<AbortController> }
|
|
53
|
+
|
|
54
|
+
export function serve(config: ServeConfig) {
|
|
55
|
+
const allowOrigins = config.allowOrigins ?? [config.url]
|
|
56
|
+
// Live socket count + the pending idle-exit timer (armed only once a client has connected).
|
|
57
|
+
let connections = 0
|
|
58
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
59
|
+
|
|
60
|
+
return Bun.serve<WsData>({
|
|
61
|
+
// Loopback only — never 0.0.0.0.
|
|
62
|
+
hostname: '127.0.0.1',
|
|
63
|
+
port: config.port ?? BRIDGE_PORT,
|
|
64
|
+
// Origin allow-list + token are checked on the handshake (also the rebind guard);
|
|
65
|
+
// a passing request is upgraded to the WebSocket, anything else is refused.
|
|
66
|
+
fetch(request, server) {
|
|
67
|
+
const origin = request.headers.get('origin') ?? ''
|
|
68
|
+
if (!allowOrigins.includes(origin)) {
|
|
69
|
+
return new Response(null, { status: 403 })
|
|
70
|
+
}
|
|
71
|
+
if (config.token && new URL(request.url).searchParams.get('token') !== config.token) {
|
|
72
|
+
return new Response(null, { status: 401 })
|
|
73
|
+
}
|
|
74
|
+
if (server.upgrade(request, { data: { controllers: new Set() } })) {
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
return new Response('Upgrade required', { status: 426 })
|
|
78
|
+
},
|
|
79
|
+
websocket: {
|
|
80
|
+
open() {
|
|
81
|
+
connections++
|
|
82
|
+
// A (re)connection cancels any pending idle exit.
|
|
83
|
+
if (idleTimer) {
|
|
84
|
+
clearTimeout(idleTimer)
|
|
85
|
+
idleTimer = undefined
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async message(ws, raw) {
|
|
89
|
+
let request: ChatRequest
|
|
90
|
+
try {
|
|
91
|
+
request = JSON.parse(String(raw))
|
|
92
|
+
} catch {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
/* Per-turn controller, registered so close() can abort this run even
|
|
96
|
+
while it's idle (between frames) — the readyState check below only
|
|
97
|
+
fires on the next frame, which a stalled run never produces. */
|
|
98
|
+
const controller = new AbortController()
|
|
99
|
+
ws.data.controllers.add(controller)
|
|
100
|
+
/* Per-message engine so the site's behavioral systemPrompt can vary
|
|
101
|
+
per turn — it shapes output within the granted tools, never expands
|
|
102
|
+
them, so it's safe to take from the page. */
|
|
103
|
+
const runAgent = cliEngine({
|
|
104
|
+
tools: config.tools ?? [],
|
|
105
|
+
permissions: config.permissions ?? { defaultMode: 'default' },
|
|
106
|
+
mcpToken: config.mcpToken,
|
|
107
|
+
systemPrompt: request.systemPrompt,
|
|
108
|
+
abortController: controller,
|
|
109
|
+
})
|
|
110
|
+
// The engine ignores `surface` — it dials the site's MCP via `origin`.
|
|
111
|
+
const frames = runAgent({
|
|
112
|
+
surface: undefined as never,
|
|
113
|
+
messages: request.messages,
|
|
114
|
+
origin: config.url,
|
|
115
|
+
})
|
|
116
|
+
try {
|
|
117
|
+
for await (const frame of frames) {
|
|
118
|
+
// Stop the run if the page closed the socket mid-stream.
|
|
119
|
+
if (ws.readyState !== 1) {
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
ws.send(JSON.stringify({ id: request.id, frame }))
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// An abort (page closed) is expected; surface only real failures.
|
|
126
|
+
if (!controller.signal.aborted) {
|
|
127
|
+
console.error(error)
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
ws.data.controllers.delete(controller)
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
// Page gone: abort every in-flight run so its Claude process dies with the socket.
|
|
134
|
+
close(ws) {
|
|
135
|
+
ws.data.controllers.forEach((controller) => {
|
|
136
|
+
controller.abort()
|
|
137
|
+
})
|
|
138
|
+
ws.data.controllers.clear()
|
|
139
|
+
connections--
|
|
140
|
+
// Last subscriber gone — arm the idle exit; a reload reconnects within the grace.
|
|
141
|
+
if (connections === 0 && config.onIdle) {
|
|
142
|
+
idleTimer = setTimeout(config.onIdle, config.idleGraceMs ?? 30_000)
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
}
|