@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.
Files changed (47) hide show
  1. package/apps/studio/acp/bridge.ts +585 -73
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/bin/_import-asset.mjs +35 -5
  6. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  7. package/apps/studio/client/panels/ChatPanel.jsx +802 -133
  8. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  9. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  10. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  11. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  12. package/apps/studio/client/panels/acp-capabilities.js +71 -0
  13. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  14. package/apps/studio/client/panels/acp-runtime.js +248 -9
  15. package/apps/studio/client/panels/acp-usage.js +65 -0
  16. package/apps/studio/client/panels/transcript-view.js +36 -0
  17. package/apps/studio/client/styles/6-acp-chat.css +648 -11
  18. package/apps/studio/dist/client.bundle.js +1596 -1594
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/generation/whisper-models.test.ts +28 -1
  21. package/apps/studio/generation/whisper-models.ts +22 -7
  22. package/apps/studio/http.ts +33 -2
  23. package/apps/studio/test/acp-bridge.test.ts +11 -6
  24. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  25. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  26. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  27. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  28. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  29. package/apps/studio/test/acp-permission.test.ts +262 -0
  30. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  31. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  32. package/apps/studio/test/acp-transcript.test.ts +75 -2
  33. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  34. package/apps/studio/test/acp-usage.test.ts +143 -0
  35. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  36. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  37. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  38. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  39. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  40. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  41. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  42. package/apps/studio/test/import-asset.test.ts +31 -3
  43. package/apps/studio/whats-new.json +34 -0
  44. package/cli/commands/design.mjs +107 -2
  45. package/cli/lib/pkg-root.mjs +42 -10
  46. package/cli/lib/pkg-root.test.mjs +33 -1
  47. package/package.json +11 -9
@@ -6,7 +6,11 @@
6
6
 
7
7
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
 
9
- import type { AvailableCommand, SessionUpdate } from '@agentclientprotocol/sdk';
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 AcpEffort } from './bridge.ts';
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, canvas? }`, `{ t: 'cancel' }`,
28
- * `{ t: 'warm', chat?, model?, effort? }` (spawn + create the session so the agent
29
- * publishes its slash-command catalogue — no prompt sent).
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), `turn-end`,
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: AcpEffort
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: AcpEffort
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
- const model =
274
- typeof frame.model === 'string' && VALID_MODELS.has(frame.model) ? frame.model : null;
275
- const effort: AcpEffort =
276
- typeof frame.effort === 'string' && VALID_EFFORT.has(frame.effort)
277
- ? (frame.effort as AcpEffort)
278
- : 'balanced';
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
 
@@ -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. The dev-server already runs under
159
- * Bun, so `process.execPath` is always available; prefer a real `node` when
160
- * present (the adapter + `@anthropic-ai/claude-agent-sdk` are authored for Node)
161
- * and fall back to our own Bun. Overridable via `MAUDE_ACP_RUNTIME`.
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
- return process.env.MAUDE_ACP_RUNTIME || Bun.which('node') || process.execPath;
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 { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
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: name.replace(/\.jsonl$/, ''), title: deriveTitle(lines), updated });
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 a file was removed. */
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 file = join(chatDir(designRoot), `${chatId}.jsonl`);
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
- if (/<!DOCTYPE/i.test(text) || /<!ENTITY/i.test(text)) {
252
- throw new ImportAssetError(
253
- 3,
254
- 'DOCTYPE/ENTITY declarations are rejected (XXE/entity-expansion class)'
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
+ }