@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
@@ -0,0 +1,429 @@
1
+ // The inline form-elicitation card (feature-acp-ask-user-question) — renders
2
+ // whatever `requestedSchema.properties` the agent actually sent, generically
3
+ // (works for both AskUserQuestion's `question_N`/`question_N_custom` fields
4
+ // and any other well-formed elicitation form an MCP server sends over the SAME
5
+ // wire mechanism — see the plan's Research section). Same card-slot/focus-trap
6
+ // shape as PermissionPrompt.jsx, but a different renderer: PermissionPrompt
7
+ // renders a flat `options[]`; this renders a JSON-Schema-driven form.
8
+ //
9
+ // Multiple questions render as a one-at-a-time wizard (Back/Next), not
10
+ // stacked vertically — dogfooding found a wall of stacked question groups
11
+ // hard to scan; the CLI's own AskUserQuestion picker is one question at a
12
+ // time too.
13
+
14
+ import { useEffect, useMemo, useRef, useState } from 'react';
15
+
16
+ import {
17
+ buildElicitationContent,
18
+ looksLikeSecretRequest,
19
+ parseElicitationSchema,
20
+ } from './acp-elicitation.js';
21
+
22
+ // The exact literal `askUserQuestionsToCreateRequest` falls back to as the
23
+ // card `message` whenever an AskUserQuestion call carries more than one
24
+ // question (confirmed on disk against the installed adapter source) — never
25
+ // meaningful content, just "there's more than one question below."
26
+ const GENERIC_MULTI_QUESTION_MESSAGE = 'Please answer the following questions.';
27
+
28
+ function isAnswered(q, answers) {
29
+ const custom = q.customFieldId ? answers[q.customFieldId] : undefined;
30
+ if (typeof custom === 'string' && custom.trim() !== '') return true;
31
+ if (q.kind === 'multi') return Array.isArray(answers[q.id]) && answers[q.id].length > 0;
32
+ return typeof answers[q.id] === 'string' && answers[q.id] !== '';
33
+ }
34
+
35
+ function Question({
36
+ q,
37
+ answers,
38
+ setAnswer,
39
+ customOpen,
40
+ onChooseOption,
41
+ onChooseOther,
42
+ onToggleCustom,
43
+ groupName,
44
+ cardSecretShaped,
45
+ }) {
46
+ // SECURITY (ethical-hacker finding) — Maude only ever declares the `form`
47
+ // elicitation capability, never `url` (DDR-180's Open decisions), which
48
+ // makes this free-text box the ONLY channel any connected MCP server can
49
+ // ever use to ask for a real credential — the MCP spec's own guidance is
50
+ // that url-mode exists specifically so a genuine secret request never has
51
+ // to pass through here at all. This doesn't block anything; it just makes a
52
+ // credential-shaped ask LOOK like one (masked input + a visible warning),
53
+ // the same pause a real password field gives a user, instead of rendering
54
+ // identically to "which color do you like?".
55
+ const secretShaped = cardSecretShaped || q.secretShaped;
56
+ // `title` (AskUserQuestion's `header` — a short ≤12-char chip like "Barva")
57
+ // and `description` (the full question text, only carried per-field once a
58
+ // call has more than one question — a single-question call puts the full
59
+ // text in the card's own `message` instead) are BOTH real, independent
60
+ // pieces of the schema — a single `title || description` fallback was
61
+ // silently dropping the actual question text whenever a header existed
62
+ // (dogfooding finding: legends showed only short chips like "Barva"/
63
+ // "Projekty"/"Styl" with no visible question). Show both when present.
64
+ return (
65
+ <fieldset className="chat-elicit-question" data-testid={`chat-elicit-question-${q.id}`}>
66
+ {q.title || q.description ? (
67
+ <legend className="chat-elicit-question-hd">
68
+ {q.title ? <span className="chat-elicit-question-chip">{q.title}</span> : null}
69
+ {q.description || null}
70
+ </legend>
71
+ ) : null}
72
+ {q.kind === 'single' ? (
73
+ <div className="chat-elicit-options">
74
+ {q.options.map((o, i) => (
75
+ <label
76
+ key={o.value}
77
+ className="chat-elicit-option"
78
+ data-testid={`chat-elicit-option-${q.id}-${i}`}
79
+ >
80
+ <input
81
+ type="radio"
82
+ name={groupName}
83
+ checked={!customOpen && answers[q.id] === o.value}
84
+ onChange={() => onChooseOption(o.value)}
85
+ />
86
+ <span className="chat-elicit-option-body">
87
+ <span>
88
+ {o.label}
89
+ {o.description ? (
90
+ <span className="chat-elicit-option-desc"> — {o.description}</span>
91
+ ) : null}
92
+ </span>
93
+ {o.preview ? <pre className="chat-elicit-option-preview">{o.preview}</pre> : null}
94
+ </span>
95
+ </label>
96
+ ))}
97
+ {/* "Other" rides IN the radio group, not a separate hidden toggle
98
+ link below it — dogfooding found the link easy to miss entirely
99
+ (image: a muted collapsed box read as inert, not clickable) AND
100
+ found the OLD design (link + still-checked radio) actively
101
+ confusing: the picked radio stayed visually selected while
102
+ typing, implying both would be sent when only the custom text
103
+ was. A real radio, in the SAME mutually-exclusive group, makes
104
+ the actual either/or truth (one field, one answer — a real
105
+ adapter constraint, not a UI choice) visible for free: picking
106
+ a real option or "Other" naturally un-picks the other. */}
107
+ {q.customFieldId ? (
108
+ <label
109
+ className="chat-elicit-option chat-elicit-option--other"
110
+ data-testid={`chat-elicit-option-${q.id}-other`}
111
+ >
112
+ <input type="radio" name={groupName} checked={customOpen} onChange={onChooseOther} />
113
+ <span className="chat-elicit-option-body">
114
+ <span>Other — type your own answer</span>
115
+ </span>
116
+ </label>
117
+ ) : null}
118
+ {q.customFieldId && customOpen ? (
119
+ <input
120
+ type={secretShaped ? 'password' : 'text'}
121
+ className="chat-elicit-text chat-elicit-custom chat-elicit-custom--inline"
122
+ placeholder="Type your own answer…"
123
+ autoFocus
124
+ value={answers[q.customFieldId] || ''}
125
+ onChange={(e) => setAnswer(q.customFieldId, e.target.value)}
126
+ data-testid={`chat-elicit-custom-${q.id}`}
127
+ />
128
+ ) : null}
129
+ </div>
130
+ ) : null}
131
+ {q.kind === 'multi' ? (
132
+ <div className="chat-elicit-options">
133
+ {q.options.map((o, i) => {
134
+ const selected = Array.isArray(answers[q.id]) ? answers[q.id] : [];
135
+ return (
136
+ <label
137
+ key={o.value}
138
+ className="chat-elicit-option"
139
+ data-testid={`chat-elicit-option-${q.id}-${i}`}
140
+ >
141
+ <input
142
+ type="checkbox"
143
+ checked={selected.includes(o.value)}
144
+ onChange={(e) =>
145
+ setAnswer(
146
+ q.id,
147
+ e.target.checked
148
+ ? [...selected, o.value]
149
+ : selected.filter((v) => v !== o.value)
150
+ )
151
+ }
152
+ />
153
+ <span className="chat-elicit-option-body">
154
+ <span>
155
+ {o.label}
156
+ {o.description ? (
157
+ <span className="chat-elicit-option-desc"> — {o.description}</span>
158
+ ) : null}
159
+ </span>
160
+ {o.preview ? <pre className="chat-elicit-option-preview">{o.preview}</pre> : null}
161
+ </span>
162
+ </label>
163
+ );
164
+ })}
165
+ </div>
166
+ ) : null}
167
+ {q.kind === 'text' ? (
168
+ <input
169
+ type={secretShaped ? 'password' : 'text'}
170
+ className="chat-elicit-text"
171
+ value={answers[q.id] || ''}
172
+ onChange={(e) => setAnswer(q.id, e.target.value)}
173
+ data-testid={`chat-elicit-text-${q.id}`}
174
+ />
175
+ ) : null}
176
+ {/* Multi-select keeps the collapsed-toggle shape (checkboxes aren't
177
+ mutually exclusive, so there's no natural "Other" radio to fold
178
+ into) — but it genuinely COMBINES rather than overrides (see
179
+ buildElicitationContent), so the note explains that instead of
180
+ warning about a replacement that isn't happening here. */}
181
+ {q.kind === 'multi' && q.customFieldId ? (
182
+ customOpen ? (
183
+ <div className="chat-elicit-custom-wrap">
184
+ <input
185
+ type={secretShaped ? 'password' : 'text'}
186
+ className="chat-elicit-text chat-elicit-custom"
187
+ placeholder="Type your own answer…"
188
+ autoFocus
189
+ value={answers[q.customFieldId] || ''}
190
+ onChange={(e) => setAnswer(q.customFieldId, e.target.value)}
191
+ />
192
+ <p className="chat-elicit-custom-note">Added to your selections above.</p>
193
+ </div>
194
+ ) : (
195
+ <button type="button" className="chat-elicit-custom-toggle" onClick={onToggleCustom}>
196
+ Add your own answer to your selections?
197
+ </button>
198
+ )
199
+ ) : null}
200
+ {secretShaped ? (
201
+ <p className="chat-elicit-secret-warning" data-testid="chat-elicit-secret-warning">
202
+ This looks like it's asking for a password, key, or other credential — Maude never
203
+ needs your real ones here.
204
+ </p>
205
+ ) : null}
206
+ </fieldset>
207
+ );
208
+ }
209
+
210
+ export default function ElicitationPrompt({ request, onRespond }) {
211
+ const cardRef = useRef(null);
212
+ const questions = useMemo(() => parseElicitationSchema(request.requestedSchema), [request]);
213
+ const [answers, setAnswers] = useState({});
214
+ const [customOpenIds, setCustomOpenIds] = useState(() => new Set());
215
+ const [step, setStep] = useState(0);
216
+ const isWizard = questions.length > 1;
217
+
218
+ useEffect(() => {
219
+ setAnswers({});
220
+ setCustomOpenIds(new Set());
221
+ setStep(0);
222
+ }, [request.id]);
223
+
224
+ // Focus the card ONCE per request, on mount only — NOT on every answer
225
+ // change. Dogfooding found every keystroke in a free-text field lost focus:
226
+ // this used to live in the same effect as the keydown listener below, whose
227
+ // deps included `answers`, so it re-ran (and re-called `.focus()` on the
228
+ // outer card div) on every character typed, yanking focus off the input
229
+ // mid-type. Splitting it out fixes that — the keydown listener still needs
230
+ // fresh `canSubmit`/`answers` closures, but re-adding a listener doesn't
231
+ // need to also steal focus.
232
+ useEffect(() => {
233
+ cardRef.current?.focus();
234
+ }, [request.id]);
235
+
236
+ const setAnswer = (id, value) => setAnswers((prev) => ({ ...prev, [id]: value }));
237
+ const openCustom = (id) => setCustomOpenIds((prev) => new Set(prev).add(id));
238
+ const closeCustom = (id) =>
239
+ setCustomOpenIds((prev) => {
240
+ if (!prev.has(id)) return prev;
241
+ const next = new Set(prev);
242
+ next.delete(id);
243
+ return next;
244
+ });
245
+ // Multi-select's collapsed toggle: closing it must also clear whatever was
246
+ // typed — otherwise the text stays in `answers` invisibly and still gets
247
+ // folded into the submitted content even though the field is hidden again
248
+ // (the same class of stale-state bug `onChooseOption` below exists to close
249
+ // for single-select).
250
+ const toggleCustom = (id) => {
251
+ if (customOpenIds.has(id)) {
252
+ setAnswer(id, '');
253
+ closeCustom(id);
254
+ } else {
255
+ openCustom(id);
256
+ }
257
+ };
258
+ // Single-select's "Other" now rides in the radio group (see Question) —
259
+ // picking a real option must clear any stale custom text so it can never
260
+ // silently win at submit time (buildElicitationContent only checks
261
+ // "is there non-empty custom text", not "is Other currently selected").
262
+ // Picking "Other" clears the real pick the same way, for the same reason —
263
+ // harmless since buildElicitationContent ignores `answers[q.id]` once
264
+ // custom is non-empty, but keeps the two paths symmetric and correct even
265
+ // before the user has typed anything into Other yet.
266
+ const chooseOption = (q, value) => {
267
+ setAnswers((prev) => ({
268
+ ...prev,
269
+ [q.id]: value,
270
+ ...(q.customFieldId ? { [q.customFieldId]: '' } : {}),
271
+ }));
272
+ if (q.customFieldId) closeCustom(q.customFieldId);
273
+ };
274
+ const chooseOther = (q) => {
275
+ setAnswer(q.id, '');
276
+ openCustom(q.customFieldId);
277
+ };
278
+
279
+ const canSubmit = questions.filter((q) => q.required).every((q) => isAnswered(q, answers));
280
+ const currentQuestion = isWizard ? questions[step] : null;
281
+ const canAdvance = !currentQuestion || !currentQuestion.required || isAnswered(currentQuestion, answers);
282
+ const isLastStep = !isWizard || step === questions.length - 1;
283
+
284
+ const submit = () => {
285
+ if (!canSubmit) return;
286
+ onRespond({ action: 'accept', content: buildElicitationContent(questions, answers) });
287
+ };
288
+ const skip = () => onRespond({ action: 'decline' });
289
+ const cancel = () => onRespond({ action: 'cancel' });
290
+ const back = () => setStep((s) => Math.max(0, s - 1));
291
+ const next = () => {
292
+ if (!canAdvance) return;
293
+ setStep((s) => Math.min(questions.length - 1, s + 1));
294
+ };
295
+
296
+ useEffect(() => {
297
+ function onKey(e) {
298
+ if (e.key === 'Escape') {
299
+ e.preventDefault();
300
+ cancel();
301
+ } else if (e.key === 'Enter' && e.target?.tagName !== 'BUTTON') {
302
+ e.preventDefault();
303
+ if (isLastStep) submit();
304
+ else next();
305
+ }
306
+ }
307
+ const el = cardRef.current;
308
+ el?.addEventListener('keydown', onKey);
309
+ return () => el?.removeEventListener('keydown', onKey);
310
+ // eslint-disable-next-line react-hooks/exhaustive-deps
311
+ }, [request.id, canSubmit, canAdvance, isLastStep, answers, step]);
312
+
313
+ // SECURITY (ethical-hacker + security-auditor finding) — this same card
314
+ // renders BOTH the built-in AskUserQuestion tool's questions AND any
315
+ // connected MCP server's form elicitation (see DDR-180); the wire protocol
316
+ // carries no field that reliably distinguishes which. Never brand this as
317
+ // "from Claude" — that would misattribute an MCP-server-authored form
318
+ // (potentially phishing-shaped free text) as trusted first-party chrome.
319
+ // `toolCallId`, when present, ties this to a specific tool call the
320
+ // transcript already rendered a card for just above — the honest,
321
+ // independently-verifiable signal, instead of an unearned brand claim.
322
+ const attribution = request.toolCallId
323
+ ? 'Requested by the tool call above'
324
+ : 'Requested by a connected tool (not tied to a visible step)';
325
+
326
+ const visibleQuestions = isWizard ? (currentQuestion ? [currentQuestion] : []) : questions;
327
+ // The card's own top-level `message` can itself read as a credential ask
328
+ // (e.g. a single-question call whose only text is "Enter your API key") —
329
+ // checked separately from each question's own title/description since a
330
+ // single-question AskUserQuestion call puts the real text in `message`,
331
+ // not in the per-field schema (see the showTopMessage comment below).
332
+ const cardSecretShaped = looksLikeSecretRequest(request.message);
333
+
334
+ // A multi-question AskUserQuestion call's top-level `message` is always the
335
+ // SAME generic boilerplate string ("Please answer the following
336
+ // questions." — askUserQuestionsToCreateRequest's literal fallback,
337
+ // confirmed on disk); the real per-question text already renders inside
338
+ // each step. Showing both stacked one above the other read as cluttered
339
+ // ("vypadá dost ošklivě" dogfooding feedback) — skip the boilerplate line
340
+ // and let the step counter carry that slot instead. A genuinely custom
341
+ // top-level message (single-question calls, or a non-AskUserQuestion MCP
342
+ // form) still gets its own prominent header line.
343
+ const showTopMessage =
344
+ !!request.message && (!isWizard || request.message !== GENERIC_MULTI_QUESTION_MESSAGE);
345
+
346
+ return (
347
+ <div
348
+ className="chat-elicit"
349
+ role="alertdialog"
350
+ aria-label="Input requested"
351
+ tabIndex={-1}
352
+ ref={cardRef}
353
+ data-testid="chat-elicit-prompt"
354
+ >
355
+ <div className="chat-elicit-hd">
356
+ {showTopMessage ? <b>{request.message}</b> : null}
357
+ <div className="chat-elicit-meta">
358
+ {isWizard ? (
359
+ <span data-testid="chat-elicit-step">
360
+ Question {step + 1} of {questions.length}
361
+ </span>
362
+ ) : null}
363
+ <span className="chat-elicit-attribution">{attribution}</span>
364
+ </div>
365
+ </div>
366
+ {visibleQuestions.map((q) => (
367
+ <Question
368
+ key={q.id}
369
+ q={q}
370
+ answers={answers}
371
+ setAnswer={setAnswer}
372
+ customOpen={customOpenIds.has(q.customFieldId)}
373
+ onChooseOption={(value) => chooseOption(q, value)}
374
+ onChooseOther={() => chooseOther(q)}
375
+ onToggleCustom={() => toggleCustom(q.customFieldId)}
376
+ groupName={`elicit-${request.id}-${q.id}`}
377
+ cardSecretShaped={cardSecretShaped}
378
+ />
379
+ ))}
380
+ <div className="chat-elicit-actions">
381
+ {isWizard && step > 0 ? (
382
+ <button type="button" className="btn" onClick={back} data-testid="chat-elicit-back">
383
+ Back
384
+ </button>
385
+ ) : null}
386
+ {isLastStep ? (
387
+ <button
388
+ type="button"
389
+ className="btn btn--primary"
390
+ disabled={!canSubmit}
391
+ onClick={submit}
392
+ data-testid="chat-elicit-submit"
393
+ >
394
+ Submit
395
+ </button>
396
+ ) : (
397
+ <button
398
+ type="button"
399
+ className="btn btn--primary"
400
+ disabled={!canAdvance}
401
+ onClick={next}
402
+ data-testid="chat-elicit-next"
403
+ >
404
+ Next
405
+ </button>
406
+ )}
407
+ <span className="chat-elicit-actions-spacer" />
408
+ <button
409
+ type="button"
410
+ className="btn"
411
+ onClick={skip}
412
+ title="Let Claude continue without an answer"
413
+ data-testid="chat-elicit-skip"
414
+ >
415
+ Skip
416
+ </button>
417
+ <button
418
+ type="button"
419
+ className="btn btn--danger"
420
+ onClick={cancel}
421
+ title="Stop this question — the action it was part of is aborted too"
422
+ data-testid="chat-elicit-cancel"
423
+ >
424
+ Cancel
425
+ </button>
426
+ </div>
427
+ </div>
428
+ );
429
+ }
@@ -0,0 +1,119 @@
1
+ // The inline approve/deny card (Milestone B — retires DDR-125 F2's blanket
2
+ // auto-approve). Renders whatever `options[]` the agent actually offered —
3
+ // NOT a fixed Allow/Reject pair — because ExitPlanMode rides this SAME
4
+ // requestPermission path with its own multi-way option set ("Yes, and use
5
+ // auto mode" / "Yes, and auto-accept edits" / "No, keep planning", …). Only
6
+ // mounted while a request is pending for the ACTIVE chat (ChatThread gates
7
+ // this — one card at a time, oldest first).
8
+
9
+ import { useEffect, useRef } from 'react';
10
+
11
+ function toolTarget(toolCall) {
12
+ const raw = toolCall?.rawInput;
13
+ const fromInput =
14
+ raw && typeof raw === 'object' ? raw.path || raw.file_path || raw.abs_path || raw.filePath : undefined;
15
+ const fromLocation = Array.isArray(toolCall?.locations) ? toolCall.locations[0]?.path : undefined;
16
+ const path = fromInput || fromLocation;
17
+ return path ? String(path).split('/').pop() : null;
18
+ }
19
+
20
+ // Visual bucket for a PermissionOptionKind — drives which button style reads
21
+ // as "the safe default" (allow-once) vs "the escape hatch" (reject). Note
22
+ // `allow_always` is deliberately NOT primary-styled — see `pickDefaultAllow`
23
+ // below for why.
24
+ function kindClass(kind) {
25
+ if (kind === 'reject_once' || kind === 'reject_always') return 'btn btn--danger';
26
+ if (kind === 'allow_once') return 'btn btn--primary';
27
+ return 'btn';
28
+ }
29
+
30
+ // SECURITY (ethical-hacker finding) — pure, exported for direct unit testing
31
+ // (test/acp-permission-prompt.test.ts) without needing to render the
32
+ // component. Prefers `allow_once` over `allow_always` as the default (Enter
33
+ // key + visually primary button). The adapter's own option ordering for BOTH
34
+ // a routine tool call and an ExitPlanMode request happens to list the
35
+ // most-permissive standing-exemption option FIRST (`allow_always`, or
36
+ // ExitPlanMode's `auto` mode) — a plain `.find()` over an unordered
37
+ // preference grabbed whichever came first, which meant Enter/the one bold
38
+ // button on an ORDINARY tool-call card silently granted a session-scoped
39
+ // standing exemption instead of a one-time approval, and on an ExitPlanMode
40
+ // card silently flipped the whole session into an unattended auto mode
41
+ // (after which `requestPermission` is never called again — DDR-179's own
42
+ // bypassPermissions short-circuit). Only fall back to `allow_always` when no
43
+ // once-only option was offered at all, so there's always SOME allow default
44
+ // rather than none.
45
+ export function pickDefaultAllow(options) {
46
+ const list = Array.isArray(options) ? options : [];
47
+ return (
48
+ list.find((o) => o?.kind === 'allow_once') ?? list.find((o) => o?.kind === 'allow_always') ?? null
49
+ );
50
+ }
51
+
52
+ export function pickDefaultReject(options) {
53
+ const list = Array.isArray(options) ? options : [];
54
+ return list.find((o) => o?.kind === 'reject_once' || o?.kind === 'reject_always') ?? null;
55
+ }
56
+
57
+ export default function PermissionPrompt({ request, onRespond, queueLength = 1 }) {
58
+ const cardRef = useRef(null);
59
+ const { toolCall, options } = request;
60
+ const target = toolTarget(toolCall);
61
+
62
+ const firstAllow = pickDefaultAllow(options);
63
+ const firstReject = pickDefaultReject(options);
64
+
65
+ useEffect(() => {
66
+ cardRef.current?.focus();
67
+ function onKey(e) {
68
+ if (e.key === 'Enter' && firstAllow) {
69
+ e.preventDefault();
70
+ onRespond(firstAllow.optionId);
71
+ } else if (e.key === 'Escape') {
72
+ e.preventDefault();
73
+ onRespond(firstReject ? firstReject.optionId : 'cancelled');
74
+ }
75
+ }
76
+ const el = cardRef.current;
77
+ el?.addEventListener('keydown', onKey);
78
+ return () => el?.removeEventListener('keydown', onKey);
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [request.id]);
81
+
82
+ return (
83
+ <div
84
+ className="chat-perm"
85
+ role="alertdialog"
86
+ aria-label="Permission request"
87
+ tabIndex={-1}
88
+ ref={cardRef}
89
+ data-testid="chat-permission-prompt"
90
+ >
91
+ <div className="chat-perm-hd">
92
+ <b title={toolCall?.title || 'Tool call'}>{toolCall?.title || 'Tool call'}</b>
93
+ {target ? <span className="chat-tool-path">{target}</span> : null}
94
+ {/* SECURITY (ethical-hacker finding) — a growing backlog was
95
+ invisible (always "1 of 1"), which is what manufactures the
96
+ reflexive-Enter-mashing precondition the wrong-default bug
97
+ depended on. Now visible whenever more than one is queued. */}
98
+ {queueLength > 1 ? (
99
+ <span className="chat-perm-queue" data-testid="chat-perm-queue">
100
+ +{queueLength - 1} more waiting
101
+ </span>
102
+ ) : null}
103
+ </div>
104
+ <p className="chat-perm-body">Claude wants permission to continue. Choose an option:</p>
105
+ <div className="chat-perm-actions">
106
+ {options.map((o) => (
107
+ <button
108
+ key={o.optionId}
109
+ type="button"
110
+ className={kindClass(o.kind)}
111
+ onClick={() => onRespond(o.optionId)}
112
+ >
113
+ {o.name}
114
+ </button>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ );
119
+ }
@@ -92,7 +92,11 @@ const SIGNIN_POLL_MAX_MS = 120000;
92
92
  // The installer itself is fire-and-forget server-side (avoids Bun's default
93
93
  // 10s idle timeout on a slow network) — poll for its result the same way.
94
94
  const INSTALL_POLL_MS = 1500;
95
- const INSTALL_POLL_MAX_MS = 90000;
95
+ // The window we allow with NO `running` signal from the server before giving up.
96
+ // The deadline is pushed forward on every `running` poll (see pollForInstall), so
97
+ // a genuinely-progressing install is never cut off — this only bounds a hung/lost
98
+ // installer. Generous vs the old fixed 90 s that cut off fresh-machine downloads.
99
+ const INSTALL_POLL_MAX_MS = 180000;
96
100
 
97
101
  // DDR-166 T0c/T0d — one component drives both the "install then sign in"
98
102
  // chain (mode='install') and the "sign in only" case (mode='signin'), since
@@ -144,13 +148,23 @@ function SetupAction({ mode, refresh }) {
144
148
  if (Date.now() > timerRef.current.deadline) {
145
149
  stopPoll();
146
150
  setPhase('error');
147
- setError('Install timed out — you can also run the command below yourself.');
151
+ setError('Install is taking longer than expected it may still finish in the background; you can also run the command below yourself, then Re-check.');
148
152
  return;
149
153
  }
150
154
  const state = await fetch('/_api/claude/install-status')
151
155
  .then((r) => r.json())
152
156
  .catch(() => null);
153
- if (!state || state.phase !== 'done') return; // still running
157
+ // The installer downloads a ~200 MB binary; on a fresh/slow machine that
158
+ // routinely exceeds a fixed 90 s wall clock (RCA G6 — the "Install timed
159
+ // out" the user hit while the fire-and-forget install was still running).
160
+ // While the server reports it's actively installing, push the deadline
161
+ // forward so a slow-but-progressing download is never declared a failure;
162
+ // the cap only bites if the server stops reporting `running` (hung/lost).
163
+ if (state && state.phase === 'running') {
164
+ timerRef.current.deadline = Date.now() + INSTALL_POLL_MAX_MS;
165
+ return;
166
+ }
167
+ if (!state || state.phase !== 'done') return; // transient — keep polling to the deadline
154
168
  stopPoll();
155
169
  if (!state.ok) {
156
170
  setPhase('error');
@@ -0,0 +1,79 @@
1
+ // Collapsed consecutive-tool-call summary row (Task C1) — folds a RUN of 2+
2
+ // back-to-back tool-call parts into one "Ran N tools" line, expandable to the
3
+ // individual ChatToolCards. A single isolated tool call renders as-is (no
4
+ // wrapper) so the common case stays exactly as it was.
5
+
6
+ import { useState } from 'react';
7
+
8
+ /**
9
+ * Pure grouping fn (exported for tests): `parts` (a message's raw content
10
+ * array, or the continuation tail's parts) → an ordered list of
11
+ * `{ type:'single', part }` | `{ type:'tool-group', parts:[...] }`. Only
12
+ * CONSECUTIVE tool-call parts fold into a group; anything else (text,
13
+ * reasoning) — or a lone tool-call with no neighbor — passes through single.
14
+ */
15
+ export function groupToolCalls(parts) {
16
+ const list = Array.isArray(parts) ? parts : [];
17
+ const out = [];
18
+ let i = 0;
19
+ while (i < list.length) {
20
+ const p = list[i];
21
+ if (p?.type === 'tool-call') {
22
+ const run = [];
23
+ let j = i;
24
+ while (j < list.length && list[j]?.type === 'tool-call') {
25
+ run.push(list[j]);
26
+ j++;
27
+ }
28
+ out.push(run.length > 1 ? { type: 'tool-group', parts: run } : { type: 'single', part: run[0] });
29
+ i = j;
30
+ } else {
31
+ out.push({ type: 'single', part: p });
32
+ i++;
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /** "Ran N × Write file" when every entry in the run shares one title, else a
39
+ * short "Ran N tools — A, B, C…" listing the distinct titles seen. */
40
+ export function summarizeGroup(parts) {
41
+ const names = parts.map((p) => p.toolName || 'tool');
42
+ const uniq = [...new Set(names)];
43
+ if (uniq.length === 1) return `Ran ${parts.length} × ${uniq[0]}`;
44
+ const shown = uniq.slice(0, 3).join(', ');
45
+ return `Ran ${parts.length} tools — ${shown}${uniq.length > 3 ? '…' : ''}`;
46
+ }
47
+
48
+ export default function ToolGroup({ parts, ToolCard, forceOpen = false, verbose = false }) {
49
+ const [open, setOpen] = useState(forceOpen);
50
+ const allDone = parts.every((p) => p.result !== undefined);
51
+ const anyError = parts.some((p) => p.isError);
52
+ return (
53
+ <details
54
+ className="chat-toolgroup"
55
+ open={forceOpen || open}
56
+ onToggle={(e) => setOpen(e.currentTarget.open)}
57
+ data-testid="chat-tool-group"
58
+ >
59
+ <summary className="chat-toolgroup-sum">
60
+ <span className={`chat-tool-dot ${allDone ? 'chat-tool-dot--done' : 'chat-tool-dot--run'}`} />
61
+ <span>{summarizeGroup(parts)}</span>
62
+ {anyError ? <span className="del"> · error</span> : null}
63
+ </summary>
64
+ <div className="chat-toolgroup-body">
65
+ {parts.map((p, i) => (
66
+ <ToolCard
67
+ key={p.toolCallId || i}
68
+ toolName={p.toolName}
69
+ args={p.args}
70
+ result={p.result}
71
+ isError={p.isError}
72
+ verbose={verbose}
73
+ flat
74
+ />
75
+ ))}
76
+ </div>
77
+ </details>
78
+ );
79
+ }