@1agh/maude 0.29.0 → 0.31.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.
Files changed (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
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.iagh.cz 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.iagh.cz/demo-poster.jpg" width="800"></video>
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.iagh.cz** (or browse the source under [`site/content/docs/`](./site/content/docs/) until the public URL lands).
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 |
@@ -106,17 +106,17 @@ Plugin slash-commands reach all executable logic through the on-PATH `maude` bin
106
106
  Two clean paths, no middle ground ([DDR-047](.ai/decisions/DDR-047-collab-scope-cut-no-lan-mode-hub-admin-ui.md)):
107
107
 
108
108
  - **v1.0 — git handoff OR loopback multi-tab.** Push / pull is the cross-machine story. On a single machine, two browser tabs (or two Claude Code instances editing the same repo) sync cursors, comments, and annotations live over loopback WebSocket. The dev server refuses any non-loopback `host` header on the collab WS endpoint.
109
- - **v1.1 — deploy a hub** (Phase 9, in-flight). Cross-machine live collab needs a hub binary you deploy yourself. No tunnel mode; no shared cloud.
109
+ - **v1.1 — deploy a hub** (Phase 9, in-flight). Cross-machine live collab needs a hub binary you deploy yourself. No tunnel mode; no shared cloud. **Boot order can't eat your work** ([DDR-102](.ai/decisions/DDR-102-cold-start-divergence-resolution.md)): a per-machine journal tells clean catch-ups apart from genuine divergence; diverged canvases snapshot **both** versions to `_history/` before the newer one wins, so the loser is one `/design:rollback` away — and `maude design status` reports per-canvas sync state honestly (synced / pending / auth-rejected).
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.iagh.cz/docs/security) (source: [`site/content/docs/security.mdx`](./site/content/docs/security.mdx)). To **report** a vulnerability, see [SECURITY.md](./SECURITY.md).
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.iagh.cz once Vercel is wired — see [DDR-005](.ai/decisions/DDR-005-docs-site-stack-and-hosting.md)).
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
+ }