@1agh/maude 0.45.1 → 0.45.2
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/apps/studio/acp/bridge.ts +585 -73
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +802 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +71 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +648 -11
- package/apps/studio/dist/client.bundle.js +1596 -1594
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +33 -2
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/whats-new.json +34 -0
- package/cli/commands/design.mjs +107 -2
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
package/apps/studio/acp/index.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
AvailableCommand,
|
|
11
|
+
CreateElicitationRequest,
|
|
12
|
+
SessionUpdate,
|
|
13
|
+
} from '@agentclientprotocol/sdk';
|
|
10
14
|
import type { ServerWebSocket } from 'bun';
|
|
11
15
|
|
|
12
16
|
import { isCanvasFile } from '../activity.ts';
|
|
@@ -14,22 +18,31 @@ import type { AiActivity } from '../collab/ai-activity.ts';
|
|
|
14
18
|
import type { Context } from '../context.ts';
|
|
15
19
|
import type { WsData } from '../ws.ts';
|
|
16
20
|
import { buildStudioBrief } from './bootstrap-brief.ts';
|
|
17
|
-
import { AcpBridge, type
|
|
21
|
+
import { AcpBridge, type BridgeUsage } from './bridge.ts';
|
|
18
22
|
import { isNativePluginContext, resolveSessionPlugins } from './plugin-bootstrap.ts';
|
|
19
23
|
import { probeAcpAvailability } from './probe.ts';
|
|
20
24
|
|
|
21
|
-
const VALID_EFFORT = new Set(['fast', 'balanced', 'thorough']);
|
|
22
|
-
// Model is set as ANTHROPIC_MODEL on the spawned child — allowlist it server-side
|
|
23
|
-
// (security review F1) so the loopback WS frame can't pin an arbitrary value.
|
|
24
|
-
const VALID_MODELS = new Set(['opus', 'sonnet', 'haiku']);
|
|
25
|
-
|
|
26
25
|
/**
|
|
27
|
-
* Browser → server frames: `{ t: 'prompt', text,
|
|
28
|
-
* `{ t: 'warm', chat?, model?, effort? }` (spawn +
|
|
29
|
-
* publishes its slash-command catalogue — no
|
|
26
|
+
* Browser → server frames: `{ t: 'prompt', text, chat?, model?, effort?, mode? }`,
|
|
27
|
+
* `{ t: 'cancel' }`, `{ t: 'warm', chat?, model?, effort?, mode? }` (spawn +
|
|
28
|
+
* create the session so the agent publishes its slash-command catalogue — no
|
|
29
|
+
* prompt sent), `{ t: 'set-mode', chat?, modeId }`, `{ t: 'set-config', chat?,
|
|
30
|
+
* configId, value }` (live change on an already-established session —
|
|
31
|
+
* feature-acp-panel-dynamic-claude-code-capabilities), `{ t: 'permission-response',
|
|
32
|
+
* id, decision }` (Milestone B), `{ t: 'elicitation-response', id, action,
|
|
33
|
+
* content? }` (feature-acp-ask-user-question — `action` is `accept`/`decline`/
|
|
34
|
+
* `cancel`; `content` only meaningful alongside `accept`).
|
|
30
35
|
* Server → browser frames: `ready` (availability on open), `connected` (session
|
|
31
36
|
* live), `update` (each streamed session/update), `commands` (the agent's
|
|
32
|
-
* `available_commands_update` catalogue — cached + replayed on open), `
|
|
37
|
+
* `available_commands_update` catalogue — cached + replayed on open), `caps`
|
|
38
|
+
* (the session's mode roster + config-option set — dynamic, never hardcoded),
|
|
39
|
+
* `session-info` (agent-generated chat title), `permission-request` (Milestone B
|
|
40
|
+
* approve/deny gate), `elicitation-request` (feature-acp-ask-user-question —
|
|
41
|
+
* `AskUserQuestion` + any MCP-server-originated form), `elicitation-resolved`
|
|
42
|
+
* (a pending elicitation settled via ANY path, including one the client never
|
|
43
|
+
* initiated — timeout/cancel/stop — so the client can drop a now-dead pending
|
|
44
|
+
* card instead of leaving it stuck), `usage` (context-window + cost +
|
|
45
|
+
* rate-limit, Milestone D — cached + replayed on open), `turn-end`,
|
|
33
46
|
* `permission`, `error`.
|
|
34
47
|
*/
|
|
35
48
|
export interface Acp {
|
|
@@ -130,6 +143,10 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
130
143
|
// on the second panel-open without re-warming. Not persisted (static list in
|
|
131
144
|
// the client covers a cold process); avoids DDR-115 runtime-state churn.
|
|
132
145
|
let latestCommands: AvailableCommand[] = [];
|
|
146
|
+
// Milestone D — same replay-on-open treatment for the last-seen usage
|
|
147
|
+
// snapshot, so a freshly-opened socket shows SOMETHING immediately instead
|
|
148
|
+
// of waiting for the chat's first turn to complete.
|
|
149
|
+
let latestUsage: BridgeUsage | null = null;
|
|
133
150
|
|
|
134
151
|
function send(ws: ServerWebSocket<WsData>, payload: unknown): void {
|
|
135
152
|
try {
|
|
@@ -169,10 +186,45 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
169
186
|
send(ws, { t: 'update', update });
|
|
170
187
|
},
|
|
171
188
|
onPermission: (req) => send(ws, { t: 'permission', toolCall: req.toolCall }),
|
|
189
|
+
onPermissionRequest: (id, req) =>
|
|
190
|
+
send(ws, { t: 'permission-request', id, toolCall: req.toolCall, options: req.options }),
|
|
191
|
+
onElicitationRequest: (id, req: CreateElicitationRequest) =>
|
|
192
|
+
send(ws, {
|
|
193
|
+
t: 'elicitation-request',
|
|
194
|
+
id,
|
|
195
|
+
message: req.message,
|
|
196
|
+
mode: req.mode,
|
|
197
|
+
// `req.mode === 'form'` is guaranteed by the bridge (it declines
|
|
198
|
+
// any other mode before ever calling onElicitationRequest — see
|
|
199
|
+
// the SECURITY comment in bridge.ts) — this ternary exists for
|
|
200
|
+
// TYPE narrowing (requestedSchema only exists on the form variant
|
|
201
|
+
// of the CreateElicitationRequest union), not as a runtime guard.
|
|
202
|
+
requestedSchema: req.mode === 'form' ? req.requestedSchema : undefined,
|
|
203
|
+
// Forwarded so the client can attribute the request to the actual
|
|
204
|
+
// tool call it's scoped to (when present) instead of a blanket
|
|
205
|
+
// "from Claude" label — ethical-hacker finding: `toolCallId` is
|
|
206
|
+
// present on `ElicitationSessionScope` and was being silently
|
|
207
|
+
// dropped, even though ElicitationPrompt.jsx had nothing else to
|
|
208
|
+
// disambiguate a built-in AskUserQuestion form from an arbitrary
|
|
209
|
+
// connected MCP server's form.
|
|
210
|
+
toolCallId: 'toolCallId' in req ? req.toolCallId : undefined,
|
|
211
|
+
}),
|
|
212
|
+
// A pending elicitation settled via ANY path, including one the
|
|
213
|
+
// client never initiated (a bridge-side timeout, cancel(), stop()) —
|
|
214
|
+
// tells the client to drop it from its own pending list even though
|
|
215
|
+
// it didn't send the `elicitation-response` itself. See the doc
|
|
216
|
+
// comment on `onElicitationSettled` in bridge.ts.
|
|
217
|
+
onElicitationSettled: (id) => send(ws, { t: 'elicitation-resolved', id }),
|
|
172
218
|
onCommands: (commands) => {
|
|
173
219
|
latestCommands = commands;
|
|
174
220
|
send(ws, { t: 'commands', commands });
|
|
175
221
|
},
|
|
222
|
+
onCaps: (modes, configOptions) => send(ws, { t: 'caps', modes, configOptions }),
|
|
223
|
+
onSessionInfo: (info) => send(ws, { t: 'session-info', ...info }),
|
|
224
|
+
onUsage: (usage) => {
|
|
225
|
+
latestUsage = usage;
|
|
226
|
+
send(ws, { t: 'usage', usage });
|
|
227
|
+
},
|
|
176
228
|
});
|
|
177
229
|
bridges.set(ws.data.id, bridge);
|
|
178
230
|
}
|
|
@@ -202,12 +254,13 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
202
254
|
text: string,
|
|
203
255
|
chatId: string,
|
|
204
256
|
model: string | null,
|
|
205
|
-
effort:
|
|
257
|
+
effort: string | null,
|
|
258
|
+
modeId: string | null
|
|
206
259
|
): Promise<void> {
|
|
207
260
|
const bridge = getOrCreateBridge(ws);
|
|
208
261
|
bridge.setTranscriptPath(transcriptPathFor(chatId));
|
|
209
262
|
bridge.setSessionStorePath(sessionStorePathFor(chatId));
|
|
210
|
-
bridge.setConfig(model, effort);
|
|
263
|
+
bridge.setConfig(model, effort, modeId);
|
|
211
264
|
try {
|
|
212
265
|
await bridge.ensureStarted();
|
|
213
266
|
const { stopReason } = await bridge.prompt(text, sanitizeChatId(chatId));
|
|
@@ -232,11 +285,12 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
232
285
|
ws: ServerWebSocket<WsData>,
|
|
233
286
|
chatId: string,
|
|
234
287
|
model: string | null,
|
|
235
|
-
effort:
|
|
288
|
+
effort: string | null,
|
|
289
|
+
modeId: string | null
|
|
236
290
|
): Promise<void> {
|
|
237
291
|
const bridge = getOrCreateBridge(ws);
|
|
238
292
|
bridge.setSessionStorePath(sessionStorePathFor(chatId));
|
|
239
|
-
bridge.setConfig(model, effort);
|
|
293
|
+
bridge.setConfig(model, effort, modeId);
|
|
240
294
|
try {
|
|
241
295
|
await bridge.warmUp(sanitizeChatId(chatId));
|
|
242
296
|
} catch {
|
|
@@ -244,6 +298,57 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
244
298
|
}
|
|
245
299
|
}
|
|
246
300
|
|
|
301
|
+
/** True when `value` is currently offered for the select-type option `configId`
|
|
302
|
+
* on `bridge`'s last-advertised set — the dynamic replacement for the old
|
|
303
|
+
* hardcoded VALID_MODELS/VALID_EFFORT allowlists (DDR-125 F1: a loopback
|
|
304
|
+
* frame still can't pin an arbitrary value onto a live session). */
|
|
305
|
+
function optionOffers(bridge: AcpBridge, configId: string, value: string): boolean {
|
|
306
|
+
const opt = bridge.configOptions.find((o) => o.id === configId);
|
|
307
|
+
if (opt?.type !== 'select') return false;
|
|
308
|
+
const list = Array.isArray(opt.options) ? opt.options : [];
|
|
309
|
+
for (const o of list) {
|
|
310
|
+
if (o && typeof o === 'object' && 'options' in o && Array.isArray(o.options)) {
|
|
311
|
+
if (o.options.some((leaf) => leaf.value === value)) return true;
|
|
312
|
+
} else if (o && typeof o === 'object' && 'value' in o && o.value === value) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Live mode change on an already-established session (Task A2/A4). */
|
|
320
|
+
async function handleSetMode(
|
|
321
|
+
ws: ServerWebSocket<WsData>,
|
|
322
|
+
chatId: string,
|
|
323
|
+
modeId: string
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
const bridge = bridges.get(ws.data.id);
|
|
326
|
+
if (!bridge) return;
|
|
327
|
+
if (!bridge.modes?.availableModes.some((m) => m.id === modeId)) return; // not advertised — reject silently
|
|
328
|
+
try {
|
|
329
|
+
await bridge.setMode(sanitizeChatId(chatId), modeId);
|
|
330
|
+
} catch {
|
|
331
|
+
/* best-effort — the picker just keeps showing the last-confirmed caps frame */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Live config-option change (model/effort/fast/…) on an already-established session. */
|
|
336
|
+
async function handleSetConfig(
|
|
337
|
+
ws: ServerWebSocket<WsData>,
|
|
338
|
+
chatId: string,
|
|
339
|
+
configId: string,
|
|
340
|
+
value: string
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
const bridge = bridges.get(ws.data.id);
|
|
343
|
+
if (!bridge) return;
|
|
344
|
+
if (!optionOffers(bridge, configId, value)) return; // not advertised — reject silently
|
|
345
|
+
try {
|
|
346
|
+
await bridge.setConfigOption(sanitizeChatId(chatId), configId, value);
|
|
347
|
+
} catch {
|
|
348
|
+
/* best-effort — see handleSetMode */
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
247
352
|
return {
|
|
248
353
|
onOpen(ws) {
|
|
249
354
|
const probe = probeAcpAvailability();
|
|
@@ -251,6 +356,9 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
251
356
|
// Replay the last-known command catalogue so autocomplete is instant on a
|
|
252
357
|
// re-open (no re-warm needed within a dev-server lifetime).
|
|
253
358
|
if (latestCommands.length) send(ws, { t: 'commands', commands: latestCommands });
|
|
359
|
+
// Same treatment for usage (Milestone D) — a stale cross-chat snapshot
|
|
360
|
+
// briefly, corrected by the new chat's own first usage_update.
|
|
361
|
+
if (latestUsage) send(ws, { t: 'usage', usage: latestUsage });
|
|
254
362
|
},
|
|
255
363
|
|
|
256
364
|
onMessage(ws, raw) {
|
|
@@ -267,24 +375,65 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
267
375
|
chat?: unknown;
|
|
268
376
|
model?: unknown;
|
|
269
377
|
effort?: unknown;
|
|
378
|
+
mode?: unknown;
|
|
379
|
+
modeId?: unknown;
|
|
380
|
+
configId?: unknown;
|
|
381
|
+
value?: unknown;
|
|
382
|
+
id?: unknown;
|
|
383
|
+
decision?: unknown;
|
|
384
|
+
action?: unknown;
|
|
385
|
+
content?: unknown;
|
|
270
386
|
};
|
|
271
387
|
|
|
272
388
|
const chatId = typeof frame.chat === 'string' && frame.chat ? frame.chat : 'default';
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
389
|
+
// Model/effort/mode are opaque, dynamic option ids/values now (no
|
|
390
|
+
// hardcoded allowlist) — a persisted pick that isn't actually offered by
|
|
391
|
+
// the resolved session is silently skipped by the bridge's own
|
|
392
|
+
// `optionOffers` check (`applyDesiredConfigOnce`), never forwarded blind
|
|
393
|
+
// to the adapter. Live mid-session changes (`set-mode`/`set-config`
|
|
394
|
+
// below) DO get validated against the bridge's last-advertised set —
|
|
395
|
+
// that is the DDR-125 F1 boundary that matters (pinning a value onto an
|
|
396
|
+
// ALREADY-running session).
|
|
397
|
+
const model = typeof frame.model === 'string' && frame.model ? frame.model : null;
|
|
398
|
+
const effort = typeof frame.effort === 'string' && frame.effort ? frame.effort : null;
|
|
399
|
+
const modeId = typeof frame.mode === 'string' && frame.mode ? frame.mode : null;
|
|
279
400
|
|
|
280
401
|
if (frame.t === 'prompt' && typeof frame.text === 'string') {
|
|
281
|
-
void handlePrompt(ws, frame.text, chatId, model, effort);
|
|
402
|
+
void handlePrompt(ws, frame.text, chatId, model, effort, modeId);
|
|
282
403
|
} else if (frame.t === 'warm') {
|
|
283
|
-
void handleWarm(ws, chatId, model, effort);
|
|
404
|
+
void handleWarm(ws, chatId, model, effort, modeId);
|
|
284
405
|
} else if (frame.t === 'cancel') {
|
|
285
406
|
// RC5 — a cancelled turn may never resolve prompt(); clear banners now.
|
|
286
407
|
trackers.get(ws.data.id)?.endTurn();
|
|
287
408
|
void bridges.get(ws.data.id)?.cancel();
|
|
409
|
+
} else if (frame.t === 'set-mode' && typeof frame.modeId === 'string' && frame.modeId) {
|
|
410
|
+
void handleSetMode(ws, chatId, frame.modeId);
|
|
411
|
+
} else if (
|
|
412
|
+
frame.t === 'set-config' &&
|
|
413
|
+
typeof frame.configId === 'string' &&
|
|
414
|
+
frame.configId &&
|
|
415
|
+
typeof frame.value === 'string'
|
|
416
|
+
) {
|
|
417
|
+
void handleSetConfig(ws, chatId, frame.configId, frame.value);
|
|
418
|
+
} else if (frame.t === 'permission-response' && typeof frame.id === 'string' && frame.id) {
|
|
419
|
+
// Milestone B — the human's approve/deny decision for a pending
|
|
420
|
+
// `permission-request`. `decision` is either an offered `optionId` or
|
|
421
|
+
// the literal 'cancelled'; anything else collapses to 'cancelled'
|
|
422
|
+
// (deny) rather than forwarding an unvalidated string as an optionId
|
|
423
|
+
// — resolvePermission itself is a no-op on an unknown/already-settled
|
|
424
|
+
// id, so a malformed decision here just denies, never allows blind.
|
|
425
|
+
const decision =
|
|
426
|
+
typeof frame.decision === 'string' && frame.decision ? frame.decision : 'cancelled';
|
|
427
|
+
bridges.get(ws.data.id)?.resolvePermission(frame.id, decision);
|
|
428
|
+
} else if (frame.t === 'elicitation-response' && typeof frame.id === 'string' && frame.id) {
|
|
429
|
+
// feature-acp-ask-user-question — the human's answer/skip/cancel for a
|
|
430
|
+
// pending `elicitation-request`. `action`/`content` are forwarded
|
|
431
|
+
// shallowly; `AcpBridge.resolveElicitation` is the actual fail-closed
|
|
432
|
+
// gate (anything other than a well-formed accept collapses to decline).
|
|
433
|
+
const action = typeof frame.action === 'string' ? frame.action : 'decline';
|
|
434
|
+
const content =
|
|
435
|
+
frame.content && typeof frame.content === 'object' ? frame.content : undefined;
|
|
436
|
+
bridges.get(ws.data.id)?.resolveElicitation(frame.id, { action, content });
|
|
288
437
|
}
|
|
289
438
|
},
|
|
290
439
|
|
package/apps/studio/acp/probe.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { createHash } from 'node:crypto';
|
|
|
10
10
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
12
|
|
|
13
|
-
import { DEV_SERVER_ROOT } from '../paths.ts';
|
|
13
|
+
import { DEV_SERVER_ROOT, IS_COMPILED_BINARY } from '../paths.ts';
|
|
14
14
|
import { scrubAgentEnv } from './env.ts';
|
|
15
15
|
|
|
16
16
|
/** Adapter npm package — the renamed continuation of `@zed-industries/claude-code-acp`. */
|
|
@@ -155,13 +155,37 @@ export function resolveClaudePath(): string | null {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* The JS runtime used to launch the adapter
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
158
|
+
* The JS runtime used to launch the adapter, plus whether it must be spawned
|
|
159
|
+
* with `BUN_BE_BUN=1`. Ladder: explicit `MAUDE_ACP_RUNTIME` override → a real
|
|
160
|
+
* `node` (the adapter + `@anthropic-ai/claude-agent-sdk` are authored for Node)
|
|
161
|
+
* → a real `bun` → our OWN `process.execPath`.
|
|
162
|
+
*
|
|
163
|
+
* The last rung is the RCA-G1 fix. On a machine with NO node/bun (the target
|
|
164
|
+
* user — installs the `.app`, never opens a terminal), `process.execPath` inside
|
|
165
|
+
* the `bun --compile` dev-server sidecar is the sidecar BINARY, not a JS
|
|
166
|
+
* interpreter: a compiled Bun executable only runs a passed script when
|
|
167
|
+
* `BUN_BE_BUN=1` is set — otherwise it re-runs its own embedded server and the
|
|
168
|
+
* ACP handshake never happens, so the panel hangs at "Working…" forever (the
|
|
169
|
+
* exact reported bug; the user's own `brew install node` masked it by making the
|
|
170
|
+
* `node` rung resolve). So when we fall back to our own COMPILED self we flag
|
|
171
|
+
* `bunBeBun=true`; `bridge.ts` sets it on the child env. In dev, `execPath` is a
|
|
172
|
+
* real `bun` and `IS_COMPILED_BINARY` is false, so `bunBeBun` stays false (no-op).
|
|
173
|
+
*
|
|
174
|
+
* The bundled compiled sidecar IS Bun 1.3.x, so this needs ZERO extra bytes and
|
|
175
|
+
* makes AI editing work with no user-installed runtime. NOTE the adapter + SDK
|
|
176
|
+
* are Node-authored — running them under Bun via this rung MUST be verified on a
|
|
177
|
+
* real compiled, node-less build (the dev path prefers `node`, so Bun is not
|
|
178
|
+
* exercised there); if a Bun/Node incompatibility surfaces, bundle a real `node`
|
|
179
|
+
* as an externalBin and point `MAUDE_ACP_RUNTIME` at it (the override rung above).
|
|
162
180
|
*/
|
|
163
|
-
export function resolveAgentRuntime(): string {
|
|
164
|
-
|
|
181
|
+
export function resolveAgentRuntime(): { bin: string; bunBeBun: boolean } {
|
|
182
|
+
const override = process.env.MAUDE_ACP_RUNTIME;
|
|
183
|
+
if (override) return { bin: override, bunBeBun: false };
|
|
184
|
+
const node = Bun.which('node');
|
|
185
|
+
if (node) return { bin: node, bunBeBun: false };
|
|
186
|
+
const bun = Bun.which('bun');
|
|
187
|
+
if (bun) return { bin: bun, bunBeBun: false };
|
|
188
|
+
return { bin: process.execPath, bunBeBun: IS_COMPILED_BINARY };
|
|
165
189
|
}
|
|
166
190
|
|
|
167
191
|
/**
|
|
@@ -2,13 +2,39 @@
|
|
|
2
2
|
// appends raw per-update lines; these readers turn them into the chat list (for
|
|
3
3
|
// the switcher) and clean per-turn messages (for hydrating the thread on open).
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
6
14
|
import { join } from 'node:path';
|
|
7
15
|
|
|
8
16
|
export interface ChatSummary {
|
|
9
17
|
id: string;
|
|
10
18
|
title: string;
|
|
11
19
|
updated: number; // mtime ms
|
|
20
|
+
/** True when `title` came from a user rename (meta.json), not the
|
|
21
|
+
* auto-derived first line. The client uses this to stop a later live
|
|
22
|
+
* `session_info_update` (agent auto-title) from clobbering an explicit
|
|
23
|
+
* rename — see ChatPanel.jsx's `renamedChatIdsRef`. */
|
|
24
|
+
renamed?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* User-set overrides (Task C5 — per-chat overflow menu), `<designRoot>/_chat/
|
|
29
|
+
* <chatId>.meta.json`. `title` wins over BOTH the auto-derived first-line
|
|
30
|
+
* title and any live `session_info_update` the agent later emits — an
|
|
31
|
+
* explicit rename must not be silently clobbered by an auto-generated
|
|
32
|
+
* summary landing afterward. `archived` hides the chat from the switcher's
|
|
33
|
+
* recents list (`listChats`) without deleting its transcript.
|
|
34
|
+
*/
|
|
35
|
+
export interface ChatMeta {
|
|
36
|
+
title?: string;
|
|
37
|
+
archived?: boolean;
|
|
12
38
|
}
|
|
13
39
|
|
|
14
40
|
export interface ChatMessagePart {
|
|
@@ -26,6 +52,52 @@ function chatDir(designRoot: string): string {
|
|
|
26
52
|
return join(designRoot, '_chat');
|
|
27
53
|
}
|
|
28
54
|
|
|
55
|
+
function metaPath(designRoot: string, chatId: string): string {
|
|
56
|
+
return join(chatDir(designRoot), `${chatId}.meta.json`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Read a chat's meta sidecar. Missing/corrupt/malformed → `{}` (no override),
|
|
60
|
+
* never throws — a bad sidecar must degrade to "no rename/archive", not break
|
|
61
|
+
* the switcher. */
|
|
62
|
+
export function readChatMeta(designRoot: string, chatId: string): ChatMeta {
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(readFileSync(metaPath(designRoot, chatId), 'utf8')) as unknown;
|
|
65
|
+
if (!raw || typeof raw !== 'object') return {};
|
|
66
|
+
const r = raw as Record<string, unknown>;
|
|
67
|
+
const meta: ChatMeta = {};
|
|
68
|
+
if (typeof r.title === 'string' && r.title.trim()) meta.title = r.title.trim().slice(0, 200);
|
|
69
|
+
if (typeof r.archived === 'boolean') meta.archived = r.archived;
|
|
70
|
+
return meta;
|
|
71
|
+
} catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Merge `patch` into the chat's meta sidecar (creates `_chat/` if needed).
|
|
77
|
+
* `title: null` / `archived: false` clear that field rather than deleting
|
|
78
|
+
* the file — callers pass only the field(s) they're changing. */
|
|
79
|
+
export function writeChatMeta(
|
|
80
|
+
designRoot: string,
|
|
81
|
+
chatId: string,
|
|
82
|
+
patch: { title?: string | null; archived?: boolean }
|
|
83
|
+
): ChatMeta {
|
|
84
|
+
const dir = chatDir(designRoot);
|
|
85
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
86
|
+
const current = readChatMeta(designRoot, chatId);
|
|
87
|
+
const next: ChatMeta = { ...current };
|
|
88
|
+
if ('title' in patch) {
|
|
89
|
+
const t = patch.title?.trim();
|
|
90
|
+
if (t) next.title = t.slice(0, 200);
|
|
91
|
+
else next.title = undefined;
|
|
92
|
+
}
|
|
93
|
+
if ('archived' in patch) {
|
|
94
|
+
if (patch.archived) next.archived = true;
|
|
95
|
+
else next.archived = undefined;
|
|
96
|
+
}
|
|
97
|
+
writeFileSync(metaPath(designRoot, chatId), JSON.stringify(next));
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
|
|
29
101
|
function readLines(file: string): Array<Record<string, unknown>> {
|
|
30
102
|
try {
|
|
31
103
|
return readFileSync(file, 'utf8')
|
|
@@ -54,13 +126,18 @@ function deriveTitle(lines: Array<Record<string, unknown>>): string {
|
|
|
54
126
|
return trimmed ? trimmed.slice(0, 60) : 'New chat';
|
|
55
127
|
}
|
|
56
128
|
|
|
57
|
-
/** List chats newest-first.
|
|
129
|
+
/** List chats newest-first. Archived chats (Task C5) are excluded — the
|
|
130
|
+
* transcript stays on disk, it just doesn't show up in the switcher. A
|
|
131
|
+
* user-set title (readChatMeta) wins over the auto-derived first-line one. */
|
|
58
132
|
export function listChats(designRoot: string): ChatSummary[] {
|
|
59
133
|
const dir = chatDir(designRoot);
|
|
60
134
|
if (!existsSync(dir)) return [];
|
|
61
135
|
const out: ChatSummary[] = [];
|
|
62
136
|
for (const name of readdirSync(dir)) {
|
|
63
137
|
if (!name.endsWith('.jsonl')) continue;
|
|
138
|
+
const id = name.replace(/\.jsonl$/, '');
|
|
139
|
+
const meta = readChatMeta(designRoot, id);
|
|
140
|
+
if (meta.archived) continue;
|
|
64
141
|
const file = join(dir, name);
|
|
65
142
|
let updated = 0;
|
|
66
143
|
try {
|
|
@@ -70,14 +147,23 @@ export function listChats(designRoot: string): ChatSummary[] {
|
|
|
70
147
|
}
|
|
71
148
|
const lines = readLines(file);
|
|
72
149
|
if (lines.length === 0) continue;
|
|
73
|
-
out.push({ id:
|
|
150
|
+
out.push({ id, title: meta.title || deriveTitle(lines), updated, renamed: !!meta.title });
|
|
74
151
|
}
|
|
75
152
|
return out.sort((a, b) => b.updated - a.updated);
|
|
76
153
|
}
|
|
77
154
|
|
|
78
|
-
/** Delete a chat's transcript. Returns true if
|
|
155
|
+
/** Delete a chat's transcript + its meta/session sidecars. Returns true if
|
|
156
|
+
* the transcript file was removed (the sidecars are best-effort cleanup). */
|
|
79
157
|
export function deleteChat(designRoot: string, chatId: string): boolean {
|
|
80
|
-
const
|
|
158
|
+
const dir = chatDir(designRoot);
|
|
159
|
+
for (const suffix of ['.meta.json', '.session.json']) {
|
|
160
|
+
try {
|
|
161
|
+
rmSync(join(dir, `${chatId}${suffix}`));
|
|
162
|
+
} catch {
|
|
163
|
+
/* absent or unreadable — non-fatal, best-effort cleanup */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const file = join(dir, `${chatId}.jsonl`);
|
|
81
167
|
if (!existsSync(file)) return false;
|
|
82
168
|
try {
|
|
83
169
|
rmSync(file);
|
|
@@ -248,11 +248,41 @@ export function svgPreParseReject(text) {
|
|
|
248
248
|
if (encMatch && !/^(utf-8|us-ascii|ascii)$/i.test(encMatch[1])) {
|
|
249
249
|
throw new ImportAssetError(3, `unsupported declared encoding: ${encMatch[1]}`);
|
|
250
250
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
251
|
+
// XXE / entity-expansion hardening. The attack surface is an ENTITY
|
|
252
|
+
// declaration or an internal DTD subset (`<!DOCTYPE svg [ … ]>`, where entities
|
|
253
|
+
// are declared) — NOT a plain external-identifier DOCTYPE. Adobe Illustrator,
|
|
254
|
+
// Affinity/Serif, and Inkscape all emit a benign `<!DOCTYPE svg PUBLIC "-//W3C//
|
|
255
|
+
// DTD SVG 1.1//EN" "…svg11.dtd">` with no internal subset; rejecting that whole
|
|
256
|
+
// class turned away real, clean brand logos (RCA G8). Reject the actual vector,
|
|
257
|
+
// allow the benign declaration (the allowlist sanitizer drops the DOCTYPE from
|
|
258
|
+
// the stored output regardless, and happy-dom treats the DOCTYPE as an inert
|
|
259
|
+
// node — it never fetches an external DTD). See DDR-167 + DDR-177.
|
|
260
|
+
if (/<!ENTITY/i.test(text)) {
|
|
261
|
+
throw new ImportAssetError(3, 'ENTITY declarations are rejected (XXE/entity-expansion class)');
|
|
262
|
+
}
|
|
263
|
+
// Internal-subset detection, quote-aware. A naive `/<!DOCTYPE\b(.*?)>/` is
|
|
264
|
+
// evadable: a `>` inside a quoted external-id literal (`SYSTEM "http://x/>y"
|
|
265
|
+
// [ … ]`) truncates the capture before the `[` (ethical-hacker review). Scan
|
|
266
|
+
// from `<!DOCTYPE` respecting quotes, and reject on a `[` (internal subset)
|
|
267
|
+
// reached before the closing `>`.
|
|
268
|
+
const dtStart = text.search(/<!DOCTYPE\b/i);
|
|
269
|
+
if (dtStart >= 0) {
|
|
270
|
+
let quote = null;
|
|
271
|
+
for (let i = dtStart + 9; i < text.length; i++) {
|
|
272
|
+
const c = text[i];
|
|
273
|
+
if (quote) {
|
|
274
|
+
if (c === quote) quote = null;
|
|
275
|
+
} else if (c === '"' || c === "'") {
|
|
276
|
+
quote = c;
|
|
277
|
+
} else if (c === '[') {
|
|
278
|
+
throw new ImportAssetError(
|
|
279
|
+
3,
|
|
280
|
+
'DOCTYPE with an internal subset is rejected (XXE/entity-expansion class)'
|
|
281
|
+
);
|
|
282
|
+
} else if (c === '>') {
|
|
283
|
+
break; // end of DOCTYPE, no internal subset seen
|
|
284
|
+
}
|
|
285
|
+
}
|
|
256
286
|
}
|
|
257
287
|
// Any processing instruction other than the single leading XML declaration.
|
|
258
288
|
const piRe = /<\?([^?]*)\?>/g;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The model / effort / mode picker row (feature-acp-panel-dynamic-claude-code-
|
|
2
|
+
// capabilities) — replaces the two hardcoded MODELS/EFFORTS `<select>`s that
|
|
3
|
+
// used to live in ChatPanel.jsx's Composer. Model/effort/mode come from the
|
|
4
|
+
// LIVE ACP session (`modes`/`configOptions`) — never a static list. Any OTHER
|
|
5
|
+
// advertised option (e.g. an "Agent persona" selector) is deliberately NOT
|
|
6
|
+
// rendered here — user call: keep this row to the three controls people
|
|
7
|
+
// actually reach for, not every generic config option the session offers.
|
|
8
|
+
|
|
9
|
+
import { flattenSelectOptions } from './acp-capabilities.js';
|
|
10
|
+
|
|
11
|
+
function OptionSelect({ id, label, currentValue, options, onChange, disabled }) {
|
|
12
|
+
const flat = flattenSelectOptions(options);
|
|
13
|
+
if (!flat.length) return null;
|
|
14
|
+
return (
|
|
15
|
+
<select
|
|
16
|
+
className="chat-select"
|
|
17
|
+
value={currentValue ?? ''}
|
|
18
|
+
aria-label={label}
|
|
19
|
+
title={flat.find((o) => o.value === currentValue)?.description || label}
|
|
20
|
+
disabled={disabled}
|
|
21
|
+
onChange={(e) => onChange(id, e.target.value)}
|
|
22
|
+
>
|
|
23
|
+
{flat.map((o) => (
|
|
24
|
+
<option key={o.value} value={o.value}>
|
|
25
|
+
{o.name || o.value}
|
|
26
|
+
</option>
|
|
27
|
+
))}
|
|
28
|
+
</select>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function CapabilityBar({ modes, configOptions, onSetMode, onSetConfig, disabled }) {
|
|
33
|
+
const modeList = modes?.availableModes ?? [];
|
|
34
|
+
const model = configOptions?.model ?? null;
|
|
35
|
+
const effort = configOptions?.effort ?? null;
|
|
36
|
+
const fast = configOptions?.fast ?? null;
|
|
37
|
+
|
|
38
|
+
// Nothing arrived yet — the session is still establishing (a fresh chat
|
|
39
|
+
// spawns `claude` on first view; see ChatThread's warmedOnVisibleRef). Show
|
|
40
|
+
// a placeholder instead of rendering nothing at all, so this row doesn't
|
|
41
|
+
// read as "the picker disappeared" while it's just still connecting.
|
|
42
|
+
if (!modeList.length && !model && !effort && !fast) {
|
|
43
|
+
return (
|
|
44
|
+
<span className="chat-caps chat-caps--connecting" data-testid="chat-caps-connecting">
|
|
45
|
+
Connecting…
|
|
46
|
+
</span>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="chat-caps" data-testid="chat-caps-bar">
|
|
52
|
+
{modeList.length ? (
|
|
53
|
+
<select
|
|
54
|
+
className="chat-select"
|
|
55
|
+
value={modes?.currentModeId ?? ''}
|
|
56
|
+
aria-label="Permission mode"
|
|
57
|
+
data-testid="chat-mode-picker"
|
|
58
|
+
title={modeList.find((m) => m.id === modes?.currentModeId)?.description || 'Permission mode'}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
onChange={(e) => onSetMode(e.target.value)}
|
|
61
|
+
>
|
|
62
|
+
{modeList.map((m) => (
|
|
63
|
+
<option key={m.id} value={m.id}>
|
|
64
|
+
{m.name || m.id}
|
|
65
|
+
</option>
|
|
66
|
+
))}
|
|
67
|
+
</select>
|
|
68
|
+
) : null}
|
|
69
|
+
{model ? (
|
|
70
|
+
<OptionSelect
|
|
71
|
+
id={model.id}
|
|
72
|
+
label="Model"
|
|
73
|
+
currentValue={model.currentValue}
|
|
74
|
+
options={model.options}
|
|
75
|
+
onChange={onSetConfig}
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
/>
|
|
78
|
+
) : null}
|
|
79
|
+
{effort ? (
|
|
80
|
+
<OptionSelect
|
|
81
|
+
id={effort.id}
|
|
82
|
+
label="Effort"
|
|
83
|
+
currentValue={effort.currentValue}
|
|
84
|
+
options={effort.options}
|
|
85
|
+
onChange={onSetConfig}
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
/>
|
|
88
|
+
) : null}
|
|
89
|
+
{fast ? (
|
|
90
|
+
<OptionSelect
|
|
91
|
+
id={fast.id}
|
|
92
|
+
label="Fast mode"
|
|
93
|
+
currentValue={fast.currentValue}
|
|
94
|
+
options={fast.options}
|
|
95
|
+
onChange={onSetConfig}
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
/>
|
|
98
|
+
) : null}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|