@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 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,4 @@
1
+ /* Fixed loopback port the serve bridge listens on and the browser connects to.
2
+ Fixed rather than scanned so the page can reach a running bridge at a known
3
+ address — presence is just whether the WebSocket opens. */
4
+ export const BRIDGE_PORT = 8787
@@ -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
+ }
@@ -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
+ }