@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
|
@@ -139,6 +139,35 @@ const CHROME_CSS = `
|
|
|
139
139
|
white-space: normal;
|
|
140
140
|
max-width: 200px;
|
|
141
141
|
}
|
|
142
|
+
/* Phase 30 / DDR-120 — soft editing-presence. A peer (or a bridged agent)
|
|
143
|
+
actively editing this canvas gets a gently-pulsing ring + a ✎ marker in the
|
|
144
|
+
peer's OWN identity hue (--dc-edit-ring, set inline from peer.color) so the
|
|
145
|
+
agent pulses violet, not the indigo accent. A heads-up, never a lock. */
|
|
146
|
+
.dc-participant--editing { animation: dc-participant-edit-pulse 1.8s ease-in-out infinite; }
|
|
147
|
+
@keyframes dc-participant-edit-pulse {
|
|
148
|
+
0%, 100% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); }
|
|
149
|
+
50% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 4px color-mix(in oklab, var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))) 45%, transparent); }
|
|
150
|
+
}
|
|
151
|
+
.dc-participant-edit-marker {
|
|
152
|
+
position: absolute;
|
|
153
|
+
right: -3px;
|
|
154
|
+
top: -3px;
|
|
155
|
+
width: 13px;
|
|
156
|
+
height: 13px;
|
|
157
|
+
border-radius: 50%;
|
|
158
|
+
background: var(--maude-chrome-bg-0, #fff);
|
|
159
|
+
color: var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50)));
|
|
160
|
+
display: inline-flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
justify-content: center;
|
|
163
|
+
font-size: 8px;
|
|
164
|
+
line-height: 1;
|
|
165
|
+
box-shadow: 0 0 0 1px rgba(0,0,0,0.14);
|
|
166
|
+
pointer-events: none;
|
|
167
|
+
}
|
|
168
|
+
@media (prefers-reduced-motion: reduce) {
|
|
169
|
+
.dc-participant--editing { animation: none; box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); }
|
|
170
|
+
}
|
|
142
171
|
`.trim();
|
|
143
172
|
|
|
144
173
|
function ensureChromeStyles(): void {
|
|
@@ -150,6 +179,17 @@ function ensureChromeStyles(): void {
|
|
|
150
179
|
document.head.appendChild(s);
|
|
151
180
|
}
|
|
152
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Phase 30 / DDR-120 — true when a foreign peer is THIS machine's own
|
|
184
|
+
* server-side editing echo: a peer carrying my own name with editing set but
|
|
185
|
+
* no cursor/selection. That's the room's projection of my own agent/inspector
|
|
186
|
+
* edit (which I already see via the local ai-activity avatar), so I skip it. A
|
|
187
|
+
* remote peer never matches (different name), so they still get the heads-up.
|
|
188
|
+
*/
|
|
189
|
+
function isOwnEditingEcho(peer: ForeignAwareness, myName: string | undefined): boolean {
|
|
190
|
+
return peer.name === myName && !!peer.editing && !peer.cursor && !peer.selection;
|
|
191
|
+
}
|
|
192
|
+
|
|
153
193
|
function initialsFor(name: string): string {
|
|
154
194
|
const cleaned = name.trim();
|
|
155
195
|
if (!cleaned) return '?';
|
|
@@ -169,6 +209,7 @@ interface AvatarProps {
|
|
|
169
209
|
function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element {
|
|
170
210
|
const [open, setOpen] = useState(false);
|
|
171
211
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
212
|
+
const editing = !!peer.editing;
|
|
172
213
|
|
|
173
214
|
useEffect(() => {
|
|
174
215
|
if (!open) return;
|
|
@@ -180,11 +221,16 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
|
|
|
180
221
|
return () => document.removeEventListener('mousedown', onDocClick);
|
|
181
222
|
}, [open]);
|
|
182
223
|
|
|
224
|
+
const cls = `dc-participant${isFollowing ? ' dc-participant--following' : ''}${editing ? ' dc-participant--editing' : ''}`;
|
|
225
|
+
// `--dc-edit-ring` drives the editing pulse/marker off the peer's own hue.
|
|
226
|
+
const style = (
|
|
227
|
+
editing ? { background: peer.color, '--dc-edit-ring': peer.color } : { background: peer.color }
|
|
228
|
+
) as React.CSSProperties;
|
|
183
229
|
return (
|
|
184
230
|
<div
|
|
185
231
|
ref={rootRef}
|
|
186
|
-
className={
|
|
187
|
-
style={
|
|
232
|
+
className={cls}
|
|
233
|
+
style={style}
|
|
188
234
|
onClick={() => setOpen((v) => !v)}
|
|
189
235
|
onKeyDown={(e) => {
|
|
190
236
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
@@ -194,14 +240,20 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
|
|
|
194
240
|
}}
|
|
195
241
|
role="button"
|
|
196
242
|
tabIndex={0}
|
|
197
|
-
title={peer.name}
|
|
198
|
-
aria-label={peer.name}
|
|
243
|
+
title={editing ? `${peer.name} — editing this canvas` : peer.name}
|
|
244
|
+
aria-label={editing ? `${peer.name}, editing this canvas` : peer.name}
|
|
199
245
|
aria-expanded={open}
|
|
200
246
|
>
|
|
201
247
|
{initialsFor(peer.name)}
|
|
248
|
+
{editing && (
|
|
249
|
+
<span className="dc-participant-edit-marker" aria-hidden="true">
|
|
250
|
+
✎
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
202
253
|
{open && (
|
|
203
254
|
<div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
|
|
204
255
|
<div className="dc-participant-popover__name">{peer.name}</div>
|
|
256
|
+
{editing && <div className="dc-participant-popover__sub">Editing this canvas</div>}
|
|
205
257
|
<button
|
|
206
258
|
type="button"
|
|
207
259
|
className={`dc-participant-popover__btn${isFollowing ? ' dc-participant-popover__btn--stop' : ''}`}
|
|
@@ -318,13 +370,21 @@ export function ParticipantsChrome(): JSX.Element | null {
|
|
|
318
370
|
controller.setViewport(v);
|
|
319
371
|
}, [controller, followTarget, peers]);
|
|
320
372
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
373
|
+
// Phase 30 / DDR-120 — drop the authoring machine's own server-side editing
|
|
374
|
+
// echo: a foreign peer carrying MY name with editing set but no cursor/
|
|
375
|
+
// selection is the room's projection of my own agent/inspector edit, which I
|
|
376
|
+
// already see via the local ai-activity avatar. A remote peer never matches
|
|
377
|
+
// (different name), so they still get the "X is editing" heads-up.
|
|
378
|
+
const myName = collab?.myName;
|
|
379
|
+
const visiblePeers = peers.filter((p) => !isOwnEditingEcho(p, myName));
|
|
380
|
+
|
|
381
|
+
// Render whenever there's a visible human peer OR an active agent (so an agent
|
|
382
|
+
// shows even with no human collaborators connected).
|
|
383
|
+
if (visiblePeers.length === 0 && !agent) return null;
|
|
324
384
|
|
|
325
385
|
return (
|
|
326
386
|
<div className="dc-participants" aria-label="Active collaborators">
|
|
327
|
-
{
|
|
387
|
+
{visiblePeers.map((p) => (
|
|
328
388
|
<Avatar
|
|
329
389
|
key={p.clientID}
|
|
330
390
|
peer={p}
|
|
@@ -337,4 +397,4 @@ export function ParticipantsChrome(): JSX.Element | null {
|
|
|
337
397
|
);
|
|
338
398
|
}
|
|
339
399
|
|
|
340
|
-
export { initialsFor };
|
|
400
|
+
export { initialsFor, isOwnEditingEcho };
|
package/apps/studio/paths.ts
CHANGED
|
@@ -82,6 +82,18 @@ function isDevServerDir(dir: string): boolean {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function resolveDevServerRoot(): string {
|
|
85
|
+
// (0) Explicit override. Used when the runtime can't be found by walk-up — e.g.
|
|
86
|
+
// the Tauri desktop bundle (DDR-106) ships apps/studio/ as an app resource and
|
|
87
|
+
// sets MAUDE_DEV_SERVER_ROOT to it, because the sidecar binary sits alone in
|
|
88
|
+
// Maude.app/Contents/MacOS/ with no apps/studio/ up-tree (the bundle ships the
|
|
89
|
+
// full source, so `http.ts` is present at the resource root).
|
|
90
|
+
const override = process.env.MAUDE_DEV_SERVER_ROOT;
|
|
91
|
+
// Require the same `http.ts` anchor walk-up uses (not merely a `dist/` dir) so a
|
|
92
|
+
// planted dist-only directory can't hijack the runtime root (security review F4).
|
|
93
|
+
if (override && !isVirtualBunfsPath(override) && isDevServerDir(override)) {
|
|
94
|
+
return override;
|
|
95
|
+
}
|
|
96
|
+
|
|
85
97
|
// (1) Dev mode: import.meta.url is a real file:// path AND lands in the
|
|
86
98
|
// dev-server dir. Common case for `bun run server.ts`, tests, etc.
|
|
87
99
|
const importDir = getImportMetaDir();
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// First-open AI-editing readiness probe (DDR-128). Backs `GET /_api/preflight`.
|
|
2
|
+
//
|
|
3
|
+
// Read-only: it reports which pieces of the AI-editing chain are present — the
|
|
4
|
+
// `claude` CLI, the `maude` CLI, the maude marketplace + plugins registered in the
|
|
5
|
+
// paired Claude Code, and the optional `agent-browser` — with per-item remediation.
|
|
6
|
+
// It NEVER installs, links, or mutates anything (DDR-128 detect-and-guide posture).
|
|
7
|
+
//
|
|
8
|
+
// PATH accuracy: in the packaged `.app` the sidecar's PATH is corrected at the Rust
|
|
9
|
+
// boundary (apps/desktop/.../sidecar.rs, DDR-128), so `Bun.which` is accurate here;
|
|
10
|
+
// `resolveOnPath` keeps a login-shell fallback as defense-in-depth for an unusual
|
|
11
|
+
// shell config the Rust resolution missed. Under `maude design serve` the terminal
|
|
12
|
+
// PATH is already correct.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { resolveClaudePath } from './acp/probe.ts';
|
|
19
|
+
|
|
20
|
+
export type ReadinessStatus = 'present' | 'missing' | 'unknown';
|
|
21
|
+
|
|
22
|
+
export interface ReadinessItem {
|
|
23
|
+
id: 'claude' | 'maude' | 'plugins' | 'agent-browser';
|
|
24
|
+
/** Short human label for the row. */
|
|
25
|
+
label: string;
|
|
26
|
+
/** Required items gate `ready`; optional ones never block it. */
|
|
27
|
+
required: boolean;
|
|
28
|
+
status: ReadinessStatus;
|
|
29
|
+
/** One-line current-state description. */
|
|
30
|
+
detail: string;
|
|
31
|
+
/** Copy-paste-able fix, present only when the item is not satisfied. */
|
|
32
|
+
remediation?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReadinessReport {
|
|
36
|
+
/** True when every REQUIRED item is `present`. */
|
|
37
|
+
ready: boolean;
|
|
38
|
+
items: ReadinessItem[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a binary on PATH. `Bun.which` first (accurate once the sidecar PATH is
|
|
43
|
+
* Rust-corrected, and always correct under a terminal launch); a login-shell
|
|
44
|
+
* fallback recovers a binary the app env can't see when the Rust resolution missed.
|
|
45
|
+
* Unix-only fallback (Windows GUI apps inherit the user PATH). Returns an absolute
|
|
46
|
+
* path or null. `bin` is always a hardcoded literal — never user input.
|
|
47
|
+
*
|
|
48
|
+
* Async + `Bun.spawn` (not `spawnSync`): the fallback shells out for up to 5 s, so a
|
|
49
|
+
* synchronous spawn would block the single-threaded dev-server event loop for the
|
|
50
|
+
* whole probe — a real freeze on a fresh machine where the binary is missing
|
|
51
|
+
* (DDR-128 hardening, ethical-hacker F1). Awaiting yields the loop; the 5 s kill
|
|
52
|
+
* bounds a misconfigured rc.
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveOnPath(bin: string): Promise<string | null> {
|
|
55
|
+
const direct = Bun.which(bin);
|
|
56
|
+
if (direct) return direct;
|
|
57
|
+
if (process.platform === 'win32') return null;
|
|
58
|
+
try {
|
|
59
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
60
|
+
const proc = Bun.spawn([shell, '-ilc', `command -v ${bin} 2>/dev/null`], {
|
|
61
|
+
stdin: 'ignore',
|
|
62
|
+
stdout: 'pipe',
|
|
63
|
+
stderr: 'ignore',
|
|
64
|
+
});
|
|
65
|
+
const killer = setTimeout(() => {
|
|
66
|
+
try {
|
|
67
|
+
proc.kill(9);
|
|
68
|
+
} catch {
|
|
69
|
+
/* already gone */
|
|
70
|
+
}
|
|
71
|
+
}, 5000);
|
|
72
|
+
let out: string;
|
|
73
|
+
try {
|
|
74
|
+
out = await new Response(proc.stdout).text();
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(killer);
|
|
77
|
+
}
|
|
78
|
+
// Instant-prompt frameworks (powerlevel10k) can print to stdout on interactive
|
|
79
|
+
// start, so take the last line that is an absolute path to a real file.
|
|
80
|
+
const hit = out
|
|
81
|
+
.split('\n')
|
|
82
|
+
.map((l) => l.trim())
|
|
83
|
+
.reverse()
|
|
84
|
+
.find((l) => l.startsWith('/') && existsSync(l));
|
|
85
|
+
return hit ?? null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Claude Code's config dir — relocatable via `CLAUDE_CONFIG_DIR` (its own contract). */
|
|
92
|
+
function claudeConfigDir(): string {
|
|
93
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readJson<T = unknown>(path: string): T | null {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface PluginScan {
|
|
105
|
+
/** 'unknown' when the registry couldn't be read (Claude Code's internal contract). */
|
|
106
|
+
status: 'present' | 'unknown';
|
|
107
|
+
marketplace: boolean;
|
|
108
|
+
design: boolean;
|
|
109
|
+
flow: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read-only scan of Claude Code's plugin registry for the maude marketplace
|
|
114
|
+
* (`repo: 1aGh/maude`) and the `design@maude` / `flow@maude` plugins. `readFileSync`
|
|
115
|
+
* follows symlinks (a dev's `~/.claude` is symlinked into Dotfiles). Never writes,
|
|
116
|
+
* never throws — an unrecognized layout yields `status: 'unknown'`.
|
|
117
|
+
*/
|
|
118
|
+
function scanPlugins(): PluginScan {
|
|
119
|
+
const dir = claudeConfigDir();
|
|
120
|
+
const markets = readJson<Record<string, { source?: { repo?: string } }>>(
|
|
121
|
+
join(dir, 'plugins', 'known_marketplaces.json')
|
|
122
|
+
);
|
|
123
|
+
const installed = readJson<{ plugins?: Record<string, unknown> }>(
|
|
124
|
+
join(dir, 'plugins', 'installed_plugins.json')
|
|
125
|
+
);
|
|
126
|
+
if (!markets && !installed) {
|
|
127
|
+
return { status: 'unknown', marketplace: false, design: false, flow: false };
|
|
128
|
+
}
|
|
129
|
+
const marketplace =
|
|
130
|
+
!!markets &&
|
|
131
|
+
Object.values(markets).some(
|
|
132
|
+
(m) => String(m?.source?.repo ?? '').toLowerCase() === '1agh/maude'
|
|
133
|
+
);
|
|
134
|
+
const plugins = installed?.plugins ?? {};
|
|
135
|
+
const has = (key: string): boolean => {
|
|
136
|
+
const v = plugins[key];
|
|
137
|
+
return Array.isArray(v) ? v.length > 0 : !!v;
|
|
138
|
+
};
|
|
139
|
+
return { status: 'present', marketplace, design: has('design@maude'), flow: has('flow@maude') };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Side-effect-free readiness report. The three binary probes run concurrently so a
|
|
144
|
+
* fresh machine (where the login-shell fallback fires) resolves in ~one shell round
|
|
145
|
+
* trip, not three sequential ones. The backing route gates cross-origin callers
|
|
146
|
+
* (DDR-128 hardening) so this can't be turned into a spawn-storm from a drive-by page.
|
|
147
|
+
*/
|
|
148
|
+
export async function probeReadiness(): Promise<ReadinessReport> {
|
|
149
|
+
const [claude, maude, agentBrowser] = await Promise.all([
|
|
150
|
+
(async () => resolveClaudePath() ?? (await resolveOnPath('claude')))(),
|
|
151
|
+
resolveOnPath('maude'),
|
|
152
|
+
resolveOnPath('agent-browser'),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const items: ReadinessItem[] = [];
|
|
156
|
+
|
|
157
|
+
items.push({
|
|
158
|
+
id: 'claude',
|
|
159
|
+
label: 'Claude Code (the `claude` CLI)',
|
|
160
|
+
required: true,
|
|
161
|
+
status: claude ? 'present' : 'missing',
|
|
162
|
+
detail: claude
|
|
163
|
+
? 'Installed — AI editing drives it on your own Pro/Max subscription.'
|
|
164
|
+
: 'Not found on PATH.',
|
|
165
|
+
remediation: claude
|
|
166
|
+
? undefined
|
|
167
|
+
: 'Install Claude Code, then run `claude` and `/login` once. AI editing runs on your own Pro/Max subscription — never API billing.',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
items.push({
|
|
171
|
+
id: 'maude',
|
|
172
|
+
label: 'maude CLI',
|
|
173
|
+
required: true,
|
|
174
|
+
status: maude ? 'present' : 'missing',
|
|
175
|
+
detail: maude ? 'On PATH — `/design:edit` can reach its helpers.' : 'Not found on PATH.',
|
|
176
|
+
remediation: maude
|
|
177
|
+
? undefined
|
|
178
|
+
: 'Install it: `npm i -g @1agh/maude`. `/design:edit` shells out to `maude design …`, so it must be on PATH.',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const scan = scanPlugins();
|
|
182
|
+
const pluginsPresent = scan.design && scan.flow;
|
|
183
|
+
const pluginStatus: ReadinessStatus =
|
|
184
|
+
scan.status === 'unknown' ? 'unknown' : pluginsPresent ? 'present' : 'missing';
|
|
185
|
+
const missing = [!scan.design && 'design@maude', !scan.flow && 'flow@maude']
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.join(' + ');
|
|
188
|
+
items.push({
|
|
189
|
+
id: 'plugins',
|
|
190
|
+
label: 'Maude plugins in Claude Code',
|
|
191
|
+
required: true,
|
|
192
|
+
status: pluginStatus,
|
|
193
|
+
detail:
|
|
194
|
+
scan.status === 'unknown'
|
|
195
|
+
? "Couldn't read Claude Code's plugin registry — check it manually."
|
|
196
|
+
: pluginsPresent
|
|
197
|
+
? 'design@maude + flow@maude are installed.'
|
|
198
|
+
: `Missing: ${missing}${scan.marketplace ? '' : ' (and the maude marketplace)'}.`,
|
|
199
|
+
remediation:
|
|
200
|
+
pluginStatus === 'present'
|
|
201
|
+
? undefined
|
|
202
|
+
: 'In Claude Code: `/plugin marketplace add 1aGh/maude`, then `/plugin install design@maude` and `/plugin install flow@maude`.',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
items.push({
|
|
206
|
+
id: 'agent-browser',
|
|
207
|
+
label: 'agent-browser (optional)',
|
|
208
|
+
required: false,
|
|
209
|
+
status: agentBrowser ? 'present' : 'missing',
|
|
210
|
+
detail: agentBrowser
|
|
211
|
+
? 'Installed — screenshot evidence during edits.'
|
|
212
|
+
: 'Optional — richer screenshot evidence during edits.',
|
|
213
|
+
remediation: agentBrowser
|
|
214
|
+
? undefined
|
|
215
|
+
: 'Optional. Install `agent-browser` for screenshot evidence during `/design:edit`.',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const ready = items.filter((i) => i.required).every((i) => i.status === 'present');
|
|
219
|
+
return { ready, items };
|
|
220
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// scaffold-design.ts — write a minimal, BOOTABLE `.design/` into a folder (Phase 28).
|
|
2
|
+
//
|
|
3
|
+
// Used by two native-app flows: "New project" (create a GitHub repo → init a local
|
|
4
|
+
// project) and the "open a repo with no Maude design system" fallback ("set it up?").
|
|
5
|
+
// It deliberately scaffolds only the MINIMUM the dev-server needs to boot (per
|
|
6
|
+
// context.ts the only hard requirement is a `.design/` dir; config needs just
|
|
7
|
+
// `name` + `designRoot`). A real design system is still created by /design:setup-ds
|
|
8
|
+
// (agent-driven) — this just gets the project to open instead of crash-looping.
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const CONFIG_SCHEMA =
|
|
14
|
+
'https://raw.githubusercontent.com/1aGh/maude/main/apps/studio/config.schema.json';
|
|
15
|
+
|
|
16
|
+
/** Whether `dir` is already a Maude project (has `.design/config.json`). */
|
|
17
|
+
export function hasDesign(dir: string): boolean {
|
|
18
|
+
return existsSync(join(dir, '.design', 'config.json'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScaffoldResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Scaffold a minimal bootable `.design/` (no design system yet). Refuses to
|
|
27
|
+
* clobber an existing project. `name` is the human project label. */
|
|
28
|
+
export function scaffoldDesign(dir: string, name?: string): ScaffoldResult {
|
|
29
|
+
const designDir = join(dir, '.design');
|
|
30
|
+
const configPath = join(designDir, 'config.json');
|
|
31
|
+
if (existsSync(configPath)) {
|
|
32
|
+
return { ok: false, error: 'This folder is already a Maude project.' };
|
|
33
|
+
}
|
|
34
|
+
const projectName = (name && name.trim()) || basename(dir) || 'Untitled';
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(join(designDir, 'ui'), { recursive: true });
|
|
37
|
+
mkdirSync(join(designDir, 'system'), { recursive: true });
|
|
38
|
+
const config = {
|
|
39
|
+
$schema: CONFIG_SCHEMA,
|
|
40
|
+
name: projectName,
|
|
41
|
+
designRoot: '.design',
|
|
42
|
+
canvasGroups: [
|
|
43
|
+
{ label: 'Design system', path: 'system' },
|
|
44
|
+
{ label: 'UI kit', path: 'ui' },
|
|
45
|
+
],
|
|
46
|
+
designSystems: [],
|
|
47
|
+
completenessProfile: 'standard',
|
|
48
|
+
};
|
|
49
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
50
|
+
// Keep the empty group dirs in git so a fresh clone still has the structure.
|
|
51
|
+
writeFileSync(join(designDir, 'ui', '.gitkeep'), '', 'utf8');
|
|
52
|
+
writeFileSync(join(designDir, 'system', '.gitkeep'), '', 'utf8');
|
|
53
|
+
return { ok: true };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return { ok: false, error: e instanceof Error ? e.message : 'Could not set up the project.' };
|
|
56
|
+
}
|
|
57
|
+
}
|
package/apps/studio/server.ts
CHANGED
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
18
|
|
|
19
|
+
import { createAcp } from './acp/index.ts';
|
|
19
20
|
import { createActivity } from './activity.ts';
|
|
20
21
|
import { createApi } from './api.ts';
|
|
21
22
|
import { bootSelfHeal } from './boot-self-heal.ts';
|
|
22
|
-
import {
|
|
23
|
+
import { createCanvasListWatch } from './canvas-list-watch.ts';
|
|
24
|
+
import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
|
|
23
25
|
import { createGitLifecycle } from './collab/git-lifecycle.ts';
|
|
24
26
|
import { createCollab } from './collab/index.ts';
|
|
25
27
|
import { createContext } from './context.ts';
|
|
26
28
|
import { createFsWatch } from './fs-watch.ts';
|
|
29
|
+
import { createGitWatch } from './git/watch.ts';
|
|
27
30
|
import { createHttp } from './http.ts';
|
|
28
31
|
import { createInspect } from './inspect.ts';
|
|
29
32
|
import { startHeapWatch } from './mem.ts';
|
|
@@ -77,11 +80,28 @@ await inspect.load();
|
|
|
77
80
|
|
|
78
81
|
collab = createCollab(ctx, api);
|
|
79
82
|
const aiActivity = createAiActivity(ctx);
|
|
83
|
+
|
|
84
|
+
// Phase 30 — bridge agent `ai-activity` onto the per-canvas room awareness so a
|
|
85
|
+
// remote peer sees "X is editing" cross-machine. The `ai-activity` bus event is
|
|
86
|
+
// loopback-only (inspector WS); awareness is the one channel that crosses the
|
|
87
|
+
// hub. Soft heads-up — projected onto the room's own awareness slot, cleared
|
|
88
|
+
// when the activity ends/expires. No-op when no room is live for the slug.
|
|
89
|
+
ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | null }) => {
|
|
90
|
+
if (!collab) return;
|
|
91
|
+
const slug = api.fileSlug(payload.file);
|
|
92
|
+
collab.registry.setAgentEditing(
|
|
93
|
+
slug,
|
|
94
|
+
payload.entry ? { name: payload.entry.author, since: payload.entry.startedAt } : null
|
|
95
|
+
);
|
|
96
|
+
});
|
|
80
97
|
const gitLifecycle = createGitLifecycle(ctx, collab.registry);
|
|
81
98
|
// Phase 13 / DDR-029 — fs-watch-driven canvas activity overlay. Subscribes to
|
|
82
99
|
// `fs:any` and emits `activity:change`; ws.ts forwards it to canvas iframes.
|
|
83
100
|
const activity = createActivity(ctx);
|
|
84
|
-
|
|
101
|
+
// Phase 31 (DDR-123) — ACP chat bridge manager. Owns one claude-agent-acp
|
|
102
|
+
// subprocess per /_ws/acp socket; main-origin + loopback only (wired below).
|
|
103
|
+
const acp = createAcp(ctx);
|
|
104
|
+
const ws = createWs(ctx, api, inspect, collab, activity, acp);
|
|
85
105
|
const http = createHttp(ctx, api, inspect, aiActivity);
|
|
86
106
|
const fsWatch = createFsWatch(ctx);
|
|
87
107
|
|
|
@@ -141,6 +161,26 @@ function startServer(port: number): BunServer {
|
|
|
141
161
|
return new Response('Upgrade failed', { status: 400 });
|
|
142
162
|
}
|
|
143
163
|
|
|
164
|
+
// Phase 31 (DDR-123) — ACP chat bridge WS. Main origin ONLY (this server,
|
|
165
|
+
// never startCanvasServer) and loopback-guarded like collab: the bridge
|
|
166
|
+
// spawns the user's `claude` and can drive file edits, so the untrusted
|
|
167
|
+
// canvas origin and remote hosts must never reach it. Checked BEFORE the
|
|
168
|
+
// generic `/_ws` inspector branch since `/_ws/acp`.startsWith('/_ws').
|
|
169
|
+
if (pathname === '/_ws/acp') {
|
|
170
|
+
if (!isLoopbackHost(req.headers.get('host'))) {
|
|
171
|
+
return new Response('ACP chat is loopback-only', { status: 403 });
|
|
172
|
+
}
|
|
173
|
+
const ok = srv.upgrade(req, {
|
|
174
|
+
data: {
|
|
175
|
+
id: crypto.randomUUID(),
|
|
176
|
+
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
177
|
+
kind: 'acp',
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
if (ok) return undefined as unknown as Response;
|
|
181
|
+
return new Response('Upgrade failed', { status: 400 });
|
|
182
|
+
}
|
|
183
|
+
|
|
144
184
|
// Legacy inspector WS — JSON frames, designer-facing live tab state.
|
|
145
185
|
if (pathname.startsWith('/_ws')) {
|
|
146
186
|
const ok = srv.upgrade(req, {
|
|
@@ -339,6 +379,19 @@ await Bun.write(
|
|
|
339
379
|
fsWatch.start();
|
|
340
380
|
startHeapWatch();
|
|
341
381
|
|
|
382
|
+
// Phase 27 Task 5 (E2) — live dirty-state. Subscribes to `fs:any` (after
|
|
383
|
+
// fsWatch.start so it sees every event) and broadcasts `git-status` to the shell
|
|
384
|
+
// on each versionable change. No-op for a non-git project (gitStatus → repo:false).
|
|
385
|
+
const gitWatch = createGitWatch(ctx);
|
|
386
|
+
|
|
387
|
+
// Phase 31 follow-up — external-canvas list watcher. Subscribes to `fs:any` and
|
|
388
|
+
// emits `canvas-list-update` when a canvas file appears/disappears on disk from
|
|
389
|
+
// OUTSIDE the dev-server (ACP agent `/design:new`, agent Write, git checkout),
|
|
390
|
+
// so the browser file tree refreshes without a reload — the symmetric
|
|
391
|
+
// counterpart to api.ts's create/delete emit. RCA:
|
|
392
|
+
// `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
|
|
393
|
+
const canvasListWatch = createCanvasListWatch(ctx);
|
|
394
|
+
|
|
342
395
|
// Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
|
|
343
396
|
// linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
|
|
344
397
|
// off after fsWatch so the agent's bus subscription receives every fs event.
|
|
@@ -371,6 +424,16 @@ if (!process.env.NO_OPEN) {
|
|
|
371
424
|
async function shutdown() {
|
|
372
425
|
console.log('\n Stopping…');
|
|
373
426
|
fsWatch.stop();
|
|
427
|
+
try {
|
|
428
|
+
gitWatch.stop();
|
|
429
|
+
} catch {
|
|
430
|
+
/* timer cleanup is best-effort */
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
canvasListWatch.stop();
|
|
434
|
+
} catch {
|
|
435
|
+
/* timer cleanup is best-effort */
|
|
436
|
+
}
|
|
374
437
|
try {
|
|
375
438
|
activity.stop();
|
|
376
439
|
} catch {
|