@1agh/maude 0.36.1 → 0.38.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 +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +305 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +686 -35
- package/apps/studio/client/github.js +6 -0
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/CreateProject.jsx +57 -23
- package/apps/studio/client/panels/GitPanel.jsx +1 -1
- package/apps/studio/client/panels/IdentityBar.jsx +43 -2
- package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/client/tour/collab-tour.js +3 -3
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +17 -13
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/github/endpoints.ts +42 -0
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +183 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/scaffold-design.ts +95 -2
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +10 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/test/sync-agent.test.ts +9 -2
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +63 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +3 -3
package/apps/studio/readiness.ts
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
-
|
|
17
|
+
import { isNativePluginContext } from './acp/plugin-bootstrap.ts';
|
|
18
18
|
import { resolveAdapterEntry, resolveClaudePath } from './acp/probe.ts';
|
|
19
|
+
import { resolveBrowser } from './bin/_ensure-browser.mjs';
|
|
20
|
+
import { DESIGN_PLUGIN_DIR } from './paths.ts';
|
|
19
21
|
|
|
20
22
|
export type ReadinessStatus = 'present' | 'missing' | 'unknown';
|
|
21
23
|
|
|
@@ -101,7 +103,7 @@ function readJson<T = unknown>(path: string): T | null {
|
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
interface PluginScan {
|
|
106
|
+
export interface PluginScan {
|
|
105
107
|
/** 'unknown' when the registry couldn't be read (Claude Code's internal contract). */
|
|
106
108
|
status: 'present' | 'unknown';
|
|
107
109
|
marketplace: boolean;
|
|
@@ -114,8 +116,17 @@ interface PluginScan {
|
|
|
114
116
|
* (`repo: 1aGh/maude`) and the `design@maude` / `flow@maude` plugins. `readFileSync`
|
|
115
117
|
* follows symlinks (a dev's `~/.claude` is symlinked into Dotfiles). Never writes,
|
|
116
118
|
* never throws — an unrecognized layout yields `status: 'unknown'`.
|
|
119
|
+
*
|
|
120
|
+
* A plugin counts as present if EITHER the marketplace registry
|
|
121
|
+
* (`installed_plugins.json`) lists it OR the user enabled it in `settings.json`
|
|
122
|
+
* `enabledPlugins` (`"<plugin>@<marketplace>": true`). The adapter spawns the
|
|
123
|
+
* user's `claude` with `settingSources: ["user","project","local"]`, so a
|
|
124
|
+
* settings-enabled plugin loads NATIVELY in the session — the ACP plugin
|
|
125
|
+
* auto-bootstrap (DDR-143) must treat it as already-present and skip injection,
|
|
126
|
+
* or the command set double-registers. Exported so `acp/plugin-bootstrap.ts`
|
|
127
|
+
* reuses this single registry-scan instead of duplicating it.
|
|
117
128
|
*/
|
|
118
|
-
function scanPlugins(): PluginScan {
|
|
129
|
+
export function scanPlugins(): PluginScan {
|
|
119
130
|
const dir = claudeConfigDir();
|
|
120
131
|
const markets = readJson<Record<string, { source?: { repo?: string } }>>(
|
|
121
132
|
join(dir, 'plugins', 'known_marketplaces.json')
|
|
@@ -123,7 +134,12 @@ function scanPlugins(): PluginScan {
|
|
|
123
134
|
const installed = readJson<{ plugins?: Record<string, unknown> }>(
|
|
124
135
|
join(dir, 'plugins', 'installed_plugins.json')
|
|
125
136
|
);
|
|
126
|
-
|
|
137
|
+
// `settingSources:user` — a plugin the user enabled in ~/.claude/settings.json
|
|
138
|
+
// loads natively even if it's not in installed_plugins.json (DDR-143 no-op gate).
|
|
139
|
+
const settings = readJson<{ enabledPlugins?: Record<string, unknown> }>(
|
|
140
|
+
join(dir, 'settings.json')
|
|
141
|
+
);
|
|
142
|
+
if (!markets && !installed && !settings) {
|
|
127
143
|
return { status: 'unknown', marketplace: false, design: false, flow: false };
|
|
128
144
|
}
|
|
129
145
|
const marketplace =
|
|
@@ -132,9 +148,11 @@ function scanPlugins(): PluginScan {
|
|
|
132
148
|
(m) => String(m?.source?.repo ?? '').toLowerCase() === '1agh/maude'
|
|
133
149
|
);
|
|
134
150
|
const plugins = installed?.plugins ?? {};
|
|
151
|
+
const enabled = settings?.enabledPlugins ?? {};
|
|
135
152
|
const has = (key: string): boolean => {
|
|
136
153
|
const v = plugins[key];
|
|
137
|
-
|
|
154
|
+
const installedHit = Array.isArray(v) ? v.length > 0 : !!v;
|
|
155
|
+
return installedHit || enabled[key] === true;
|
|
138
156
|
};
|
|
139
157
|
return { status: 'present', marketplace, design: has('design@maude'), flow: has('flow@maude') };
|
|
140
158
|
}
|
|
@@ -179,40 +197,71 @@ export async function probeReadiness(): Promise<ReadinessReport> {
|
|
|
179
197
|
});
|
|
180
198
|
|
|
181
199
|
const scan = scanPlugins();
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
200
|
+
// DDR-143 — on the native/desktop path the ACP chat session AUTO-LOADS the
|
|
201
|
+
// bundled `design` plugin (acp/plugin-bootstrap.ts → session-scoped
|
|
202
|
+
// `_meta.claudeCode.options.plugins`), so a design plugin that isn't
|
|
203
|
+
// marketplace-installed is still available in the chat. Count that as
|
|
204
|
+
// satisfied: the check collapses from a red install-wall to green. `/flow` is
|
|
205
|
+
// intentionally NOT part of the chat for now (2026-07-03), so the gate tracks
|
|
206
|
+
// `design` alone — demanding a plugin the chat doesn't use would be a false red.
|
|
207
|
+
// The web `maude design serve` path ships no bundle (native=false, dir null) →
|
|
208
|
+
// the manual marketplace remediation still shows there.
|
|
209
|
+
const native = isNativePluginContext();
|
|
210
|
+
const designAutoloaded = native && DESIGN_PLUGIN_DIR !== null && !scan.design;
|
|
211
|
+
const designReady = scan.design || designAutoloaded;
|
|
212
|
+
const pluginStatus: ReadinessStatus = designReady
|
|
213
|
+
? 'present'
|
|
214
|
+
: scan.status === 'unknown'
|
|
215
|
+
? 'unknown'
|
|
216
|
+
: 'missing';
|
|
188
217
|
items.push({
|
|
189
218
|
id: 'plugins',
|
|
190
|
-
label: 'Maude
|
|
219
|
+
label: 'Maude design plugin in Claude Code',
|
|
191
220
|
required: true,
|
|
192
221
|
status: pluginStatus,
|
|
193
|
-
detail:
|
|
194
|
-
|
|
222
|
+
detail: designReady
|
|
223
|
+
? designAutoloaded
|
|
224
|
+
? 'Auto-loaded in the Maude chat session — nothing to install.'
|
|
225
|
+
: 'design@maude is installed.'
|
|
226
|
+
: scan.status === 'unknown'
|
|
195
227
|
? "Couldn't read Claude Code's plugin registry — check it manually."
|
|
196
|
-
:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
pluginStatus === 'present'
|
|
201
|
-
? undefined
|
|
202
|
-
: 'In Claude Code: `/plugin marketplace add 1aGh/maude`, then `/plugin install design@maude` and `/plugin install flow@maude`.',
|
|
228
|
+
: `Missing: design@maude${scan.marketplace ? '' : ' (and the maude marketplace)'}.`,
|
|
229
|
+
remediation: designReady
|
|
230
|
+
? undefined
|
|
231
|
+
: 'In Claude Code: `/plugin marketplace add 1aGh/maude`, then `/plugin install design@maude`.',
|
|
203
232
|
});
|
|
204
233
|
|
|
234
|
+
// agent-browser + a headless Chromium capture artboards so `/design:*` critics
|
|
235
|
+
// can SEE the work — effectively required for the full design workflow. On the
|
|
236
|
+
// native/desktop path agent-browser is BUNDLED (externalBin, DDR — bundled
|
|
237
|
+
// screenshots) and the browser engine is resolved (or provisioned as
|
|
238
|
+
// chrome-headless-shell on first use) by ensure-browser, so it's a first-class,
|
|
239
|
+
// satisfied capability — not a red "optional install" wall. On web/CLI it stays
|
|
240
|
+
// optional (the user brings agent-browser or playwright).
|
|
241
|
+
let browserReady = false;
|
|
242
|
+
if (native) {
|
|
243
|
+
try {
|
|
244
|
+
browserReady = !!(await resolveBrowser({ download: false })).path;
|
|
245
|
+
} catch {
|
|
246
|
+
/* best-effort — the browser provisions at screenshot time regardless */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
205
249
|
items.push({
|
|
206
250
|
id: 'agent-browser',
|
|
207
|
-
label: 'agent-browser (optional)',
|
|
208
|
-
required:
|
|
209
|
-
status: agentBrowser ? 'present' : 'missing',
|
|
210
|
-
detail:
|
|
211
|
-
?
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
251
|
+
label: native ? 'Screenshot engine (design critics)' : 'agent-browser (optional)',
|
|
252
|
+
required: native,
|
|
253
|
+
status: native || agentBrowser ? 'present' : 'missing',
|
|
254
|
+
detail: native
|
|
255
|
+
? browserReady
|
|
256
|
+
? 'Bundled — artboard screenshots for `/design:*` critics are ready.'
|
|
257
|
+
: 'Bundled — the browser engine downloads on your first screenshot (~94 MB, one-time).'
|
|
258
|
+
: agentBrowser
|
|
259
|
+
? 'Installed — screenshot evidence during edits.'
|
|
260
|
+
: 'Optional — richer screenshot evidence during edits.',
|
|
261
|
+
remediation:
|
|
262
|
+
native || agentBrowser
|
|
263
|
+
? undefined
|
|
264
|
+
: 'Optional. Install `agent-browser` for screenshot evidence during `/design:edit`.',
|
|
216
265
|
});
|
|
217
266
|
|
|
218
267
|
// The chat bridge itself: the adapter must be resolvable on disk. This is what
|
|
@@ -13,6 +13,93 @@ import { basename, join } from 'node:path';
|
|
|
13
13
|
const CONFIG_SCHEMA =
|
|
14
14
|
'https://raw.githubusercontent.com/1aGh/maude/main/apps/studio/config.schema.json';
|
|
15
15
|
|
|
16
|
+
// A neutral, token-free starter canvas seeded into `ui/` so a freshly-created project
|
|
17
|
+
// opens to a real artboard instead of an empty studio (the "empty-studio after Start a
|
|
18
|
+
// new project" onboarding gap). It depends ONLY on @maude/canvas-lib + inline styles —
|
|
19
|
+
// no design-system tokens — so it renders before /design:setup-ds exists. The button
|
|
20
|
+
// vocabulary mirrors the shipped GitPanel (Save version · Publish · Get latest).
|
|
21
|
+
const STARTER_CANVAS_TSX = `/**
|
|
22
|
+
* @canvas welcome · Your first canvas — seeded when a new Maude project is created.
|
|
23
|
+
* @platform desktop
|
|
24
|
+
* @stack React 19 · TSX · Bun.build
|
|
25
|
+
*
|
|
26
|
+
* Neutral + token-free so it renders the moment the project is created — before
|
|
27
|
+
* /design:setup-ds builds a design system. Edit it, replace it, or delete it once you
|
|
28
|
+
* have your own canvases.
|
|
29
|
+
*/
|
|
30
|
+
import { DCArtboard, DCSection, DesignCanvas } from "@maude/canvas-lib";
|
|
31
|
+
|
|
32
|
+
export default function Welcome() {
|
|
33
|
+
return (
|
|
34
|
+
<DesignCanvas>
|
|
35
|
+
<DCSection id="welcome" title="Welcome to Maude" subtitle="START HERE">
|
|
36
|
+
<DCArtboard id="welcome" label="WELCOME/01" width={680} height={440}>
|
|
37
|
+
<div
|
|
38
|
+
data-testid="welcome-artboard-content"
|
|
39
|
+
style={{
|
|
40
|
+
height: "100%",
|
|
41
|
+
boxSizing: "border-box",
|
|
42
|
+
padding: 48,
|
|
43
|
+
display: "flex",
|
|
44
|
+
flexDirection: "column",
|
|
45
|
+
gap: 24,
|
|
46
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
47
|
+
color: "#16181d",
|
|
48
|
+
background: "#fbfbfc",
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
52
|
+
<span style={{ fontSize: 12, letterSpacing: "0.12em", textTransform: "uppercase", color: "#8a8f98" }}>
|
|
53
|
+
Your first canvas
|
|
54
|
+
</span>
|
|
55
|
+
<h1 style={{ margin: 0, fontSize: 30, lineHeight: 1.1, fontWeight: 650 }}>
|
|
56
|
+
You're in. This is a canvas.
|
|
57
|
+
</h1>
|
|
58
|
+
<p style={{ margin: 0, fontSize: 15, lineHeight: 1.5, color: "#4a4f57", maxWidth: 460 }}>
|
|
59
|
+
Everything you design lives on canvases like this one. Edit it, or start fresh —
|
|
60
|
+
your work saves on its own, and you can version and share it without ever opening a terminal.
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
<div style={{ display: "flex", gap: 12 }}>
|
|
64
|
+
{[
|
|
65
|
+
["Save version", "keep a checkpoint, just for you"],
|
|
66
|
+
["Publish", "share it with your team"],
|
|
67
|
+
["Get latest", "pull in everyone else's work"],
|
|
68
|
+
].map(([title, desc]) => (
|
|
69
|
+
<div key={title} style={{ flex: 1, padding: 16, border: "1px solid #e6e7ea", borderRadius: 10, background: "#fff" }}>
|
|
70
|
+
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{title}</div>
|
|
71
|
+
<div style={{ fontSize: 12.5, lineHeight: 1.4, color: "#6b7078" }}>{desc}</div>
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.5, color: "#8a8f98" }}>
|
|
76
|
+
Next: build a design system, or just start editing this canvas.
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</DCArtboard>
|
|
80
|
+
</DCSection>
|
|
81
|
+
</DesignCanvas>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const STARTER_CANVAS_META = {
|
|
87
|
+
title: 'Welcome',
|
|
88
|
+
subtitle: 'Your first canvas — seeded on project creation',
|
|
89
|
+
brief:
|
|
90
|
+
'Neutral, token-free welcome canvas written by scaffoldDesign() so a new project opens to a real artboard instead of an empty studio. Safe to edit or delete.',
|
|
91
|
+
platform: 'desktop',
|
|
92
|
+
sections: [
|
|
93
|
+
{
|
|
94
|
+
id: 'welcome',
|
|
95
|
+
title: 'Welcome to Maude · START HERE',
|
|
96
|
+
artboards: [{ id: 'welcome', label: 'WELCOME/01', width: 680, height: 440 }],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
css_mode: 'inline',
|
|
100
|
+
layout: { artboards: [{ id: 'welcome', x: 0, y: 0 }] },
|
|
101
|
+
};
|
|
102
|
+
|
|
16
103
|
/** Whether `dir` is already a Maude project (has `.design/config.json`). */
|
|
17
104
|
export function hasDesign(dir: string): boolean {
|
|
18
105
|
return existsSync(join(dir, '.design', 'config.json'));
|
|
@@ -47,8 +134,14 @@ export function scaffoldDesign(dir: string, name?: string): ScaffoldResult {
|
|
|
47
134
|
completenessProfile: 'standard',
|
|
48
135
|
};
|
|
49
136
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
50
|
-
//
|
|
51
|
-
writeFileSync(join(designDir, 'ui', '.
|
|
137
|
+
// Seed a starter canvas so the studio opens to a real artboard, not an empty list.
|
|
138
|
+
writeFileSync(join(designDir, 'ui', 'Welcome.tsx'), STARTER_CANVAS_TSX, 'utf8');
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(designDir, 'ui', 'Welcome.meta.json'),
|
|
141
|
+
`${JSON.stringify(STARTER_CANVAS_META, null, 2)}\n`,
|
|
142
|
+
'utf8'
|
|
143
|
+
);
|
|
144
|
+
// `system/` is genuinely empty until /design:setup-ds — keep it in git.
|
|
52
145
|
writeFileSync(join(designDir, 'system', '.gitkeep'), '', 'utf8');
|
|
53
146
|
return { ok: true };
|
|
54
147
|
} catch (e) {
|
package/apps/studio/server.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { createHttp } from './http.ts';
|
|
|
31
31
|
import { createInspect } from './inspect.ts';
|
|
32
32
|
import { startHeapWatch } from './mem.ts';
|
|
33
33
|
import { createSyncRuntime } from './sync/index.ts';
|
|
34
|
-
import { createWs, isLoopbackHost, parseCollabSlug, type WsData } from './ws.ts';
|
|
34
|
+
import { createWs, isLoopbackHost, isSameOriginWs, parseCollabSlug, type WsData } from './ws.ts';
|
|
35
35
|
|
|
36
36
|
// Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
|
|
37
37
|
// node_modules/ ships empty (git clone honors .gitignore). Auto-installs +
|
|
@@ -100,7 +100,10 @@ const gitLifecycle = createGitLifecycle(ctx, collab.registry);
|
|
|
100
100
|
const activity = createActivity(ctx);
|
|
101
101
|
// Phase 31 (DDR-123) — ACP chat bridge manager. Owns one claude-agent-acp
|
|
102
102
|
// subprocess per /_ws/acp socket; main-origin + loopback only (wired below).
|
|
103
|
-
|
|
103
|
+
// Gets the ai-activity registry so agent edits raise the same "Claude is
|
|
104
|
+
// editing" banner + presence as /design:edit (RC5,
|
|
105
|
+
// rca/issue-canvas-hmr-optimistic-update-consistency).
|
|
106
|
+
const acp = createAcp(ctx, aiActivity);
|
|
104
107
|
const ws = createWs(ctx, api, inspect, collab, activity, acp);
|
|
105
108
|
const http = createHttp(ctx, api, inspect, aiActivity);
|
|
106
109
|
const fsWatch = createFsWatch(ctx);
|
|
@@ -170,6 +173,12 @@ function startServer(port: number): BunServer {
|
|
|
170
173
|
if (!isLoopbackHost(req.headers.get('host'))) {
|
|
171
174
|
return new Response('ACP chat is loopback-only', { status: 403 });
|
|
172
175
|
}
|
|
176
|
+
// CSWSH defense: a WS handshake bypasses SOP, so loopback-Host alone would
|
|
177
|
+
// let a cross-origin drive-by open this privileged socket (spawns `claude`,
|
|
178
|
+
// drives edits). Reject any Origin that isn't this same loopback server.
|
|
179
|
+
if (!isSameOriginWs(req)) {
|
|
180
|
+
return new Response('ACP chat is same-origin only', { status: 403 });
|
|
181
|
+
}
|
|
173
182
|
const ok = srv.upgrade(req, {
|
|
174
183
|
data: {
|
|
175
184
|
id: crypto.randomUUID(),
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// RCA issue-acp-subagent-activity-invisible — the ACP chat "still working"
|
|
2
|
+
// indicator must survive the premature turn-end that claude-agent-acp emits
|
|
3
|
+
// while background subagents are still running (adapter settles the prompt at
|
|
4
|
+
// the main agent's `result`, #773). These lock the pure activity reducer +
|
|
5
|
+
// label so a background subagent stays visible instead of the panel looking idle.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
activityLabel,
|
|
11
|
+
applyUpdate,
|
|
12
|
+
isSubagentTool,
|
|
13
|
+
reduceActivity,
|
|
14
|
+
} from '../client/panels/acp-runtime.js';
|
|
15
|
+
|
|
16
|
+
const call = (id: string, toolName?: string, over: Record<string, unknown> = {}) => ({
|
|
17
|
+
t: 'update' as const,
|
|
18
|
+
update: {
|
|
19
|
+
sessionUpdate: 'tool_call',
|
|
20
|
+
toolCallId: id,
|
|
21
|
+
title: over.title,
|
|
22
|
+
kind: over.kind,
|
|
23
|
+
...(toolName ? { _meta: { claudeCode: { toolName } } } : {}),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const done = (id: string, status = 'completed') => ({
|
|
27
|
+
t: 'update' as const,
|
|
28
|
+
update: { sessionUpdate: 'tool_call_update', toolCallId: id, status },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('reduceActivity', () => {
|
|
32
|
+
test('a Task tool_call survives turn-end — the background subagent stays tracked', () => {
|
|
33
|
+
const m = new Map();
|
|
34
|
+
expect(
|
|
35
|
+
reduceActivity(m, call('t1', 'Task', { title: 'map the acp module', kind: 'think' }))
|
|
36
|
+
).toBe(true);
|
|
37
|
+
expect(m.size).toBe(1);
|
|
38
|
+
|
|
39
|
+
// The regression this fix exists for: turn-end must NOT wipe the map.
|
|
40
|
+
expect(reduceActivity(m, { t: 'turn-end', stopReason: 'end_turn' })).toBe(false);
|
|
41
|
+
expect(m.size).toBe(1);
|
|
42
|
+
|
|
43
|
+
// …it drains on the subagent's OWN completed update, not on turn-end.
|
|
44
|
+
expect(reduceActivity(m, done('t1'))).toBe(true);
|
|
45
|
+
expect(m.size).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a hard error still wipes the map (teardown)', () => {
|
|
49
|
+
const m = new Map();
|
|
50
|
+
reduceActivity(m, call('t1', 'Read', { kind: 'read' }));
|
|
51
|
+
expect(reduceActivity(m, { t: 'error', message: 'boom' })).toBe(true);
|
|
52
|
+
expect(m.size).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('captures the concrete tool name for subagent classification', () => {
|
|
56
|
+
const m = new Map();
|
|
57
|
+
reduceActivity(m, call('t1', 'Task', { title: 'x', kind: 'think' }));
|
|
58
|
+
reduceActivity(m, call('t2', 'Read', { title: 'foo.ts', kind: 'read' }));
|
|
59
|
+
const tools = [...m.values()];
|
|
60
|
+
expect(isSubagentTool(tools[0])).toBe(true);
|
|
61
|
+
expect(isSubagentTool(tools[1])).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('failed updates remove the tool; unknown ids are a no-op', () => {
|
|
65
|
+
const m = new Map();
|
|
66
|
+
reduceActivity(m, call('t1', 'Bash', { kind: 'execute' }));
|
|
67
|
+
expect(reduceActivity(m, done('nope'))).toBe(false);
|
|
68
|
+
expect(reduceActivity(m, done('t1', 'failed'))).toBe(true);
|
|
69
|
+
expect(m.size).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('activityLabel', () => {
|
|
74
|
+
test('subagents get explicit, counted wording', () => {
|
|
75
|
+
expect(activityLabel([{ toolName: 'Task', title: 'a' }])).toBe('1 subagent running');
|
|
76
|
+
expect(activityLabel([{ toolName: 'Task' }, { toolName: 'Agent' }])).toBe(
|
|
77
|
+
'2 subagents running'
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('non-subagent tools fall back to title / count / Working…', () => {
|
|
82
|
+
expect(activityLabel([{ title: 'Edit foo.ts' }])).toBe('Edit foo.ts');
|
|
83
|
+
expect(activityLabel([{ title: 'a' }, { title: 'b' }])).toBe('2 tasks running');
|
|
84
|
+
expect(activityLabel([])).toBe('Working…');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// The F2 fix: the SAME reducer folds live-turn frames AND the post-turn-end
|
|
89
|
+
// "background continuation" frames the client used to drop. If this drifts, the
|
|
90
|
+
// dropped subagent results / consolidation render wrong (or not at all).
|
|
91
|
+
describe('applyUpdate (shared part reducer)', () => {
|
|
92
|
+
const chunk = (text: string) => ({
|
|
93
|
+
sessionUpdate: 'agent_message_chunk',
|
|
94
|
+
content: { type: 'text', text },
|
|
95
|
+
});
|
|
96
|
+
const call = (id: string, title: string, kind: string, rawInput: unknown = {}) => ({
|
|
97
|
+
sessionUpdate: 'tool_call',
|
|
98
|
+
toolCallId: id,
|
|
99
|
+
title,
|
|
100
|
+
kind,
|
|
101
|
+
rawInput,
|
|
102
|
+
});
|
|
103
|
+
const upd = (id: string, status: string, rawOutput: unknown) => ({
|
|
104
|
+
sessionUpdate: 'tool_call_update',
|
|
105
|
+
toolCallId: id,
|
|
106
|
+
status,
|
|
107
|
+
rawOutput,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('coalesces consecutive text chunks into one part', () => {
|
|
111
|
+
const parts: unknown[] = [];
|
|
112
|
+
const ti = new Map();
|
|
113
|
+
applyUpdate(parts, ti, chunk('Panel: '));
|
|
114
|
+
applyUpdate(parts, ti, chunk('9 blockers'));
|
|
115
|
+
expect(parts).toEqual([{ type: 'text', text: 'Panel: 9 blockers' }]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('tool_call adds a pending part; tool_call_update fills the result', () => {
|
|
119
|
+
const parts: Array<Record<string, unknown>> = [];
|
|
120
|
+
const ti = new Map();
|
|
121
|
+
applyUpdate(parts, ti, call('t1', 'Write critique/001.md', 'edit', { path: 'x' }));
|
|
122
|
+
expect(parts[0]).toMatchObject({ type: 'tool-call', toolName: 'Write critique/001.md' });
|
|
123
|
+
expect(parts[0].result).toBeUndefined();
|
|
124
|
+
applyUpdate(parts, ti, upd('t1', 'completed', { ok: true }));
|
|
125
|
+
expect(parts[0]).toMatchObject({ result: { ok: true }, isError: false });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('failed update marks isError', () => {
|
|
129
|
+
const parts: Array<Record<string, unknown>> = [];
|
|
130
|
+
const ti = new Map();
|
|
131
|
+
applyUpdate(parts, ti, call('t1', 'Bash', 'execute'));
|
|
132
|
+
applyUpdate(parts, ti, upd('t1', 'failed', null));
|
|
133
|
+
expect(parts[0].isError).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('thought → reasoning part; usage/plan ignored', () => {
|
|
137
|
+
const parts: unknown[] = [];
|
|
138
|
+
const ti = new Map();
|
|
139
|
+
applyUpdate(parts, ti, {
|
|
140
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
141
|
+
content: { type: 'text', text: 'hmm' },
|
|
142
|
+
});
|
|
143
|
+
applyUpdate(parts, ti, { sessionUpdate: 'usage_update' });
|
|
144
|
+
applyUpdate(parts, ti, { sessionUpdate: 'plan' });
|
|
145
|
+
expect(parts).toEqual([{ type: 'reasoning', text: 'hmm' }]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('update for an unknown tool id is a no-op', () => {
|
|
149
|
+
const parts: unknown[] = [];
|
|
150
|
+
const ti = new Map();
|
|
151
|
+
applyUpdate(parts, ti, upd('nope', 'completed', {}));
|
|
152
|
+
expect(parts).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// RC5 (rca/issue-canvas-hmr-optimistic-update-consistency) — the ACP chat agent
|
|
2
|
+
// must raise the same "Claude is editing" ai-activity banner as /design:edit.
|
|
3
|
+
// The tracker watches streamed tool_call / tool_call_update notifications for
|
|
4
|
+
// edit-kind tools touching canvas files and drives start/heartbeat/end.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import type { SessionUpdate } from '@agentclientprotocol/sdk';
|
|
10
|
+
|
|
11
|
+
import { createAgentActivityTracker } from '../acp/index.ts';
|
|
12
|
+
import type { AiActivity, AiActivityEntry } from '../collab/ai-activity.ts';
|
|
13
|
+
import { type Context, createBus } from '../context.ts';
|
|
14
|
+
|
|
15
|
+
const ROOT = join('/tmp', 'acp-ai-root');
|
|
16
|
+
const DESIGN = join(ROOT, '.design');
|
|
17
|
+
|
|
18
|
+
function mkCtx(): Context {
|
|
19
|
+
return {
|
|
20
|
+
cfg: {} as Context['cfg'],
|
|
21
|
+
projectLabel: 'test',
|
|
22
|
+
bus: createBus(),
|
|
23
|
+
paths: {
|
|
24
|
+
repoRoot: ROOT,
|
|
25
|
+
designRel: '.design',
|
|
26
|
+
designRoot: DESIGN,
|
|
27
|
+
serverInfoFile: join(DESIGN, '_server.json'),
|
|
28
|
+
activeFile: join(DESIGN, '_active.json'),
|
|
29
|
+
commentsDir: join(DESIGN, '_comments'),
|
|
30
|
+
canvasStateDir: join(DESIGN, '_canvas-state'),
|
|
31
|
+
historyDir: join(DESIGN, '_history'),
|
|
32
|
+
tokensUrlRel: '',
|
|
33
|
+
systemDirRel: 'system',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mkAi(): { ai: AiActivity; calls: string[] } {
|
|
39
|
+
const calls: string[] = [];
|
|
40
|
+
const entry = (file: string): AiActivityEntry => ({
|
|
41
|
+
file,
|
|
42
|
+
author: 'x',
|
|
43
|
+
startedAt: 0,
|
|
44
|
+
lastHeartbeat: 0,
|
|
45
|
+
});
|
|
46
|
+
const ai: AiActivity = {
|
|
47
|
+
start(file, author) {
|
|
48
|
+
calls.push(`start:${file}:${author}`);
|
|
49
|
+
return entry(file);
|
|
50
|
+
},
|
|
51
|
+
heartbeat(file) {
|
|
52
|
+
calls.push(`beat:${file}`);
|
|
53
|
+
return entry(file);
|
|
54
|
+
},
|
|
55
|
+
end(file) {
|
|
56
|
+
calls.push(`end:${file}`);
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
list: () => [],
|
|
60
|
+
get: () => null,
|
|
61
|
+
stop: () => {},
|
|
62
|
+
};
|
|
63
|
+
return { ai, calls };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const upd = (u: Record<string, unknown>): SessionUpdate => u as unknown as SessionUpdate;
|
|
67
|
+
|
|
68
|
+
describe('acp / agent activity tracker', () => {
|
|
69
|
+
test('edit tool_call on a canvas starts ai-activity with the designRel key', () => {
|
|
70
|
+
const { ai, calls } = mkAi();
|
|
71
|
+
const t = createAgentActivityTracker(mkCtx(), ai);
|
|
72
|
+
t.onUpdate(
|
|
73
|
+
upd({
|
|
74
|
+
sessionUpdate: 'tool_call',
|
|
75
|
+
toolCallId: 't1',
|
|
76
|
+
kind: 'edit',
|
|
77
|
+
locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }],
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
expect(calls).toHaveLength(1);
|
|
81
|
+
expect(calls[0]).toStartWith('start:.design/ui/Foo.tsx:');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('follow-up update for the same file inside the beat throttle adds nothing', () => {
|
|
85
|
+
const { ai, calls } = mkAi();
|
|
86
|
+
const t = createAgentActivityTracker(mkCtx(), ai);
|
|
87
|
+
const loc = { locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }] };
|
|
88
|
+
t.onUpdate(upd({ sessionUpdate: 'tool_call', toolCallId: 't1', kind: 'edit', ...loc }));
|
|
89
|
+
// kind omitted on the update — must be recalled from the tool_call by id.
|
|
90
|
+
t.onUpdate(upd({ sessionUpdate: 'tool_call_update', toolCallId: 't1', ...loc }));
|
|
91
|
+
expect(calls).toHaveLength(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('read/search tools and non-canvas paths never banner', () => {
|
|
95
|
+
const { ai, calls } = mkAi();
|
|
96
|
+
const t = createAgentActivityTracker(mkCtx(), ai);
|
|
97
|
+
t.onUpdate(
|
|
98
|
+
upd({
|
|
99
|
+
sessionUpdate: 'tool_call',
|
|
100
|
+
toolCallId: 'r1',
|
|
101
|
+
kind: 'read',
|
|
102
|
+
locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }],
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
t.onUpdate(
|
|
106
|
+
upd({
|
|
107
|
+
sessionUpdate: 'tool_call',
|
|
108
|
+
toolCallId: 'e2',
|
|
109
|
+
kind: 'edit',
|
|
110
|
+
locations: [{ path: join(ROOT, 'src', 'App.tsx') }], // outside designRoot
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
t.onUpdate(
|
|
114
|
+
upd({
|
|
115
|
+
sessionUpdate: 'tool_call',
|
|
116
|
+
toolCallId: 'e3',
|
|
117
|
+
kind: 'edit',
|
|
118
|
+
locations: [{ path: join(DESIGN, '_history', 'x', 'Foo.tsx') }], // runtime state
|
|
119
|
+
})
|
|
120
|
+
);
|
|
121
|
+
t.onUpdate(
|
|
122
|
+
upd({
|
|
123
|
+
sessionUpdate: 'tool_call',
|
|
124
|
+
toolCallId: 'e4',
|
|
125
|
+
kind: 'edit',
|
|
126
|
+
locations: [{ path: join(DESIGN, 'ui', 'Foo.meta.json') }], // not a canvas
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
expect(calls).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('rawInput.file_path works when locations are absent', () => {
|
|
133
|
+
const { ai, calls } = mkAi();
|
|
134
|
+
const t = createAgentActivityTracker(mkCtx(), ai);
|
|
135
|
+
t.onUpdate(
|
|
136
|
+
upd({
|
|
137
|
+
sessionUpdate: 'tool_call',
|
|
138
|
+
toolCallId: 'w1',
|
|
139
|
+
kind: 'edit',
|
|
140
|
+
rawInput: { file_path: join(DESIGN, 'ui', 'Bar.tsx') },
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
expect(calls).toHaveLength(1);
|
|
144
|
+
expect(calls[0]).toStartWith('start:.design/ui/Bar.tsx:');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('endTurn ends every banner this turn raised', () => {
|
|
148
|
+
const { ai, calls } = mkAi();
|
|
149
|
+
const t = createAgentActivityTracker(mkCtx(), ai);
|
|
150
|
+
t.onUpdate(
|
|
151
|
+
upd({
|
|
152
|
+
sessionUpdate: 'tool_call',
|
|
153
|
+
toolCallId: 't1',
|
|
154
|
+
kind: 'edit',
|
|
155
|
+
locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }],
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
t.onUpdate(
|
|
159
|
+
upd({
|
|
160
|
+
sessionUpdate: 'tool_call',
|
|
161
|
+
toolCallId: 't2',
|
|
162
|
+
kind: 'edit',
|
|
163
|
+
locations: [{ path: join(DESIGN, 'ui', 'Bar.tsx') }],
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
t.endTurn();
|
|
167
|
+
expect(calls.filter((c) => c.startsWith('end:'))).toEqual([
|
|
168
|
+
'end:.design/ui/Foo.tsx',
|
|
169
|
+
'end:.design/ui/Bar.tsx',
|
|
170
|
+
]);
|
|
171
|
+
// A later turn on the same file must start (not silently beat) again.
|
|
172
|
+
t.onUpdate(
|
|
173
|
+
upd({
|
|
174
|
+
sessionUpdate: 'tool_call',
|
|
175
|
+
toolCallId: 't3',
|
|
176
|
+
kind: 'edit',
|
|
177
|
+
locations: [{ path: join(DESIGN, 'ui', 'Foo.tsx') }],
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
expect(calls.filter((c) => c.startsWith('start:'))).toHaveLength(3);
|
|
181
|
+
});
|
|
182
|
+
});
|