@1agh/maude 0.30.0 → 0.32.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/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +788 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +796 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
- package/apps/studio/client/panels/ReadinessList.jsx +189 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +625 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +821 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +29097 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5816 -1614
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +399 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/readiness.ts +220 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +95 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/readiness.test.ts +127 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +82 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
package/README.md
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
A personal marketplace of Claude Code plugins. Two plugins today, plus a `maude` CLI for scaffolding and running the bundled dev tooling.
|
|
4
4
|
|
|
5
5
|
<!-- Demo video lives at https://github.com/1aGh/maude/releases/download/v0.16.0/demo.mp4 once uploaded.
|
|
6
|
-
Until then the inline tag below stays a soft 404; landing page at https://maude.
|
|
7
|
-
<video src="https://github.com/1aGh/maude/releases/download/v0.16.0/demo.mp4" controls muted playsinline poster="https://maude.
|
|
6
|
+
Until then the inline tag below stays a soft 404; landing page at https://maude.sh autoplays the same file. -->
|
|
7
|
+
<video src="https://github.com/1aGh/maude/releases/download/v0.16.0/demo.mp4" controls muted playsinline poster="https://maude.sh/demo-poster.jpg" width="800"></video>
|
|
8
8
|
|
|
9
|
-
> **📚 Full docs: https://maude.
|
|
9
|
+
> **📚 Full docs: https://maude.sh** (or browse the source under [`site/content/docs/`](./site/content/docs/) until the public URL lands).
|
|
10
10
|
> Contributing? See [CONTRIBUTING.md](./CONTRIBUTING.md). Security? See [SECURITY.md](./SECURITY.md).
|
|
11
11
|
|
|
12
12
|
| Plugin | What it does |
|
|
@@ -110,13 +110,13 @@ Two clean paths, no middle ground ([DDR-047](.ai/decisions/DDR-047-collab-scope-
|
|
|
110
110
|
|
|
111
111
|
## Security
|
|
112
112
|
|
|
113
|
-
Solo mode (the default) is fully local — no accounts, no telemetry, no network trust surface, and the canvas sandbox is on by default. Linked (hub) mode is opt-in and carries a documented trust model with disclosed, bounded residuals (hub-pushed content is treated as untrusted input). The full account — what runs where, what the canvas sandbox does and doesn't close, `.tsx` sync's double opt-in, untrusted-context handling — is at [`/docs/security`](https://maude.
|
|
113
|
+
Solo mode (the default) is fully local — no accounts, no telemetry, no network trust surface, and the canvas sandbox is on by default. Linked (hub) mode is opt-in and carries a documented trust model with disclosed, bounded residuals (hub-pushed content is treated as untrusted input). The full account — what runs where, what the canvas sandbox does and doesn't close, `.tsx` sync's double opt-in, untrusted-context handling — is at [`/docs/security`](https://maude.sh/docs/security) (source: [`site/content/docs/security.mdx`](./site/content/docs/security.mdx)). To **report** a vulnerability, see [SECURITY.md](./SECURITY.md).
|
|
114
114
|
|
|
115
115
|
## What's where
|
|
116
116
|
|
|
117
117
|
User-facing docs live in two places — the README points you the right way:
|
|
118
118
|
|
|
119
|
-
- **Reference** (every command, every config key, recipes for Next.js / Expo / monorepo) → [`site/content/docs/`](./site/content/docs/) (served at https://maude.
|
|
119
|
+
- **Reference** (every command, every config key, recipes for Next.js / Expo / monorepo) → [`site/content/docs/`](./site/content/docs/) (served at https://maude.sh once Vercel is wired — see [DDR-005](.ai/decisions/DDR-005-docs-site-stack-and-hosting.md)).
|
|
120
120
|
- **Quickstart** + **contributor info** → this README.
|
|
121
121
|
|
|
122
122
|
The docs site auto-generates per-command pages from `plugins/{flow,design}/commands/*.md` frontmatter and a typed schema reference from `plugins/flow/.claude-plugin/config.schema.json`. Adding a new command → docs update on next build.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Per-connection ACP bridge: spawns the user's Claude Code through the
|
|
2
|
+
// `claude-agent-acp` adapter and drives it as an ACP *client* (DDR-123).
|
|
3
|
+
//
|
|
4
|
+
// browser ─/_ws/acp─► dev-server (this) ─stdio ndJSON─► claude-agent-acp ─► claude -p
|
|
5
|
+
//
|
|
6
|
+
// We never see or store an Anthropic credential — the spawned `claude` owns its
|
|
7
|
+
// own auth + billing. The single load-bearing guarantee is `scrubAgentEnv`
|
|
8
|
+
// (env.ts): the child inherits the environment MINUS `ANTHROPIC_API_KEY`, so
|
|
9
|
+
// auth precedence falls through to the user's Pro/Max subscription.
|
|
10
|
+
|
|
11
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type Client,
|
|
16
|
+
ClientSideConnection,
|
|
17
|
+
ndJsonStream,
|
|
18
|
+
PROTOCOL_VERSION,
|
|
19
|
+
type PromptResponse,
|
|
20
|
+
type RequestPermissionRequest,
|
|
21
|
+
type RequestPermissionResponse,
|
|
22
|
+
type SessionNotification,
|
|
23
|
+
type SessionUpdate,
|
|
24
|
+
} from '@agentclientprotocol/sdk';
|
|
25
|
+
|
|
26
|
+
import { scrubAgentEnv } from './env.ts';
|
|
27
|
+
import { resolveAdapterEntry, resolveAgentRuntime, resolveClaudePath } from './probe.ts';
|
|
28
|
+
|
|
29
|
+
export interface AcpBridgeOptions {
|
|
30
|
+
/** Absolute repo root the ACP session runs in (where `.design/` + the CLI operate). */
|
|
31
|
+
repoRoot: string;
|
|
32
|
+
/** Streamed `session/update` notifications relayed to the browser. */
|
|
33
|
+
onUpdate: (update: SessionUpdate) => void;
|
|
34
|
+
/** Informational: a tool permission was auto-approved (transparency for the UI). */
|
|
35
|
+
onPermission?: (req: RequestPermissionRequest) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Spawned = ReturnType<typeof Bun.spawn>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Effort → extended-thinking budget, fed to the adapter as `MAX_THINKING_TOKENS`
|
|
42
|
+
* (it maps 0 → thinking disabled, a positive int → an enabled budget). `balanced`
|
|
43
|
+
* leaves it unset so the agent uses Claude Code's own default.
|
|
44
|
+
*/
|
|
45
|
+
const EFFORT_THINKING_TOKENS: Record<string, number | null> = {
|
|
46
|
+
fast: 0,
|
|
47
|
+
balanced: null,
|
|
48
|
+
thorough: 31999,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
|
|
52
|
+
|
|
53
|
+
/** Pick the most-permissive allow option, or null if the agent offered none. */
|
|
54
|
+
function pickAllowOption(params: RequestPermissionRequest) {
|
|
55
|
+
const options = params.options ?? [];
|
|
56
|
+
return (
|
|
57
|
+
options.find((o) => o.kind === 'allow_always') ??
|
|
58
|
+
options.find((o) => o.kind === 'allow_once') ??
|
|
59
|
+
options.find((o) => typeof o.kind === 'string' && o.kind.startsWith('allow')) ??
|
|
60
|
+
null
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class AcpBridge {
|
|
65
|
+
private proc: Spawned | null = null;
|
|
66
|
+
private conn: ClientSideConnection | null = null;
|
|
67
|
+
// One ACP session per chat id (repo-level), so each chat keeps its own claude
|
|
68
|
+
// context. The adapter (one subprocess) holds them all; switching chats reuses
|
|
69
|
+
// the session, so claude remembers that chat while the app is open.
|
|
70
|
+
private sessions = new Map<string, string>(); // chatId → sessionId
|
|
71
|
+
private currentSession: string | null = null; // the in-flight prompt's session
|
|
72
|
+
private starting: Promise<void> | null = null;
|
|
73
|
+
/** Per-chat transcript file (`_chat/<id>.jsonl`); set per prompt. */
|
|
74
|
+
private transcriptPath: string | null = null;
|
|
75
|
+
// Model + effort are env-at-spawn (ANTHROPIC_MODEL / MAX_THINKING_TOKENS), so a
|
|
76
|
+
// change re-spawns the adapter. `desired*` is what the UI asked for; `active*`
|
|
77
|
+
// is what the running session was spawned with.
|
|
78
|
+
private desiredModel: string | null = null;
|
|
79
|
+
private desiredEffort: AcpEffort = 'balanced';
|
|
80
|
+
private activeModel: string | null = null;
|
|
81
|
+
private activeEffort: AcpEffort = 'balanced';
|
|
82
|
+
|
|
83
|
+
constructor(private readonly opts: AcpBridgeOptions) {}
|
|
84
|
+
|
|
85
|
+
/** The session id of the most recent prompt (for the `connected` frame). */
|
|
86
|
+
get sessionId(): string | null {
|
|
87
|
+
return this.currentSession;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get connected(): boolean {
|
|
91
|
+
return this.conn !== null && this.proc !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setTranscriptPath(path: string | null): void {
|
|
95
|
+
this.transcriptPath = path;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Desired model (alias/id, or null for the user's default) + effort. Applied
|
|
99
|
+
* on the next prompt — re-spawning the adapter only if it actually changed. */
|
|
100
|
+
setConfig(model: string | null, effort: AcpEffort): void {
|
|
101
|
+
this.desiredModel = model;
|
|
102
|
+
this.desiredEffort = effort in EFFORT_THINKING_TOKENS ? effort : 'balanced';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private configChanged(): boolean {
|
|
106
|
+
return this.desiredModel !== this.activeModel || this.desiredEffort !== this.activeEffort;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Spawn + handshake exactly once; concurrent callers share the same promise. */
|
|
110
|
+
async ensureStarted(): Promise<void> {
|
|
111
|
+
if (this.conn) return;
|
|
112
|
+
if (!this.starting) {
|
|
113
|
+
this.starting = this.start().finally(() => {
|
|
114
|
+
this.starting = null;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return this.starting;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get-or-create the ACP session for a chat id (one claude context per chat). */
|
|
121
|
+
private async sessionFor(chatId: string): Promise<string> {
|
|
122
|
+
const existing = this.sessions.get(chatId);
|
|
123
|
+
if (existing) return existing;
|
|
124
|
+
if (!this.conn) throw new Error('ACP adapter not started');
|
|
125
|
+
const created = await this.conn.newSession({ cwd: this.opts.repoRoot, mcpServers: [] });
|
|
126
|
+
this.sessions.set(chatId, created.sessionId);
|
|
127
|
+
return created.sessionId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async start(): Promise<void> {
|
|
131
|
+
const adapterEntry = resolveAdapterEntry();
|
|
132
|
+
if (!adapterEntry) {
|
|
133
|
+
throw new Error('The Claude agent bridge is not installed in this build.');
|
|
134
|
+
}
|
|
135
|
+
if (!resolveClaudePath()) {
|
|
136
|
+
throw new Error("Claude Code isn't connected — run `claude` in a terminal and `/login`.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// DDR-123 guardrail #1 — strip ANTHROPIC_API_KEY so the child stays on the
|
|
140
|
+
// user's subscription. This is the whole compliance story; do not weaken it.
|
|
141
|
+
const env = scrubAgentEnv(process.env);
|
|
142
|
+
// Model + effort selection — config, NOT credentials, so they're added back.
|
|
143
|
+
this.activeModel = this.desiredModel;
|
|
144
|
+
this.activeEffort = this.desiredEffort;
|
|
145
|
+
if (this.activeModel) env.ANTHROPIC_MODEL = this.activeModel;
|
|
146
|
+
const thinking = EFFORT_THINKING_TOKENS[this.activeEffort];
|
|
147
|
+
if (thinking !== null && thinking !== undefined) env.MAX_THINKING_TOKENS = String(thinking);
|
|
148
|
+
|
|
149
|
+
const proc = Bun.spawn([resolveAgentRuntime(), adapterEntry], {
|
|
150
|
+
cwd: this.opts.repoRoot,
|
|
151
|
+
env,
|
|
152
|
+
stdin: 'pipe',
|
|
153
|
+
stdout: 'pipe',
|
|
154
|
+
stderr: 'pipe',
|
|
155
|
+
});
|
|
156
|
+
this.proc = proc;
|
|
157
|
+
void this.drainStderr(proc.stderr);
|
|
158
|
+
|
|
159
|
+
// Bun's `proc.stdin` is a FileSink, not a WritableStream — wrap it so
|
|
160
|
+
// ndJsonStream can pipe encoded ACP frames into the child's stdin.
|
|
161
|
+
const toChild = new WritableStream<Uint8Array>({
|
|
162
|
+
write: (chunk) => {
|
|
163
|
+
proc.stdin.write(chunk);
|
|
164
|
+
proc.stdin.flush();
|
|
165
|
+
},
|
|
166
|
+
close: () => {
|
|
167
|
+
try {
|
|
168
|
+
proc.stdin.end();
|
|
169
|
+
} catch {
|
|
170
|
+
/* already gone */
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
abort: () => {
|
|
174
|
+
try {
|
|
175
|
+
proc.stdin.end();
|
|
176
|
+
} catch {
|
|
177
|
+
/* already gone */
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
const stream = ndJsonStream(toChild, proc.stdout as ReadableStream<Uint8Array>);
|
|
182
|
+
|
|
183
|
+
const client: Client = {
|
|
184
|
+
sessionUpdate: (params: SessionNotification) => {
|
|
185
|
+
this.opts.onUpdate(params.update);
|
|
186
|
+
void this.appendTranscript({ role: 'agent', update: params.update });
|
|
187
|
+
},
|
|
188
|
+
requestPermission: (params: RequestPermissionRequest): RequestPermissionResponse => {
|
|
189
|
+
// Auto-approve: the agent is the user's OWN local Claude editing their
|
|
190
|
+
// OWN project over loopback — granting it is the feature, mirroring
|
|
191
|
+
// Claude Code's trusted-session default. A manual approve/deny UI is a
|
|
192
|
+
// Task-3 follow-up; we surface the request so the panel can show it.
|
|
193
|
+
this.opts.onPermission?.(params);
|
|
194
|
+
const option = pickAllowOption(params);
|
|
195
|
+
if (!option) return { outcome: { outcome: 'cancelled' } };
|
|
196
|
+
return { outcome: { outcome: 'selected', optionId: option.optionId } };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const conn = new ClientSideConnection(() => client, stream);
|
|
201
|
+
this.conn = conn;
|
|
202
|
+
|
|
203
|
+
await conn.initialize({
|
|
204
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
205
|
+
// We don't expose the project filesystem to the agent over ACP — the
|
|
206
|
+
// spawned `claude` already has direct disk access to `cwd`, so advertising
|
|
207
|
+
// fs capabilities here would only duplicate (and widen) that surface.
|
|
208
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
209
|
+
});
|
|
210
|
+
// Sessions are created lazily per chat (sessionFor) — not here.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Send a user turn for `chatId` and resolve when it completes. */
|
|
214
|
+
async prompt(
|
|
215
|
+
text: string,
|
|
216
|
+
chatId: string
|
|
217
|
+
): Promise<{ stopReason: PromptResponse['stopReason'] }> {
|
|
218
|
+
// Model/effort are env-at-spawn — if the user changed them, tear the adapter
|
|
219
|
+
// down so ensureStarted re-spawns with the new env (sessions re-create lazily).
|
|
220
|
+
if (this.conn && this.configChanged()) {
|
|
221
|
+
await this.stop();
|
|
222
|
+
}
|
|
223
|
+
await this.ensureStarted();
|
|
224
|
+
const conn = this.conn;
|
|
225
|
+
if (!conn) throw new Error('ACP adapter not ready');
|
|
226
|
+
const sessionId = await this.sessionFor(chatId);
|
|
227
|
+
this.currentSession = sessionId;
|
|
228
|
+
|
|
229
|
+
await this.appendTranscript({ role: 'user', text });
|
|
230
|
+
const response = await conn.prompt({
|
|
231
|
+
sessionId,
|
|
232
|
+
prompt: [{ type: 'text', text }],
|
|
233
|
+
});
|
|
234
|
+
await this.appendTranscript({ role: 'stop', stopReason: response.stopReason });
|
|
235
|
+
return { stopReason: response.stopReason };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Cancel the in-flight turn (no-op if nothing is running). */
|
|
239
|
+
async cancel(): Promise<void> {
|
|
240
|
+
if (this.conn && this.currentSession) {
|
|
241
|
+
try {
|
|
242
|
+
await this.conn.cancel({ sessionId: this.currentSession });
|
|
243
|
+
} catch {
|
|
244
|
+
/* turn may already have finished */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Tear down: cancel, kill the subprocess, drop all handles + sessions. */
|
|
250
|
+
async stop(): Promise<void> {
|
|
251
|
+
await this.cancel();
|
|
252
|
+
try {
|
|
253
|
+
this.proc?.kill();
|
|
254
|
+
} catch {
|
|
255
|
+
/* already dead */
|
|
256
|
+
}
|
|
257
|
+
this.proc = null;
|
|
258
|
+
this.conn = null;
|
|
259
|
+
this.sessions.clear();
|
|
260
|
+
this.currentSession = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async drainStderr(stderr: ReadableStream<Uint8Array>): Promise<void> {
|
|
264
|
+
try {
|
|
265
|
+
const decoder = new TextDecoder();
|
|
266
|
+
for await (const chunk of stderr) {
|
|
267
|
+
const line = decoder.decode(chunk).trimEnd();
|
|
268
|
+
if (line) console.error('[acp-adapter]', line);
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
/* stream closed on teardown */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async appendTranscript(entry: Record<string, unknown>): Promise<void> {
|
|
276
|
+
const path = this.transcriptPath;
|
|
277
|
+
if (!path) return;
|
|
278
|
+
try {
|
|
279
|
+
await mkdir(dirname(path), { recursive: true });
|
|
280
|
+
await appendFile(path, `${JSON.stringify({ ts: Date.now(), ...entry })}\n`);
|
|
281
|
+
} catch {
|
|
282
|
+
/* transcript is best-effort; never block the chat on disk errors */
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// DDR-123 guardrail #1 — keep the spawned Claude Code adapter on the user's
|
|
2
|
+
// Pro/Max SUBSCRIPTION, never metered API billing.
|
|
3
|
+
//
|
|
4
|
+
// Anthropic's auth precedence puts `ANTHROPIC_API_KEY` (#3, API billing) ABOVE
|
|
5
|
+
// the subscription OAuth from `/login` (#6, the Pro/Max default). A stray global
|
|
6
|
+
// key in the inherited environment would therefore silently switch the user to
|
|
7
|
+
// API billing the moment the adapter spawns `claude -p`. We delete it (and the
|
|
8
|
+
// equivalent auth-token override) from the child env before spawning. This is
|
|
9
|
+
// the single load-bearing detail that makes the whole panel subscription-correct
|
|
10
|
+
// — the guarantee lives here, at the bottom of the stack, independent of the UI.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Env var names that flip Claude Code off the subscription and onto metered API
|
|
14
|
+
* billing (or a non-subscription auth token). The two precedence-relevant keys.
|
|
15
|
+
*/
|
|
16
|
+
export const SUBSCRIPTION_SCRUBBED_ENV_KEYS = [
|
|
17
|
+
'ANTHROPIC_API_KEY',
|
|
18
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Scrub the WHOLE provider/billing namespace, not just the two known keys —
|
|
23
|
+
* any `ANTHROPIC_*` (API key, auth token, custom `ANTHROPIC_BASE_URL`, Bedrock/
|
|
24
|
+
* Vertex base URLs, future billing vars) plus the cloud-provider toggles. This
|
|
25
|
+
* closes the denylist gap (security review F1): a stray base-URL or a NEW billing
|
|
26
|
+
* env var can't silently redirect the spawned `claude` off the user's
|
|
27
|
+
* subscription or exfiltrate prompts to an attacker endpoint. `ANTHROPIC_MODEL`
|
|
28
|
+
* is in this set too — the bridge re-adds it AFTER the scrub, from a validated
|
|
29
|
+
* allowlist value, so the parent's value never leaks through.
|
|
30
|
+
*/
|
|
31
|
+
const PROVIDER_REDIRECT_RE = /^(ANTHROPIC_|CLAUDE_CODE_USE_|AWS_BEARER_TOKEN_BEDROCK)/i;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Return a copy of `source` with the billing/provider-redirect keys removed and
|
|
35
|
+
* any `undefined` values dropped (Bun.spawn's `env` wants Record<string,string>).
|
|
36
|
+
* Pure — never mutates the input, so `process.env` stays intact for the parent.
|
|
37
|
+
*/
|
|
38
|
+
export function scrubAgentEnv(
|
|
39
|
+
source: Record<string, string | undefined> = process.env
|
|
40
|
+
): Record<string, string> {
|
|
41
|
+
const out: Record<string, string> = {};
|
|
42
|
+
for (const [key, value] of Object.entries(source)) {
|
|
43
|
+
if (value === undefined) continue;
|
|
44
|
+
if (PROVIDER_REDIRECT_RE.test(key)) continue;
|
|
45
|
+
out[key] = value;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// ACP manager: owns one AcpBridge per `/_ws/acp` socket and translates the
|
|
2
|
+
// browser's JSON chat protocol to/from the ACP client. Wired into ws.ts (the
|
|
3
|
+
// `acp` socket kind) and server.ts (the main-origin, loopback-guarded upgrade).
|
|
4
|
+
// NEVER exposed on the canvas origin (DDR-054/DDR-123) — the untrusted iframe
|
|
5
|
+
// must not reach the agent bridge.
|
|
6
|
+
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import type { ServerWebSocket } from 'bun';
|
|
10
|
+
|
|
11
|
+
import type { Context } from '../context.ts';
|
|
12
|
+
import type { WsData } from '../ws.ts';
|
|
13
|
+
import { AcpBridge, type AcpEffort } from './bridge.ts';
|
|
14
|
+
import { probeAcpAvailability } from './probe.ts';
|
|
15
|
+
|
|
16
|
+
const VALID_EFFORT = new Set(['fast', 'balanced', 'thorough']);
|
|
17
|
+
// Model is set as ANTHROPIC_MODEL on the spawned child — allowlist it server-side
|
|
18
|
+
// (security review F1) so the loopback WS frame can't pin an arbitrary value.
|
|
19
|
+
const VALID_MODELS = new Set(['opus', 'sonnet', 'haiku']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Browser → server frames: `{ t: 'prompt', text, canvas? }`, `{ t: 'cancel' }`.
|
|
23
|
+
* Server → browser frames: `ready` (availability on open), `connected` (session
|
|
24
|
+
* live), `update` (each streamed session/update), `turn-end`, `permission`,
|
|
25
|
+
* `error`.
|
|
26
|
+
*/
|
|
27
|
+
export interface Acp {
|
|
28
|
+
onOpen(ws: ServerWebSocket<WsData>): void;
|
|
29
|
+
onMessage(ws: ServerWebSocket<WsData>, raw: string | Uint8Array): void;
|
|
30
|
+
onClose(ws: ServerWebSocket<WsData>): void;
|
|
31
|
+
/** Live bridge count — for diagnostics / teardown assertions. */
|
|
32
|
+
size(): number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createAcp(ctx: Context): Acp {
|
|
36
|
+
const bridges = new Map<string, AcpBridge>();
|
|
37
|
+
|
|
38
|
+
function send(ws: ServerWebSocket<WsData>, payload: unknown): void {
|
|
39
|
+
try {
|
|
40
|
+
ws.send(JSON.stringify(payload));
|
|
41
|
+
} catch {
|
|
42
|
+
/* dead socket — close handler cleans up */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Chats are repo-level (NOT per-canvas) — `_chat/<chatId>.jsonl`. The id is
|
|
47
|
+
// client-generated; sanitize it to a safe filename.
|
|
48
|
+
function sanitizeChatId(id: string): string {
|
|
49
|
+
const safe = id.replace(/[^a-z0-9_-]/gi, '').slice(0, 64);
|
|
50
|
+
return safe || 'default';
|
|
51
|
+
}
|
|
52
|
+
function transcriptPathFor(chatId: string): string {
|
|
53
|
+
return join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}.jsonl`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function handlePrompt(
|
|
57
|
+
ws: ServerWebSocket<WsData>,
|
|
58
|
+
text: string,
|
|
59
|
+
chatId: string,
|
|
60
|
+
model: string | null,
|
|
61
|
+
effort: AcpEffort
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
let bridge = bridges.get(ws.data.id);
|
|
64
|
+
if (!bridge) {
|
|
65
|
+
bridge = new AcpBridge({
|
|
66
|
+
repoRoot: ctx.paths.repoRoot,
|
|
67
|
+
onUpdate: (update) => send(ws, { t: 'update', update }),
|
|
68
|
+
onPermission: (req) => send(ws, { t: 'permission', toolCall: req.toolCall }),
|
|
69
|
+
});
|
|
70
|
+
bridges.set(ws.data.id, bridge);
|
|
71
|
+
}
|
|
72
|
+
bridge.setTranscriptPath(transcriptPathFor(chatId));
|
|
73
|
+
bridge.setConfig(model, effort);
|
|
74
|
+
try {
|
|
75
|
+
await bridge.ensureStarted();
|
|
76
|
+
const { stopReason } = await bridge.prompt(text, sanitizeChatId(chatId));
|
|
77
|
+
send(ws, { t: 'connected', sessionId: bridge.sessionId });
|
|
78
|
+
send(ws, { t: 'turn-end', stopReason });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
send(ws, { t: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
onOpen(ws) {
|
|
86
|
+
const probe = probeAcpAvailability();
|
|
87
|
+
send(ws, { t: 'ready', available: probe.available, reason: probe.reason });
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
onMessage(ws, raw) {
|
|
91
|
+
let msg: unknown;
|
|
92
|
+
try {
|
|
93
|
+
msg = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!msg || typeof msg !== 'object') return;
|
|
98
|
+
const frame = msg as {
|
|
99
|
+
t?: unknown;
|
|
100
|
+
text?: unknown;
|
|
101
|
+
chat?: unknown;
|
|
102
|
+
model?: unknown;
|
|
103
|
+
effort?: unknown;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (frame.t === 'prompt' && typeof frame.text === 'string') {
|
|
107
|
+
const chatId = typeof frame.chat === 'string' && frame.chat ? frame.chat : 'default';
|
|
108
|
+
const model =
|
|
109
|
+
typeof frame.model === 'string' && VALID_MODELS.has(frame.model) ? frame.model : null;
|
|
110
|
+
const effort: AcpEffort =
|
|
111
|
+
typeof frame.effort === 'string' && VALID_EFFORT.has(frame.effort)
|
|
112
|
+
? (frame.effort as AcpEffort)
|
|
113
|
+
: 'balanced';
|
|
114
|
+
void handlePrompt(ws, frame.text, chatId, model, effort);
|
|
115
|
+
} else if (frame.t === 'cancel') {
|
|
116
|
+
void bridges.get(ws.data.id)?.cancel();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
onClose(ws) {
|
|
121
|
+
const bridge = bridges.get(ws.data.id);
|
|
122
|
+
if (bridge) {
|
|
123
|
+
bridges.delete(ws.data.id);
|
|
124
|
+
void bridge.stop();
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
size() {
|
|
129
|
+
return bridges.size;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Detection layer for the ACP chat bridge: where is the spawnable
|
|
2
|
+
// `claude-agent-acp` adapter, is the user's `claude` CLI installed, and which JS
|
|
3
|
+
// runtime should run the adapter. All disk paths resolve through DEV_SERVER_ROOT
|
|
4
|
+
// (DDR-045) — never `dirname(import.meta.url)`, which is the virtual `/$bunfs`
|
|
5
|
+
// path inside a compiled binary. Per DDR-123 the panel is native-app only; the
|
|
6
|
+
// native shell ships the apps/studio source tree (with node_modules), so the
|
|
7
|
+
// adapter resolves on disk there.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { DEV_SERVER_ROOT } from '../paths.ts';
|
|
13
|
+
|
|
14
|
+
/** Adapter npm package — the renamed continuation of `@zed-industries/claude-code-acp`. */
|
|
15
|
+
const ADAPTER_PKG = '@agentclientprotocol/claude-agent-acp';
|
|
16
|
+
const ADAPTER_BIN_NAME = 'claude-agent-acp';
|
|
17
|
+
|
|
18
|
+
export interface AcpAvailability {
|
|
19
|
+
/** True when both the adapter entry and a `claude` CLI are resolvable. */
|
|
20
|
+
available: boolean;
|
|
21
|
+
/** Human-readable reason when `available` is false (drives the not-connected UI). */
|
|
22
|
+
reason?: string;
|
|
23
|
+
/** Absolute path to the adapter entry JS, or null. */
|
|
24
|
+
adapterEntry: string | null;
|
|
25
|
+
/** Absolute path to the `claude` CLI, or null. */
|
|
26
|
+
claudePath: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the adapter's spawnable entry script (its `bin`), trying:
|
|
31
|
+
* 1. `MAUDE_ACP_ADAPTER_ENTRY` override (tests + escape hatch).
|
|
32
|
+
* 2. The package's own `package.json` `bin` field, joined to its dir.
|
|
33
|
+
* 3. The pnpm `.bin/<name>` symlink, dereferenced to the real file.
|
|
34
|
+
* Returns an absolute path or null.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveAdapterEntry(): string | null {
|
|
37
|
+
const override = process.env.MAUDE_ACP_ADAPTER_ENTRY;
|
|
38
|
+
if (override) return existsSync(override) ? override : null;
|
|
39
|
+
|
|
40
|
+
// (2) Locate the installed package via its package.json, read the bin map.
|
|
41
|
+
try {
|
|
42
|
+
const pkgJsonPath = Bun.resolveSync(`${ADAPTER_PKG}/package.json`, DEV_SERVER_ROOT);
|
|
43
|
+
const pkgDir = dirname(pkgJsonPath);
|
|
44
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as {
|
|
45
|
+
bin?: string | Record<string, string>;
|
|
46
|
+
};
|
|
47
|
+
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[ADAPTER_BIN_NAME];
|
|
48
|
+
if (binRel) {
|
|
49
|
+
const entry = join(pkgDir, binRel);
|
|
50
|
+
if (existsSync(entry)) return entry;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
/* fall through to the symlink strategy */
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// (3) The pnpm-linked bin symlink in the dev-server's own node_modules.
|
|
57
|
+
try {
|
|
58
|
+
const sym = join(DEV_SERVER_ROOT, 'node_modules', '.bin', ADAPTER_BIN_NAME);
|
|
59
|
+
if (existsSync(sym)) return realpathSync(sym);
|
|
60
|
+
} catch {
|
|
61
|
+
/* not present */
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Absolute path to the user's installed `claude` CLI, or null. Honors a
|
|
69
|
+
* `MAUDE_CLAUDE_BIN` override; otherwise looks it up on PATH.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveClaudePath(): string | null {
|
|
72
|
+
const override = process.env.MAUDE_CLAUDE_BIN;
|
|
73
|
+
if (override) return existsSync(override) ? override : null;
|
|
74
|
+
return Bun.which('claude');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The JS runtime used to launch the adapter. The dev-server already runs under
|
|
79
|
+
* Bun, so `process.execPath` is always available; prefer a real `node` when
|
|
80
|
+
* present (the adapter + `@anthropic-ai/claude-agent-sdk` are authored for Node)
|
|
81
|
+
* and fall back to our own Bun. Overridable via `MAUDE_ACP_RUNTIME`.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveAgentRuntime(): string {
|
|
84
|
+
return process.env.MAUDE_ACP_RUNTIME || Bun.which('node') || process.execPath;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Cheap, side-effect-free readiness probe (no subprocess spawned) backing
|
|
89
|
+
* `GET /_api/acp/status` and the WS `ready` frame. The actual ACP session spins
|
|
90
|
+
* up lazily on the first prompt.
|
|
91
|
+
*/
|
|
92
|
+
export function probeAcpAvailability(): AcpAvailability {
|
|
93
|
+
const adapterEntry = resolveAdapterEntry();
|
|
94
|
+
const claudePath = resolveClaudePath();
|
|
95
|
+
if (!adapterEntry) {
|
|
96
|
+
return {
|
|
97
|
+
available: false,
|
|
98
|
+
reason: 'The Claude agent bridge is not installed in this build.',
|
|
99
|
+
adapterEntry: null,
|
|
100
|
+
claudePath,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (!claudePath) {
|
|
104
|
+
return {
|
|
105
|
+
available: false,
|
|
106
|
+
reason: "Claude Code isn't connected — run `claude` in a terminal and `/login`.",
|
|
107
|
+
adapterEntry,
|
|
108
|
+
claudePath: null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { available: true, adapterEntry, claudePath };
|
|
112
|
+
}
|