@1agh/maude 0.45.0 → 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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pure helpers for the ACP session-capabilities channel (feature-acp-panel-
|
|
2
|
+
// dynamic-claude-code-capabilities). Every model/mode/effort list rendered by
|
|
3
|
+
// the panel is parsed from these — NEVER a hardcoded array. The session's
|
|
4
|
+
// `configOptions[]` is a generic, agent-defined menu (models/effort/fast-mode/
|
|
5
|
+
// agent persona/…, keyed by opaque ids); `modes` is the separate, protocol-
|
|
6
|
+
// typed permission-mode roster. See CapabilityBar.jsx for the render side.
|
|
7
|
+
|
|
8
|
+
const KNOWN_CONFIG_IDS = { model: 'model', effort: 'effort', fast: 'fast', mode: 'mode' };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `configOptions[]` → the three well-known controls (identified by `id` —
|
|
12
|
+
* `category` is only a UX hint and may be absent, e.g. the "Agent persona"
|
|
13
|
+
* option) plus every OTHER advertised select option, generic. The "mode"
|
|
14
|
+
* entry mirrors `modes`/`SessionModeState` (the adapter keeps both in sync)
|
|
15
|
+
* so it's excluded here — the dedicated mode picker renders from `parseModes`
|
|
16
|
+
* instead, never from this list, or it would show twice.
|
|
17
|
+
*/
|
|
18
|
+
export function parseConfigOptions(configOptions) {
|
|
19
|
+
const list = Array.isArray(configOptions) ? configOptions : [];
|
|
20
|
+
let model = null;
|
|
21
|
+
let effort = null;
|
|
22
|
+
let fast = null;
|
|
23
|
+
const others = [];
|
|
24
|
+
for (const opt of list) {
|
|
25
|
+
if (!opt || typeof opt !== 'object' || opt.id === KNOWN_CONFIG_IDS.mode) continue;
|
|
26
|
+
if (opt.id === KNOWN_CONFIG_IDS.model) model = opt;
|
|
27
|
+
else if (opt.id === KNOWN_CONFIG_IDS.effort) effort = opt;
|
|
28
|
+
else if (opt.id === KNOWN_CONFIG_IDS.fast) fast = opt;
|
|
29
|
+
else others.push(opt);
|
|
30
|
+
}
|
|
31
|
+
return { model, effort, fast, others };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `SessionModeState` → `{ current, available }` (empty when the agent doesn't
|
|
36
|
+
* advertise modes at all). `available` legitimately varies turn to turn — the
|
|
37
|
+
* adapter clamps the roster to what the CURRENT model supports (e.g. "Auto"
|
|
38
|
+
* only when `supportsAutoMode`) — never assume a fixed set.
|
|
39
|
+
*/
|
|
40
|
+
export function parseModes(modes) {
|
|
41
|
+
if (!modes || !Array.isArray(modes.availableModes)) return { current: null, available: [] };
|
|
42
|
+
return { current: modes.currentModeId ?? null, available: modes.availableModes };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Flatten a `SessionConfigSelect.options` — a flat option array OR grouped
|
|
47
|
+
* (`SessionConfigSelectGroup[]`, each `{group,name,options}`) — into one list
|
|
48
|
+
* of selectable leaves (`{value,name,description}`).
|
|
49
|
+
*/
|
|
50
|
+
export function flattenSelectOptions(options) {
|
|
51
|
+
const list = Array.isArray(options) ? options : [];
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const o of list) {
|
|
54
|
+
if (o && Array.isArray(o.options)) out.push(...o.options);
|
|
55
|
+
else if (o) out.push(o);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve which value a picker should show: the user's last explicit pick
|
|
62
|
+
* (localStorage, `savedValue`) if it's still offered among `availableValues`
|
|
63
|
+
* (a plain array of value/id strings — the caller flattens+maps first), else
|
|
64
|
+
* `defaultValue` (the session's own current value — a fresh session/model can
|
|
65
|
+
* default to something the last chat didn't use).
|
|
66
|
+
*/
|
|
67
|
+
export function resolvePersistedPick(availableValues, savedValue, defaultValue) {
|
|
68
|
+
const set = new Set(Array.isArray(availableValues) ? availableValues : []);
|
|
69
|
+
if (savedValue != null && set.has(savedValue)) return savedValue;
|
|
70
|
+
return defaultValue ?? null;
|
|
71
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Pure helpers for ACP form elicitation (feature-acp-ask-user-question) —
|
|
2
|
+
// parses a `requestedSchema` (ElicitationSchema, confirmed on disk against
|
|
3
|
+
// `@agentclientprotocol/sdk`'s schema/types.gen.d.ts) into renderable question
|
|
4
|
+
// descriptors, and folds the UI's answer state back into the `content` map a
|
|
5
|
+
// `CreateElicitationResponse` expects. No DOM — `ElicitationPrompt.jsx` is the
|
|
6
|
+
// only consumer that touches rendering.
|
|
7
|
+
//
|
|
8
|
+
// The exact field-naming convention this is written against — `question_<n>`
|
|
9
|
+
// paired with a sibling `question_<n>_custom` free-text field, enum options
|
|
10
|
+
// carrying `{const, title, description?}` — is confirmed by reading
|
|
11
|
+
// `@agentclientprotocol/claude-agent-acp/dist/elicitation.js`'s
|
|
12
|
+
// `askUserQuestionsToCreateRequest`/`applyAskElicitationResponse` directly (the
|
|
13
|
+
// installed source, not the docs — this whole feature rides an UNSTABLE
|
|
14
|
+
// protocol surface). The parser is written generically against the pairing
|
|
15
|
+
// PATTERN (`<key>` + `<key>_custom` both present in `properties`), not
|
|
16
|
+
// hardcoded to the `question_` prefix, so a well-formed non-AskUserQuestion MCP
|
|
17
|
+
// form that happens to follow the same convention renders correctly too.
|
|
18
|
+
|
|
19
|
+
// SECURITY (ethical-hacker finding) — Maude's bridge only ever declares the
|
|
20
|
+
// `form` elicitation capability, never `url` (see DDR-180's Open decisions +
|
|
21
|
+
// `acp/bridge.ts`'s structural rejection of any other mode). The MCP spec's
|
|
22
|
+
// OWN guidance is that url-mode exists specifically so a real credential
|
|
23
|
+
// request never has to pass through the LLM/client at all. By not supporting
|
|
24
|
+
// it, Maude makes the free-text box on THIS card the only channel any
|
|
25
|
+
// connected MCP server — benign or hostile — can ever use to ask for a
|
|
26
|
+
// secret. This heuristic doesn't change accept/decline routing or try to
|
|
27
|
+
// block the request; it only asks the free-text input to behave like a
|
|
28
|
+
// password field (masked, with a visible warning) when the question's own
|
|
29
|
+
// text reads as a credential ask, so a user who WOULD stop and think twice
|
|
30
|
+
// before typing a real secret into a password-styled field gets that same
|
|
31
|
+
// pause here. A false positive just means an unrelated question shows a
|
|
32
|
+
// masked box — harmless; a false negative leaves today's behavior unchanged.
|
|
33
|
+
const SECRET_SHAPED_PATTERN =
|
|
34
|
+
/\b(password|passphrase|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|auth(?:entication)?[\s_-]?token|credential|oauth[\s_-]?token)\b/i;
|
|
35
|
+
|
|
36
|
+
/** Does this question's own text read as a request for a credential/secret?
|
|
37
|
+
* Checked against title + description (or a top-level card `message`) — text
|
|
38
|
+
* the requester chose, never the user's own typed answer. */
|
|
39
|
+
export function looksLikeSecretRequest(...texts) {
|
|
40
|
+
const joined = texts.filter((t) => typeof t === 'string').join(' ');
|
|
41
|
+
return SECRET_SHAPED_PATTERN.test(joined);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `_meta` key under which `askUserQuestionsToCreateRequest` bridges an
|
|
45
|
+
* AskUserQuestion option's `preview` (mockup/code-snippet/comparison) — ACP's
|
|
46
|
+
* `EnumOption` has no first-class slot for it (confirmed on disk against the
|
|
47
|
+
* installed `claude-agent-acp/dist/elicitation.js`). */
|
|
48
|
+
const OPTION_PREVIEW_META_KEY = '_claude/askUserQuestionOption';
|
|
49
|
+
|
|
50
|
+
/** Titled or bare enum options → a uniform `{value, label, description, preview}`
|
|
51
|
+
* list. `oneOf`/array-items `anyOf` carry `{const, title, description?, _meta?}`;
|
|
52
|
+
* a bare `enum` is just an array of strings with no separate label/preview. */
|
|
53
|
+
function normalizeOptions(schema) {
|
|
54
|
+
if (Array.isArray(schema?.oneOf)) {
|
|
55
|
+
return schema.oneOf.map((o) => ({
|
|
56
|
+
value: o?.const,
|
|
57
|
+
label: o?.title ?? String(o?.const ?? ''),
|
|
58
|
+
description: o?.description ?? null,
|
|
59
|
+
preview: o?._meta?.[OPTION_PREVIEW_META_KEY]?.preview ?? null,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(schema?.enum)) {
|
|
63
|
+
return schema.enum.map((v) => ({
|
|
64
|
+
value: v,
|
|
65
|
+
label: String(v),
|
|
66
|
+
description: null,
|
|
67
|
+
preview: null,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Classify one ElicitationPropertySchema into a renderable `kind` + its options. */
|
|
74
|
+
function classifyProperty(prop) {
|
|
75
|
+
if (!prop || typeof prop !== 'object') return null;
|
|
76
|
+
if (prop.type === 'string' && (Array.isArray(prop.oneOf) || Array.isArray(prop.enum))) {
|
|
77
|
+
return { kind: 'single', options: normalizeOptions(prop) };
|
|
78
|
+
}
|
|
79
|
+
if (prop.type === 'array') {
|
|
80
|
+
const items = prop.items;
|
|
81
|
+
const options = Array.isArray(items?.anyOf)
|
|
82
|
+
? normalizeOptions({ oneOf: items.anyOf })
|
|
83
|
+
: normalizeOptions(items);
|
|
84
|
+
if (options.length) return { kind: 'multi', options };
|
|
85
|
+
return { kind: 'text', options: [] }; // array without an item enum — no renderer for it; fall back to text
|
|
86
|
+
}
|
|
87
|
+
// string/number/integer/boolean with no enum, or anything unrecognized —
|
|
88
|
+
// render as a plain free-text field rather than dropping the question.
|
|
89
|
+
return { kind: 'text', options: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Walk `requestedSchema.properties` in object-key order into an ordered list
|
|
94
|
+
* of renderable question descriptors:
|
|
95
|
+
* { id, title, description, kind: 'single'|'multi'|'text', options, required, customFieldId }
|
|
96
|
+
* Tolerates a missing/malformed schema (returns `[]`) — never throws, since a
|
|
97
|
+
* malformed elicitation must still let the user Skip/Cancel rather than crash
|
|
98
|
+
* the panel.
|
|
99
|
+
*/
|
|
100
|
+
export function parseElicitationSchema(requestedSchema) {
|
|
101
|
+
const properties = requestedSchema?.properties;
|
|
102
|
+
if (!properties || typeof properties !== 'object') return [];
|
|
103
|
+
const keys = Object.keys(properties);
|
|
104
|
+
const required = new Set(Array.isArray(requestedSchema.required) ? requestedSchema.required : []);
|
|
105
|
+
// A key is a "custom" sibling (and must NOT render as its own top-level
|
|
106
|
+
// question) when `<base>_custom` names it AND `<base>` is also a property.
|
|
107
|
+
const customKeys = new Set();
|
|
108
|
+
for (const k of keys) {
|
|
109
|
+
if (!k.endsWith('_custom')) continue;
|
|
110
|
+
const base = k.slice(0, -'_custom'.length);
|
|
111
|
+
if (base && Object.hasOwn(properties, base)) customKeys.add(k);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const questions = [];
|
|
115
|
+
for (const id of keys) {
|
|
116
|
+
if (customKeys.has(id)) continue;
|
|
117
|
+
const prop = properties[id];
|
|
118
|
+
const classified = classifyProperty(prop);
|
|
119
|
+
if (!classified) continue;
|
|
120
|
+
const customFieldId = customKeys.has(`${id}_custom`) ? `${id}_custom` : null;
|
|
121
|
+
questions.push({
|
|
122
|
+
id,
|
|
123
|
+
title: prop.title ?? null,
|
|
124
|
+
description: prop.description ?? null,
|
|
125
|
+
kind: classified.kind,
|
|
126
|
+
options: classified.options,
|
|
127
|
+
required: required.has(id),
|
|
128
|
+
customFieldId,
|
|
129
|
+
secretShaped: looksLikeSecretRequest(prop.title, prop.description),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return questions;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fold the UI's answer state into the `content` map a `CreateElicitationResponse`
|
|
137
|
+
* `accept` action carries. `answers` is keyed by question id (string for
|
|
138
|
+
* single/text, string[] for multi) AND by `customFieldId` (string) where the
|
|
139
|
+
* user typed a free-text override.
|
|
140
|
+
*
|
|
141
|
+
* Single/text: a non-empty, trimmed custom answer WINS over a selection —
|
|
142
|
+
* mirrors `applyAskElicitationResponse`'s documented read order exactly (it
|
|
143
|
+
* checks the `_custom` field first and only falls back to the base field when
|
|
144
|
+
* empty), so a single-select AskUserQuestion field folds identically however
|
|
145
|
+
* the adapter reads it back. This is a real protocol constraint, not a UI
|
|
146
|
+
* choice — a single-select question only has room for one answer.
|
|
147
|
+
*
|
|
148
|
+
* Multi: a non-empty custom answer is APPENDED to the checked options instead
|
|
149
|
+
* (routed through the BASE field, `content[q.id]`, never `content[customFieldId]`)
|
|
150
|
+
* — dogfooding expected "type something extra" to ADD to a multi-select pick,
|
|
151
|
+
* not silently discard it the way single-select's override does. This is safe
|
|
152
|
+
* specifically because `applyAskElicitationResponse` only reads the custom key
|
|
153
|
+
* to decide whether to override; when it's absent, it falls through to
|
|
154
|
+
* `Array.isArray(value) ? value.join(", ") : ...` on the base field, which
|
|
155
|
+
* doesn't validate array entries against the declared enum — appending an
|
|
156
|
+
* arbitrary string is exactly what a real multi-select "and also…" answer
|
|
157
|
+
* should read as once joined. `content[customFieldId]` is deliberately never
|
|
158
|
+
* populated for a multi-select question, so the override path never fires.
|
|
159
|
+
*
|
|
160
|
+
* An unanswered, non-required question is simply omitted from `content`
|
|
161
|
+
* (Skip/leave-blank must never fabricate a value).
|
|
162
|
+
*/
|
|
163
|
+
export function buildElicitationContent(questions, answers) {
|
|
164
|
+
// `Object.create(null)` — a question/custom-field id of `__proto__` (an
|
|
165
|
+
// agent-supplied schema key, technically attacker-influenceable) would
|
|
166
|
+
// otherwise hit the Object.prototype accessor on a plain `{}` literal
|
|
167
|
+
// instead of creating an own key, silently dropping that answer
|
|
168
|
+
// (security-auditor finding; not exploitable across the wire since
|
|
169
|
+
// JSON.stringify never serializes it either way, but a null-prototype
|
|
170
|
+
// object closes it outright rather than relying on that side effect).
|
|
171
|
+
const content = Object.create(null);
|
|
172
|
+
const src = answers && typeof answers === 'object' ? answers : {};
|
|
173
|
+
for (const q of questions ?? []) {
|
|
174
|
+
const custom = q.customFieldId ? src[q.customFieldId] : undefined;
|
|
175
|
+
const trimmedCustom = typeof custom === 'string' ? custom.trim() : '';
|
|
176
|
+
|
|
177
|
+
if (q.kind === 'multi') {
|
|
178
|
+
const selected = Array.isArray(src[q.id]) ? src[q.id] : [];
|
|
179
|
+
const combined = trimmedCustom ? [...selected, trimmedCustom] : selected;
|
|
180
|
+
if (combined.length) content[q.id] = combined;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (trimmedCustom) {
|
|
185
|
+
content[q.customFieldId] = trimmedCustom;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const value = src[q.id];
|
|
189
|
+
if (typeof value === 'string' && value !== '') {
|
|
190
|
+
content[q.id] = value;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return content;
|
|
194
|
+
}
|
|
@@ -144,6 +144,45 @@ export function createAcpConnection() {
|
|
|
144
144
|
const commandListeners = new Set();
|
|
145
145
|
let commands = [];
|
|
146
146
|
|
|
147
|
+
// Session capabilities (feature-acp-panel-dynamic-claude-code-capabilities)
|
|
148
|
+
// — the live, dynamic replacement for the old hardcoded MODELS/EFFORTS
|
|
149
|
+
// arrays. `caps` = `{modes, configOptions}`, sourced entirely from the ACP
|
|
150
|
+
// session (never a static list); `sessionInfo` = the agent-generated title.
|
|
151
|
+
const capsListeners = new Set();
|
|
152
|
+
let caps = { modes: null, configOptions: [] };
|
|
153
|
+
const sessionInfoListeners = new Set();
|
|
154
|
+
let sessionInfo = { title: null, updatedAt: null };
|
|
155
|
+
|
|
156
|
+
// Usage (Milestone D) — the raw `usage` frame's payload, replayed on
|
|
157
|
+
// subscribe like caps/commands/sessionInfo above. Parsed into render-ready
|
|
158
|
+
// shape by acp-usage.js's parseUsage, not here (keep this file transport-only).
|
|
159
|
+
const usageListeners = new Set();
|
|
160
|
+
let usage = null;
|
|
161
|
+
|
|
162
|
+
// Pending permission requests (Milestone B — retires DDR-125 F2's blanket
|
|
163
|
+
// auto-approve). Surfaced OUTSIDE the assistant-ui message stream (like
|
|
164
|
+
// commands/caps above) via a panel-level subscription, since a request can
|
|
165
|
+
// arrive mid-turn and the run() loop only understands text/tool-call/
|
|
166
|
+
// reasoning parts — not "pause here for a human decision."
|
|
167
|
+
const permissionListeners = new Set();
|
|
168
|
+
let pendingPermissions = [];
|
|
169
|
+
function emitPermissions() {
|
|
170
|
+
const snap = pendingPermissions.slice();
|
|
171
|
+
for (const fn of permissionListeners) fn(snap);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Pending elicitation requests (feature-acp-ask-user-question — AskUserQuestion
|
|
175
|
+
// + generic MCP form input). Same "outside the assistant-ui message stream"
|
|
176
|
+
// treatment as pendingPermissions above, for the same reason: a request can
|
|
177
|
+
// arrive mid-turn and the run() loop only understands text/tool-call/reasoning
|
|
178
|
+
// parts, not "pause here for a human form."
|
|
179
|
+
const elicitationListeners = new Set();
|
|
180
|
+
let pendingElicitations = [];
|
|
181
|
+
function emitElicitations() {
|
|
182
|
+
const snap = pendingElicitations.slice();
|
|
183
|
+
for (const fn of elicitationListeners) fn(snap);
|
|
184
|
+
}
|
|
185
|
+
|
|
147
186
|
function emitStatus() {
|
|
148
187
|
for (const fn of statusListeners) fn({ ...status });
|
|
149
188
|
}
|
|
@@ -184,6 +223,22 @@ export function createAcpConnection() {
|
|
|
184
223
|
emitBackground();
|
|
185
224
|
}
|
|
186
225
|
|
|
226
|
+
// Connection-problem error card (Task C3) — the bridge `error` frame (a
|
|
227
|
+
// socket/adapter failure mid-turn) is ALSO thrown from prompt()'s generator
|
|
228
|
+
// (so assistant-ui's own turn-failed bookkeeping still runs), but a thrown
|
|
229
|
+
// generator error renders as a bare, unstyled assistant-ui fallback. Expose
|
|
230
|
+
// it as its own channel too so the panel can render a proper retry card
|
|
231
|
+
// instead — same "chrome, not turn content" treatment as commands/caps.
|
|
232
|
+
const errorListeners = new Set();
|
|
233
|
+
let lastError = null; // { message } | null
|
|
234
|
+
function emitError() {
|
|
235
|
+
for (const fn of errorListeners) fn(lastError);
|
|
236
|
+
}
|
|
237
|
+
function setError(message) {
|
|
238
|
+
lastError = message ? { message } : null;
|
|
239
|
+
emitError();
|
|
240
|
+
}
|
|
241
|
+
|
|
187
242
|
function onFrame(frame) {
|
|
188
243
|
if (frame.t === 'ready') {
|
|
189
244
|
status.ready = true;
|
|
@@ -199,6 +254,62 @@ export function createAcpConnection() {
|
|
|
199
254
|
for (const fn of commandListeners) fn(commands);
|
|
200
255
|
return;
|
|
201
256
|
}
|
|
257
|
+
// Same treatment for the capability channel — chrome, not turn content,
|
|
258
|
+
// arrives on establish + on every live mode/config-option change.
|
|
259
|
+
if (frame.t === 'caps') {
|
|
260
|
+
caps = { modes: frame.modes ?? null, configOptions: frame.configOptions ?? [] };
|
|
261
|
+
for (const fn of capsListeners) fn(caps);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (frame.t === 'session-info') {
|
|
265
|
+
sessionInfo = { title: frame.title ?? null, updatedAt: frame.updatedAt ?? null };
|
|
266
|
+
for (const fn of sessionInfoListeners) fn(sessionInfo);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (frame.t === 'usage') {
|
|
270
|
+
usage = frame.usage ?? null;
|
|
271
|
+
for (const fn of usageListeners) fn(usage);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (frame.t === 'permission-request') {
|
|
275
|
+
pendingPermissions = [
|
|
276
|
+
...pendingPermissions,
|
|
277
|
+
{ id: frame.id, toolCall: frame.toolCall, options: frame.options ?? [] },
|
|
278
|
+
];
|
|
279
|
+
emitPermissions();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (frame.t === 'elicitation-request') {
|
|
283
|
+
pendingElicitations = [
|
|
284
|
+
...pendingElicitations,
|
|
285
|
+
{
|
|
286
|
+
id: frame.id,
|
|
287
|
+
message: frame.message,
|
|
288
|
+
mode: frame.mode,
|
|
289
|
+
requestedSchema: frame.requestedSchema,
|
|
290
|
+
// Present only when this elicitation is scoped to a specific tool
|
|
291
|
+
// call (e.g. the built-in AskUserQuestion) — absent for a raw
|
|
292
|
+
// session-scoped MCP-server elicitation. ElicitationPrompt.jsx uses
|
|
293
|
+
// this to avoid a blanket "from Claude" attribution it can't back up.
|
|
294
|
+
toolCallId: frame.toolCallId,
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
emitElicitations();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// The bridge settled a pending elicitation itself (timeout/cancel/stop) —
|
|
301
|
+
// drop it from the pending list even though the client never responded,
|
|
302
|
+
// so the card doesn't sit open with a Submit button that's already dead.
|
|
303
|
+
// A client-driven response already removed its own entry optimistically
|
|
304
|
+
// (respondElicitation below), so this is a no-op for that path — it only
|
|
305
|
+
// does real work for a settlement the client didn't initiate.
|
|
306
|
+
if (frame.t === 'elicitation-resolved') {
|
|
307
|
+
if (pendingElicitations.some((p) => p.id === frame.id)) {
|
|
308
|
+
pendingElicitations = pendingElicitations.filter((p) => p.id !== frame.id);
|
|
309
|
+
emitElicitations();
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
202
313
|
// Everything else belongs to the active prompt turn.
|
|
203
314
|
trackActivity(frame);
|
|
204
315
|
if (turnHandler) turnHandler(frame);
|
|
@@ -256,6 +367,13 @@ export function createAcpConnection() {
|
|
|
256
367
|
return () => backgroundListeners.delete(fn);
|
|
257
368
|
},
|
|
258
369
|
|
|
370
|
+
/** Subscribe to the connection-problem error card's source (Task C3); replays the current one, if any. */
|
|
371
|
+
onError(fn) {
|
|
372
|
+
errorListeners.add(fn);
|
|
373
|
+
fn(lastError);
|
|
374
|
+
return () => errorListeners.delete(fn);
|
|
375
|
+
},
|
|
376
|
+
|
|
259
377
|
/** Subscribe to the slash-command catalogue; replays the current list. */
|
|
260
378
|
onCommands(fn) {
|
|
261
379
|
commandListeners.add(fn);
|
|
@@ -263,12 +381,111 @@ export function createAcpConnection() {
|
|
|
263
381
|
return () => commandListeners.delete(fn);
|
|
264
382
|
},
|
|
265
383
|
|
|
384
|
+
/** Subscribe to the live capability set (`{modes, configOptions}`); replays the current snapshot. */
|
|
385
|
+
onCaps(fn) {
|
|
386
|
+
capsListeners.add(fn);
|
|
387
|
+
fn(caps);
|
|
388
|
+
return () => capsListeners.delete(fn);
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
/** Subscribe to the agent-generated chat title; replays the current snapshot. */
|
|
392
|
+
onSessionInfo(fn) {
|
|
393
|
+
sessionInfoListeners.add(fn);
|
|
394
|
+
fn(sessionInfo);
|
|
395
|
+
return () => sessionInfoListeners.delete(fn);
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
/** Subscribe to the raw usage frame (Milestone D); replays the current snapshot (may be null). */
|
|
399
|
+
onUsage(fn) {
|
|
400
|
+
usageListeners.add(fn);
|
|
401
|
+
fn(usage);
|
|
402
|
+
return () => usageListeners.delete(fn);
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
/** Subscribe to the list of currently-pending permission requests; replays the current snapshot. */
|
|
406
|
+
onPermission(fn) {
|
|
407
|
+
permissionListeners.add(fn);
|
|
408
|
+
fn(pendingPermissions);
|
|
409
|
+
return () => permissionListeners.delete(fn);
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
/** Subscribe to the list of currently-pending elicitation requests; replays the current snapshot. */
|
|
413
|
+
onElicitation(fn) {
|
|
414
|
+
elicitationListeners.add(fn);
|
|
415
|
+
fn(pendingElicitations);
|
|
416
|
+
return () => elicitationListeners.delete(fn);
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Answer a pending permission request — `decision` is one of the
|
|
421
|
+
* request's own `options[].optionId`, or `'cancelled'` to reject. Removes
|
|
422
|
+
* it from the pending list optimistically (the server has no separate
|
|
423
|
+
* "resolved" frame; the turn just continues). A response for an id that's
|
|
424
|
+
* no longer pending (already timed out, or a duplicate click) is a no-op.
|
|
425
|
+
*/
|
|
426
|
+
async respondPermission(id, decision) {
|
|
427
|
+
if (!pendingPermissions.some((p) => p.id === id)) return;
|
|
428
|
+
pendingPermissions = pendingPermissions.filter((p) => p.id !== id);
|
|
429
|
+
emitPermissions();
|
|
430
|
+
try {
|
|
431
|
+
await ensureOpen();
|
|
432
|
+
ws?.send(JSON.stringify({ t: 'permission-response', id, decision }));
|
|
433
|
+
} catch {
|
|
434
|
+
/* socket unavailable — the bridge's own timeout will deny this request */
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Answer a pending elicitation request — `response` is `{ action, content? }`,
|
|
440
|
+
* `action` one of `'accept'`/`'decline'`/`'cancel'`. Mirrors `respondPermission`
|
|
441
|
+
* exactly, including the optimistic removal + no-op-on-stale-id behavior.
|
|
442
|
+
*/
|
|
443
|
+
async respondElicitation(id, response) {
|
|
444
|
+
if (!pendingElicitations.some((p) => p.id === id)) return;
|
|
445
|
+
pendingElicitations = pendingElicitations.filter((p) => p.id !== id);
|
|
446
|
+
emitElicitations();
|
|
447
|
+
try {
|
|
448
|
+
await ensureOpen();
|
|
449
|
+
ws?.send(JSON.stringify({ t: 'elicitation-response', id, ...response }));
|
|
450
|
+
} catch {
|
|
451
|
+
/* socket unavailable — the bridge's own timeout will decline this request */
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Live-set the session mode (Manual/Plan/…) for `chatId` on an
|
|
457
|
+
* ALREADY-established session — no respawn. Fire-and-forget like `warm`;
|
|
458
|
+
* the server validates against its own last-advertised roster and
|
|
459
|
+
* silently ignores an unadvertised id, so a stale/racy pick never corrupts
|
|
460
|
+
* state — the next `caps` frame is the source of truth either way.
|
|
461
|
+
*/
|
|
462
|
+
async setMode(chatId, modeId) {
|
|
463
|
+
try {
|
|
464
|
+
await ensureOpen();
|
|
465
|
+
ws?.send(JSON.stringify({ t: 'set-mode', chat: chatId || undefined, modeId }));
|
|
466
|
+
} catch {
|
|
467
|
+
/* socket unavailable — non-fatal */
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
/** Live-set one config option (model/effort/fast/…) for `chatId`. See `setMode`. */
|
|
472
|
+
async setConfig(chatId, configId, value) {
|
|
473
|
+
try {
|
|
474
|
+
await ensureOpen();
|
|
475
|
+
ws?.send(JSON.stringify({ t: 'set-config', chat: chatId || undefined, configId, value }));
|
|
476
|
+
} catch {
|
|
477
|
+
/* socket unavailable — non-fatal */
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
|
|
266
481
|
/**
|
|
267
482
|
* Warm the adapter WITHOUT prompting so the agent publishes its command
|
|
268
483
|
* catalogue. Fired when the user starts typing a slash command. Best-effort;
|
|
269
|
-
* a dead socket just leaves autocomplete on the static list.
|
|
484
|
+
* a dead socket just leaves autocomplete on the static list. `model`/
|
|
485
|
+
* `effort`/`mode` are the user's PERSISTED picks — applied once, live, by
|
|
486
|
+
* the bridge right after the session establishes (never onto an existing one).
|
|
270
487
|
*/
|
|
271
|
-
async warm(chatId, model, effort) {
|
|
488
|
+
async warm(chatId, model, effort, mode) {
|
|
272
489
|
try {
|
|
273
490
|
await ensureOpen();
|
|
274
491
|
ws?.send(
|
|
@@ -277,6 +494,7 @@ export function createAcpConnection() {
|
|
|
277
494
|
chat: chatId || undefined,
|
|
278
495
|
model: model || undefined,
|
|
279
496
|
effort: effort || undefined,
|
|
497
|
+
mode: mode || undefined,
|
|
280
498
|
})
|
|
281
499
|
);
|
|
282
500
|
} catch {
|
|
@@ -287,17 +505,25 @@ export function createAcpConnection() {
|
|
|
287
505
|
/**
|
|
288
506
|
* Drive one prompt turn. Async-generates the bridge's `update` frames until
|
|
289
507
|
* `turn-end`; throws on `error`; sends `cancel` when `abortSignal` aborts.
|
|
508
|
+
* `model`/`effort`/`mode` — see `warm`.
|
|
290
509
|
*/
|
|
291
|
-
async *prompt(text, chatId, abortSignal, model, effort) {
|
|
292
|
-
|
|
510
|
+
async *prompt(text, chatId, abortSignal, model, effort, mode) {
|
|
511
|
+
try {
|
|
512
|
+
await ensureOpen();
|
|
513
|
+
} catch (err) {
|
|
514
|
+
setError(err?.message || 'Could not reach the Claude bridge.');
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
293
517
|
const queue = [];
|
|
294
518
|
let wake = null;
|
|
295
519
|
let ended = false;
|
|
296
520
|
let failure = null;
|
|
297
521
|
turnHandler = (frame) => {
|
|
298
522
|
if (frame.t === 'turn-end') ended = true;
|
|
299
|
-
else if (frame.t === 'error')
|
|
300
|
-
|
|
523
|
+
else if (frame.t === 'error') {
|
|
524
|
+
failure = frame.message || 'The Claude bridge errored.';
|
|
525
|
+
setError(failure); // Task C3 — the styled retry card's source
|
|
526
|
+
} else queue.push(frame); // update / connected / permission
|
|
301
527
|
if (wake) {
|
|
302
528
|
const w = wake;
|
|
303
529
|
wake = null;
|
|
@@ -319,6 +545,7 @@ export function createAcpConnection() {
|
|
|
319
545
|
// setBusy(true) fires FIRST so the finished-ping deferral (ChatPanel) sees
|
|
320
546
|
// busy before these empty emits.
|
|
321
547
|
resetBackground();
|
|
548
|
+
setError(null); // a fresh turn clears any stale card from a prior failure
|
|
322
549
|
if (activeTools.size) {
|
|
323
550
|
activeTools.clear();
|
|
324
551
|
emitActivity();
|
|
@@ -331,6 +558,7 @@ export function createAcpConnection() {
|
|
|
331
558
|
chat: chatId || undefined,
|
|
332
559
|
model: model || undefined,
|
|
333
560
|
effort: effort || undefined,
|
|
561
|
+
mode: mode || undefined,
|
|
334
562
|
})
|
|
335
563
|
);
|
|
336
564
|
for (;;) {
|
|
@@ -369,7 +597,9 @@ export function createAcpConnection() {
|
|
|
369
597
|
};
|
|
370
598
|
}
|
|
371
599
|
|
|
372
|
-
|
|
600
|
+
/** The last user message's plain text — shared by the adapter's run() AND the
|
|
601
|
+
* connection-problem card's "Try again" (Task C3), which re-sends it verbatim. */
|
|
602
|
+
export function lastUserText(messages) {
|
|
373
603
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
374
604
|
const m = messages[i];
|
|
375
605
|
if (m.role !== 'user') continue;
|
|
@@ -512,7 +742,15 @@ function expandPasteChips(text, map) {
|
|
|
512
742
|
* parts, preserving the order in which the agent emits them. `available_commands_update`
|
|
513
743
|
* and `usage_update` are intentionally dropped (chrome noise, not chat content).
|
|
514
744
|
*/
|
|
515
|
-
export function makeAcpAdapter(
|
|
745
|
+
export function makeAcpAdapter(
|
|
746
|
+
conn,
|
|
747
|
+
getChatId,
|
|
748
|
+
getModel,
|
|
749
|
+
getEffort,
|
|
750
|
+
getAttachments,
|
|
751
|
+
getContext,
|
|
752
|
+
getMode
|
|
753
|
+
) {
|
|
516
754
|
return {
|
|
517
755
|
async *run({ messages, abortSignal }) {
|
|
518
756
|
// Let any in-flight clipboard-image upload finish so its chip expands to a
|
|
@@ -540,7 +778,8 @@ export function makeAcpAdapter(conn, getChatId, getModel, getEffort, getAttachme
|
|
|
540
778
|
getChatId(),
|
|
541
779
|
abortSignal,
|
|
542
780
|
getModel?.(),
|
|
543
|
-
getEffort?.()
|
|
781
|
+
getEffort?.(),
|
|
782
|
+
getMode?.()
|
|
544
783
|
)) {
|
|
545
784
|
if (frame.t !== 'update') continue;
|
|
546
785
|
applyUpdate(parts, toolIndex, frame.update);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure parser for the `usage` WS frame (Milestone D — context-window meter +
|
|
2
|
+
// cost + single-window rate-limit banner). Scope is deliberately the genuinely
|
|
3
|
+
// dynamic subset the ACP adapter exposes today (see the plan's Milestone D
|
|
4
|
+
// research): a context-window gauge + session cost (from `usage_update`) and
|
|
5
|
+
// an event-driven SINGLE-window rate-limit signal (`SDKRateLimitInfo` riding
|
|
6
|
+
// `_meta["_claude/rateLimit"]`). The full multi-window "Plan usage limits"
|
|
7
|
+
// panel is explicitly OUT of scope — that data isn't reachable through the
|
|
8
|
+
// pinned adapter version; do not fabricate it here.
|
|
9
|
+
|
|
10
|
+
const RATE_LIMIT_LABELS = {
|
|
11
|
+
five_hour: '5-hour limit',
|
|
12
|
+
seven_day: 'Weekly limit',
|
|
13
|
+
seven_day_opus: 'Weekly · Opus',
|
|
14
|
+
seven_day_sonnet: 'Weekly · Sonnet',
|
|
15
|
+
seven_day_overage_included: 'Weekly (overage included)',
|
|
16
|
+
overage: 'Overage',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A friendly label for an `SDKRateLimitInfo.rateLimitType` — an unrecognized
|
|
20
|
+
* or missing type falls back to a generic label rather than `undefined`, so
|
|
21
|
+
* a future rate-limit-type the adapter adds still renders something sane. */
|
|
22
|
+
function rateLimitLabel(type) {
|
|
23
|
+
return RATE_LIMIT_LABELS[type] || 'Usage limit';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `{ used, size, cost?, rateLimit? }` (the bridge's `BridgeUsage` shape, sent
|
|
28
|
+
* verbatim in a `{t:'usage', usage}` frame) → the render-ready shape:
|
|
29
|
+
* { context: { used, size, pct } | null, cost: {amount,currency} | null,
|
|
30
|
+
* rateLimit: { type, label, pct, resetsAt, status } | null, asOf }
|
|
31
|
+
* Tolerant of a missing/malformed frame or `_meta` — always returns a valid,
|
|
32
|
+
* renderable (if mostly-null) shape rather than throwing. `now` defaults to
|
|
33
|
+
* `Date.now()`; callers (tests) can inject a fixed value for determinism.
|
|
34
|
+
*/
|
|
35
|
+
export function parseUsage(frame, now = Date.now()) {
|
|
36
|
+
const usage = frame && typeof frame === 'object' ? frame.usage : null;
|
|
37
|
+
const asOf = now;
|
|
38
|
+
if (!usage || typeof usage !== 'object') {
|
|
39
|
+
return { context: null, cost: null, rateLimit: null, asOf };
|
|
40
|
+
}
|
|
41
|
+
const used = typeof usage.used === 'number' ? usage.used : null;
|
|
42
|
+
const size = typeof usage.size === 'number' ? usage.size : null;
|
|
43
|
+
const context =
|
|
44
|
+
used != null && size != null && size > 0
|
|
45
|
+
? { used, size, pct: Math.max(0, Math.min(100, Math.round((used / size) * 100))) }
|
|
46
|
+
: null;
|
|
47
|
+
const cost =
|
|
48
|
+
usage.cost && typeof usage.cost === 'object' && typeof usage.cost.amount === 'number'
|
|
49
|
+
? { amount: usage.cost.amount, currency: usage.cost.currency || 'USD' }
|
|
50
|
+
: null;
|
|
51
|
+
|
|
52
|
+
const raw = usage.rateLimit;
|
|
53
|
+
let rateLimit = null;
|
|
54
|
+
if (raw && typeof raw === 'object' && typeof raw.status === 'string') {
|
|
55
|
+
rateLimit = {
|
|
56
|
+
type: raw.rateLimitType || null,
|
|
57
|
+
label: rateLimitLabel(raw.rateLimitType),
|
|
58
|
+
pct: typeof raw.utilization === 'number' ? Math.max(0, Math.min(100, Math.round(raw.utilization))) : null,
|
|
59
|
+
resetsAt: raw.resetsAt || null,
|
|
60
|
+
status: raw.status,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { context, cost, rateLimit, asOf };
|
|
65
|
+
}
|