pi-ui-extend 1.0.36 → 1.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -520,6 +520,7 @@ skills/ packaged agent skills
520
520
  docs/ release documentation
521
521
  tests/ unit, integration, and PTY tests
522
522
  scripts/ build, release, sync, and capture tooling
523
+ acp/ pix-acp: ACP adapter embedding pi into editors like Zed (own install; `npm run check:acp`)
523
524
  ```
524
525
 
525
526
  ---
@@ -28,10 +28,15 @@ export function renderConversationEntry(entry, width, options) {
28
28
  const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => ({
29
29
  ...userLine(line.text, userEntry.id, line.syntaxHighlight, [
30
30
  ...(line.segments ?? []),
31
+ ...diagramStyledSegments(line.diagramSegments, options.colors),
31
32
  ...(line.links?.map((link) => ({ ...link, foreground: options.colors.info, underline: true })) ?? []),
32
33
  ], line.links),
33
34
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
34
35
  ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
36
+ ...(line.codeBlock ? {
37
+ colorOverride: options.colors.codeBlockForeground,
38
+ backgroundOverride: options.colors.codeBlockBackground,
39
+ } : {}),
35
40
  }));
36
41
  return attachImageClickTargets(lines, userEntry.id, userEntry.images, { foreground: options.colors.info, underline: true });
37
42
  };
@@ -94,13 +99,16 @@ function renderAssistantLines(text, width, options) {
94
99
  const existingSegments = line.segments?.map((segment) => ({ ...segment, start: segment.start + contentLeft, end: segment.end + contentLeft })) ?? [];
95
100
  const links = line.links?.map((link) => ({ ...link, start: link.start + contentLeft, end: link.end + contentLeft })) ?? [];
96
101
  const linkSegments = links.map((link) => ({ start: link.start, end: link.end, foreground: options.colors.info, underline: true }));
97
- const allSegments = headingSegment ? [headingSegment, ...existingSegments, ...linkSegments] : [...existingSegments, ...linkSegments];
102
+ const diagramSegments = diagramStyledSegments(line.diagramSegments, options.colors, contentLeft);
103
+ const allSegments = headingSegment
104
+ ? [headingSegment, ...existingSegments, ...diagramSegments, ...linkSegments]
105
+ : [...existingSegments, ...diagramSegments, ...linkSegments];
98
106
  lines.push({
99
107
  text: padHorizontalText(line.text, width),
100
108
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
101
109
  ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
102
- colorOverride: options.colors.assistantForeground,
103
- backgroundOverride: options.colors.assistantMessageBackground,
110
+ colorOverride: line.codeBlock ? options.colors.codeBlockForeground : options.colors.assistantForeground,
111
+ backgroundOverride: line.codeBlock ? options.colors.codeBlockBackground : options.colors.assistantMessageBackground,
104
112
  ...(allSegments.length > 0 ? { segments: allSegments } : {}),
105
113
  ...(links.length > 0 ? { links } : {}),
106
114
  ...(line.syntaxHighlight ? { syntaxHighlight: line.syntaxHighlight } : {}),
@@ -108,3 +116,17 @@ function renderAssistantLines(text, width, options) {
108
116
  }
109
117
  return lines;
110
118
  }
119
+ function diagramStyledSegments(segments, colors, offset = 0) {
120
+ return (segments ?? []).map((segment) => {
121
+ const range = { start: segment.start + offset, end: segment.end + offset };
122
+ switch (segment.class) {
123
+ case "border":
124
+ case "edgeLabel":
125
+ return { ...range, foreground: colors.muted };
126
+ case "edge":
127
+ return { ...range, foreground: colors.accent };
128
+ case "title":
129
+ return { ...range, foreground: colors.accent, bold: true };
130
+ }
131
+ });
132
+ }
@@ -1,3 +1,4 @@
1
+ import { type Cls as MermaidSpanClass } from "grok-mermaid";
1
2
  import { type SyntaxLineHighlight, type ToolBodySyntaxHighlights } from "./syntax-highlight.js";
2
3
  export type RenderedMarkdownLine = {
3
4
  text: string;
@@ -22,6 +23,7 @@ export type RenderedMarkdownTextLine = {
22
23
  text: string;
23
24
  copyText?: string;
24
25
  continuesOnNextLine?: boolean;
26
+ codeBlock?: true;
25
27
  segments?: readonly {
26
28
  start: number;
27
29
  end: number;
@@ -29,8 +31,14 @@ export type RenderedMarkdownTextLine = {
29
31
  }[] | undefined;
30
32
  links?: readonly RenderedMarkdownLink[] | undefined;
31
33
  syntaxHighlight?: SyntaxLineHighlight | undefined;
34
+ diagramSegments?: readonly RenderedMarkdownDiagramSegment[] | undefined;
32
35
  heading?: boolean;
33
36
  };
37
+ export type RenderedMarkdownDiagramSegment = {
38
+ start: number;
39
+ end: number;
40
+ class: Exclude<MermaidSpanClass, "none" | "text">;
41
+ };
34
42
  export type RenderMarkdownTextLinesOptions = {
35
43
  preserveWrappedWordSeparator?: boolean;
36
44
  };
@@ -1,6 +1,12 @@
1
+ import { render as renderMermaid } from "grok-mermaid";
1
2
  import { displayGraphemes, expandTabs, stringDisplayWidth } from "./terminal-width.js";
2
3
  import { syntaxHighlightLanguageForMarkdownFence, } from "./syntax-highlight.js";
3
4
  const MIN_TRAILING_WORD_WIDTH_TO_REBALANCE = 5;
5
+ const MERMAID_CACHE_LIMIT = 100;
6
+ const MERMAID_MAX_PANELS = 4;
7
+ const MERMAID_MIN_PANEL_WIDTH = 40;
8
+ const MERMAID_SEQUENCE_MESSAGE_OPERATORS = ["-->>", "->>", "--x", "-x", "--)", "-)", "-->", "->"];
9
+ const mermaidRenderCache = new Map();
4
10
  export function formatMarkdownTables(text, maxWidth) {
5
11
  const lines = text.split("\n");
6
12
  const formatted = [];
@@ -85,14 +91,40 @@ export function renderMarkdownLine(text, start = 0) {
85
91
  export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
86
92
  const lines = [];
87
93
  let fence;
94
+ let fenceHasBodyLine = false;
88
95
  const formattedText = formatMarkdownTables(sanitizeMarkdownText(text), width);
89
96
  if (formattedText.length === 0)
90
97
  return [];
91
- for (const rawLine of formattedText.split("\n")) {
98
+ const rawLines = formattedText.split("\n");
99
+ for (let lineIndex = 0; lineIndex < rawLines.length;) {
100
+ const rawLine = rawLines[lineIndex] ?? "";
92
101
  const nextFence = markdownFence(rawLine);
102
+ if (!fence && nextFence && isMermaidFence(nextFence)) {
103
+ const diagram = renderMermaidBlock(rawLines, lineIndex, nextFence, width);
104
+ if (diagram) {
105
+ lines.push(...diagram.lines);
106
+ lineIndex = diagram.nextLineIndex;
107
+ continue;
108
+ }
109
+ }
93
110
  const closesFence = Boolean(fence && nextFence && fence.marker === nextFence.marker && nextFence.length >= fence.length);
94
111
  const opensFence = !fence && nextFence !== undefined;
95
- const syntaxHighlight = markdownLineSyntaxHighlight(fence, Boolean(opensFence || closesFence), start);
112
+ if (opensFence && nextFence) {
113
+ fence = { ...nextFence, language: syntaxHighlightLanguageForMarkdownFence(nextFence.info) };
114
+ fenceHasBodyLine = false;
115
+ lineIndex += 1;
116
+ continue;
117
+ }
118
+ if (closesFence) {
119
+ if (!fenceHasBodyLine)
120
+ lines.push({ text: "", codeBlock: true });
121
+ fence = undefined;
122
+ fenceHasBodyLine = false;
123
+ lineIndex += 1;
124
+ continue;
125
+ }
126
+ const syntaxHighlight = markdownLineSyntaxHighlight(fence, false, start);
127
+ const codeBlock = fence !== undefined;
96
128
  const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
97
129
  const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
98
130
  const logicalLine = markdownLine ?? { text: rawLine, segments: [], links: [] };
@@ -113,21 +145,245 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
113
145
  text: wrapped.text,
114
146
  ...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
115
147
  ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
148
+ ...(codeBlock ? { codeBlock: true } : {}),
116
149
  ...(wrapped.segments.length > 0 ? { segments: wrapped.segments } : {}),
117
150
  ...(wrapped.links && wrapped.links.length > 0 ? { links: wrapped.links } : {}),
118
151
  ...(wrappedSyntaxHighlight ? { syntaxHighlight: wrappedSyntaxHighlight } : {}),
119
152
  ...(isHeadingLine ? { heading: true } : {}),
120
153
  });
121
154
  }
122
- if (opensFence && nextFence) {
123
- fence = { ...nextFence, language: syntaxHighlightLanguageForMarkdownFence(nextFence.info) };
155
+ if (codeBlock)
156
+ fenceHasBodyLine = true;
157
+ lineIndex += 1;
158
+ }
159
+ if (fence && !fenceHasBodyLine)
160
+ lines.push({ text: "", codeBlock: true });
161
+ return lines;
162
+ }
163
+ function renderMermaidBlock(rawLines, openingLineIndex, openingFence, width) {
164
+ let closingLineIndex;
165
+ for (let index = openingLineIndex + 1; index < rawLines.length; index += 1) {
166
+ const candidate = markdownFence(rawLines[index] ?? "");
167
+ if (candidate && candidate.marker === openingFence.marker && candidate.length >= openingFence.length) {
168
+ closingLineIndex = index;
169
+ break;
124
170
  }
125
- else if (closesFence) {
126
- fence = undefined;
171
+ }
172
+ const sourceEnd = closingLineIndex ?? rawLines.length;
173
+ const source = rawLines.slice(openingLineIndex + 1, sourceEnd).join("\n");
174
+ let art = cachedMermaidRender(source);
175
+ if (!art)
176
+ return undefined;
177
+ const availableWidth = Math.max(1, width);
178
+ let participantLegend = [];
179
+ let messageLegend = [];
180
+ if (art.width > availableWidth) {
181
+ const compactSequence = compactMermaidSequence(source);
182
+ if (compactSequence) {
183
+ const compactArt = cachedMermaidRender(compactSequence.source);
184
+ if (compactArt && compactArt.width < art.width) {
185
+ art = compactArt;
186
+ participantLegend = compactSequence.participantLegend;
187
+ messageLegend = compactSequence.messageLegend;
188
+ }
189
+ }
190
+ }
191
+ const panelStarts = mermaidPanelStarts(art.width, availableWidth);
192
+ if (panelStarts.length > 1 && (availableWidth < MERMAID_MIN_PANEL_WIDTH || panelStarts.length > MERMAID_MAX_PANELS)) {
193
+ return undefined;
194
+ }
195
+ return {
196
+ lines: [
197
+ ...mermaidArtLines(art, availableWidth, panelStarts),
198
+ ...mermaidLegendLines("Participants", participantLegend, availableWidth),
199
+ ...mermaidLegendLines("Messages", messageLegend, availableWidth),
200
+ ],
201
+ nextLineIndex: closingLineIndex === undefined ? rawLines.length : closingLineIndex + 1,
202
+ };
203
+ }
204
+ function compactMermaidSequence(source) {
205
+ const lines = source.split("\n");
206
+ const header = lines.find((line) => line.trim().length > 0 && !line.trimStart().startsWith("%%"));
207
+ if (!header || !/^sequenceDiagram\s*$/iu.test(header.trim()))
208
+ return undefined;
209
+ const participantLegend = [];
210
+ const messageLegend = [];
211
+ const autonumber = lines.some((line) => /^\s*autonumber(?:\s|$)/iu.test(line));
212
+ let participantNumber = 0;
213
+ let messageNumber = 0;
214
+ const compactLines = lines.map((line) => {
215
+ const match = /^(\s*)(participant|actor)\s+(\S+?)(?:\s+as\s+(.+?))?\s*$/iu.exec(line);
216
+ if (match) {
217
+ const [, indent = "", declaration = "participant", id = "", rawLabel] = match;
218
+ participantNumber += 1;
219
+ const marker = `P${participantNumber}`;
220
+ const label = rawLabel ? normalizeMermaidLegendLabel(rawLabel) : id;
221
+ participantLegend.push(`${marker} (${id}) — ${label}`);
222
+ return `${indent}${declaration} ${id} as ${marker}`;
223
+ }
224
+ const message = mermaidSequenceMessage(line);
225
+ if (!message)
226
+ return line;
227
+ messageNumber += 1;
228
+ if (message.label.length === 0)
229
+ return line;
230
+ messageLegend.push(`${messageNumber}. ${normalizeMermaidLegendLabel(message.label)}`);
231
+ return autonumber ? message.prefix : `${message.prefix}: [${messageNumber}]`;
232
+ });
233
+ return participantLegend.length > 0 || messageLegend.length > 0
234
+ ? { source: compactLines.join("\n"), participantLegend, messageLegend }
235
+ : undefined;
236
+ }
237
+ function mermaidSequenceMessage(line) {
238
+ for (const operator of MERMAID_SEQUENCE_MESSAGE_OPERATORS) {
239
+ const operatorIndex = line.indexOf(operator);
240
+ if (operatorIndex < 0)
241
+ continue;
242
+ const colonIndex = line.indexOf(":", operatorIndex + operator.length);
243
+ const targetEnd = colonIndex < 0 ? line.length : colonIndex;
244
+ const from = line.slice(0, operatorIndex).trim();
245
+ const to = line.slice(operatorIndex + operator.length, targetEnd).trim();
246
+ if (!from || !to || /\s/u.test(from) || /\s/u.test(to))
247
+ return undefined;
248
+ return {
249
+ prefix: line.slice(0, targetEnd).trimEnd(),
250
+ label: colonIndex < 0 ? "" : line.slice(colonIndex + 1).trim(),
251
+ };
252
+ }
253
+ return undefined;
254
+ }
255
+ function normalizeMermaidLegendLabel(label) {
256
+ const normalized = label
257
+ .replace(/<br\s*\/?\s*>/giu, " ")
258
+ .replace(/<\/?[A-Za-z][^>]*>/gu, "")
259
+ .replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, "$1$2")
260
+ .trim();
261
+ return normalized || label.trim();
262
+ }
263
+ function mermaidPanelStarts(artWidth, panelWidth) {
264
+ if (artWidth <= panelWidth)
265
+ return [0];
266
+ const overlap = Math.min(24, Math.max(8, Math.floor(panelWidth / 5)));
267
+ const stride = Math.max(1, panelWidth - overlap);
268
+ const panelCount = 1 + Math.ceil((artWidth - panelWidth) / stride);
269
+ return Array.from({ length: panelCount }, (_, index) => Math.min(index * stride, artWidth - panelWidth));
270
+ }
271
+ function mermaidArtLines(art, width, panelStarts) {
272
+ if (panelStarts.length === 1) {
273
+ return art.plain.map((text, rowIndex) => {
274
+ const diagramSegments = mermaidDiagramSegments(text, art.styled[rowIndex] ?? []);
275
+ return { text, ...(diagramSegments.length > 0 ? { diagramSegments } : {}) };
276
+ });
277
+ }
278
+ const lines = [];
279
+ for (const [panelIndex, startColumn] of panelStarts.entries()) {
280
+ const continuesLeft = startColumn > 0;
281
+ const continuesRight = startColumn + width < art.width;
282
+ const title = `Mermaid ${panelIndex + 1}/${panelStarts.length}${continuesLeft ? " ←" : ""}${continuesRight ? " →" : ""}`;
283
+ lines.push({
284
+ text: title,
285
+ diagramSegments: [{ start: 0, end: title.length, class: "title" }],
286
+ });
287
+ for (const [rowIndex, text] of art.plain.entries()) {
288
+ lines.push(sliceMermaidDiagramRow(text, art.styled[rowIndex] ?? [], startColumn, width));
289
+ }
290
+ }
291
+ return lines;
292
+ }
293
+ function mermaidLegendLines(title, entries, width) {
294
+ if (entries.length === 0)
295
+ return [];
296
+ const lines = [{
297
+ text: title,
298
+ diagramSegments: [{ start: 0, end: title.length, class: "title" }],
299
+ }];
300
+ for (const entry of entries) {
301
+ for (const wrapped of wrapRenderedMarkdownLine({ text: entry, segments: [], links: [] }, width, {})) {
302
+ lines.push({
303
+ text: wrapped.text,
304
+ ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
305
+ });
127
306
  }
128
307
  }
129
308
  return lines;
130
309
  }
310
+ function sliceMermaidDiagramRow(text, spans, startColumn, width) {
311
+ const endColumn = startColumn + width;
312
+ const diagramSegments = [];
313
+ let result = "";
314
+ let column = 0;
315
+ let spanIndex = 0;
316
+ let spanEnd = spans[0]?.text.length ?? 0;
317
+ for (const grapheme of displayGraphemes(text)) {
318
+ const nextColumn = column + grapheme.width;
319
+ if (nextColumn <= startColumn) {
320
+ column = nextColumn;
321
+ continue;
322
+ }
323
+ if (column >= endColumn)
324
+ break;
325
+ while (spanIndex < spans.length - 1 && grapheme.start >= spanEnd) {
326
+ spanIndex += 1;
327
+ spanEnd += spans[spanIndex]?.text.length ?? 0;
328
+ }
329
+ const fullyVisible = column >= startColumn && nextColumn <= endColumn;
330
+ const chunk = fullyVisible
331
+ ? grapheme.text
332
+ : " ".repeat(Math.max(0, Math.min(nextColumn, endColumn) - Math.max(column, startColumn)));
333
+ const chunkStart = result.length;
334
+ result += chunk;
335
+ const spanClass = spans[spanIndex]?.cls;
336
+ if (fullyVisible && spanClass && spanClass !== "none" && spanClass !== "text") {
337
+ const previous = diagramSegments.at(-1);
338
+ if (previous && previous.class === spanClass && previous.end === chunkStart) {
339
+ previous.end = result.length;
340
+ }
341
+ else {
342
+ diagramSegments.push({ start: chunkStart, end: result.length, class: spanClass });
343
+ }
344
+ }
345
+ column = nextColumn;
346
+ }
347
+ return { text: result, ...(diagramSegments.length > 0 ? { diagramSegments } : {}) };
348
+ }
349
+ function cachedMermaidRender(source) {
350
+ if (mermaidRenderCache.has(source)) {
351
+ const cached = mermaidRenderCache.get(source) ?? null;
352
+ mermaidRenderCache.delete(source);
353
+ mermaidRenderCache.set(source, cached);
354
+ return cached;
355
+ }
356
+ let art;
357
+ try {
358
+ art = renderMermaid(source.replace(/<br\s*\/?\s*>/giu, " "));
359
+ }
360
+ catch {
361
+ art = null;
362
+ }
363
+ mermaidRenderCache.set(source, art);
364
+ if (mermaidRenderCache.size > MERMAID_CACHE_LIMIT) {
365
+ const oldestKey = mermaidRenderCache.keys().next().value;
366
+ if (oldestKey !== undefined)
367
+ mermaidRenderCache.delete(oldestKey);
368
+ }
369
+ return art;
370
+ }
371
+ function mermaidDiagramSegments(text, spans) {
372
+ const segments = [];
373
+ let start = 0;
374
+ for (const span of spans) {
375
+ const end = start + span.text.length;
376
+ if (start < text.length && span.cls !== "none" && span.cls !== "text") {
377
+ segments.push({ start, end: Math.min(end, text.length), class: span.cls });
378
+ }
379
+ start = end;
380
+ }
381
+ return segments;
382
+ }
383
+ function isMermaidFence(fence) {
384
+ const token = fence.info.trim().split(/\s+/, 1)[0]?.toLowerCase().replace(/^[{.]+|[}.]+$/gu, "") ?? "";
385
+ return token === "mermaid";
386
+ }
131
387
  export function markdownSyntaxHighlightsForText(text, startColumn = 0) {
132
388
  const highlights = [];
133
389
  let fence;
package/dist/theme.d.ts CHANGED
@@ -18,6 +18,8 @@ export type Theme = {
18
18
  assistantMessageBackground: string;
19
19
  userMessageBackground: string;
20
20
  thinkingMessageBackground: string;
21
+ codeBlockForeground: string;
22
+ codeBlockBackground: string;
21
23
  inputCursorBackground: string;
22
24
  popupForeground: string;
23
25
  popupBackground: string;
package/dist/theme.js CHANGED
@@ -19,6 +19,8 @@ export const THEMES = {
19
19
  assistantMessageBackground: "",
20
20
  userMessageBackground: "",
21
21
  thinkingMessageBackground: "",
22
+ codeBlockForeground: "#e6edf3",
23
+ codeBlockBackground: "#2a2f36",
22
24
  inputCursorBackground: "#7fb3c8",
23
25
  popupForeground: "#e6edf3",
24
26
  popupBackground: "#1e1e1e",
@@ -75,6 +77,8 @@ export const THEMES = {
75
77
  assistantMessageBackground: "",
76
78
  userMessageBackground: "",
77
79
  thinkingMessageBackground: "",
80
+ codeBlockForeground: "#0f172a",
81
+ codeBlockBackground: "#f1f5f9",
78
82
  inputCursorBackground: "#0284c7",
79
83
  popupForeground: "#0f172a",
80
84
  popupBackground: "#ffffff",
@@ -210,7 +210,7 @@
210
210
  "model": "zai/glm-5.3-flash",
211
211
  "fallbackModels": ["openai-codex/gpt-5.6-luna"],
212
212
  "thinking": "low",
213
- "timeoutMs": 120000,
213
+ "timeoutMs": 300000,
214
214
  "tools": ["read", "grep", "bash"]
215
215
  },
216
216
 
@@ -218,7 +218,7 @@ const BUILTIN_CONFIG: SubagentConfig = {
218
218
  model: "zai/glm-5.3-flash",
219
219
  fallbackModels: ["openai-codex/gpt-5.6-luna"],
220
220
  thinking: "low",
221
- timeoutMs: 120_000,
221
+ timeoutMs: 300_000,
222
222
  tools: ["read", "grep", "bash"],
223
223
  isolatedSkills: [getBrowserQaSkillPath()],
224
224
  },
@@ -24,12 +24,18 @@ the requested target in the browser.
24
24
 
25
25
  ## Workflow
26
26
 
27
- Treat target discovery as a 30-second preflight and invoke the runner within 45
28
- seconds of starting. Use at most one runner invocation unless the task explicitly
29
- requests multiple auth profiles. If you cannot identify a reachable target and
30
- a supported deterministic assertion inside that preflight, return a structured
31
- `BLOCKED` result immediately. Do not consume the launcher budget on further
32
- source reading, server polling, capability probing, or retries.
27
+ Treat target discovery as a 30-second preflight and begin browser execution within
28
+ 45 seconds of starting. For a verification task, use at most one actual `run`
29
+ browser execution per profile. Metadata/preparation commands such as `profiles`
30
+ and form-auth `scaffold` do not count as browser verification runs. For an
31
+ explicit exploratory/manual-QA task, use at most three `run` rounds, give each a
32
+ unique `--run-id`, bound each with `--runner-timeout-ms 60000`, and let each round
33
+ test one concrete hypothesis learned from the prior evidence. Stop earlier once
34
+ the requested behavior is explained or no new supported hypothesis remains. If
35
+ you cannot identify a reachable target and a supported deterministic assertion
36
+ inside the preflight, return a structured `BLOCKED` result immediately. Do not
37
+ consume the launcher budget on open-ended source reading, server polling,
38
+ capability probing, or retries.
33
39
 
34
40
  1. Resolve `scripts/browser-qa-runner.mjs` relative to this skill.
35
41
  2. Use the launcher-provided `$PI_SUBAGENT_AGENT_DIR/browser-qa/` workspace.
@@ -56,15 +62,27 @@ source reading, server polling, capability probing, or retries.
56
62
  operations documented below; it does not accept expressions or scripts.
57
63
  6. Run public QA with
58
64
  `node <runner> run --base-url <url> --flow <flow.jsonc>`. The URL's exact
59
- origin becomes the fail-closed allowlist. Only for authenticated QA, add
60
- `--profile <id>`; the selected profile then owns the URL and allowlist.
65
+ origin becomes the fail-closed allowlist. When the real app requires known
66
+ API/CDN origins, add repeatable `--allow-origin <exact-origin>` flags. Each
67
+ value must be an exact `http(s)` origin with no path, credentials, wildcard,
68
+ or inferred sibling domain; undeclared origins remain blocked. Only for
69
+ authenticated QA, add `--profile <id>`; the selected profile then owns the
70
+ URL and allowlist.
61
71
  Profile id, URL, and flow path are non-secret; never pass credentials as
62
72
  arguments or environment variables.
63
73
  7. Report deterministic assertions and every artifact returned by the runner.
64
- For each screenshot, video, trace, or retained download, emit a separate clickable Markdown
65
- link using its `uri` and also show its absolute `path`. Do this for failed
66
- runs too whenever `artifacts` is present; never report only `evidenceDir`.
67
- Visual inspection supplements assertions; it does not replace them.
74
+ For each screenshot, video, trace, or retained download, emit a separate
75
+ clickable Markdown link using its `uri` and also show its absolute `path`.
76
+ Do this for failed runs too whenever `artifacts` is present; never report only
77
+ `evidenceDir`.
78
+ When screenshots are present, inspect at least one representative meaningful
79
+ PNG directly with the `read` tool on its absolute path before claiming visual
80
+ QA. Inspect additional screenshots when they represent materially different
81
+ states or popups. Record `visualInspection: inspected` plus the inspected
82
+ paths and concrete findings. If image reading is unavailable in the active
83
+ model, report `visualInspection: unavailable` and do not claim a visual pass;
84
+ deterministic assertions may still be reported separately. Visual inspection
85
+ supplements assertions; it does not replace them.
68
86
 
69
87
  ## Scenario design
70
88
 
@@ -84,11 +102,14 @@ source reading, server polling, capability probing, or retries.
84
102
  animation/debounce. Set flow `timeoutMs` only as high as the target
85
103
  legitimately needs.
86
104
  - The runner waits after every visible interaction until the document is ready,
87
- requests started by the interaction have finished, and common visible busy
105
+ observes requests started by the interaction for a bounded readiness window,
106
+ and waits for common visible busy
88
107
  markers (including `aria-busy`, progress bars, loading/spinner/skeleton test
89
- ids and classes) disappear. It then keeps the stable state on video for 500
90
- ms. A busy page that does not settle within `timeoutMs` fails instead of
91
- continuing against a skeleton. For an app-specific loader not covered by
108
+ ids and classes) to disappear. EventSource/WebSocket traffic is excluded and
109
+ a long-poll/background request cannot pin readiness for the full flow timeout;
110
+ visible busy UI can. It then keeps the stable state on video for 500 ms. A
111
+ busy page that does not settle within `timeoutMs` fails instead of continuing
112
+ against a skeleton. For an app-specific loader not covered by
92
113
  those conventions, add an explicit `waitFor`/`assertHidden` for that loader
93
114
  and assert the loaded content before interacting with it.
94
115
  - Recorded pointer actions are annotated automatically: clicks and double-clicks
@@ -101,7 +122,8 @@ source reading, server polling, capability probing, or retries.
101
122
  pointer input, and clears before the runner's post-action stable interval
102
123
  completes.
103
124
  - Place `authRejectedIf` immediately after navigation or any transition that
104
- may reveal expired authentication.
125
+ may reveal expired authentication. It must declare `urlIncludes` or a locator;
126
+ an empty rejection check is invalid.
105
127
  - Never weaken an assertion merely to make a failing run pass. If the observed
106
128
  product behavior differs from the expectation, preserve the failure evidence
107
129
  and report the mismatch.
@@ -120,7 +142,7 @@ Supported actions:
120
142
  `uncheck`, `selectOption`, `wheel`, `evaluate`, `dragTo`, `uploadFiles`,
121
143
  `openPopup`, `download`
122
144
  - assertions: `assertVisible`, `assertHidden`, `assertEnabled`,
123
- `assertDisabled`, `assertChecked`, `assertUnchecked`, `assertText`,
145
+ `assertDisabled`, `assertChecked`, `assertUnchecked`, `assertText`, `assertTextContent`,
124
146
  `assertValue`, `assertAttribute`, `assertCount`, `assertURL`,
125
147
  `assertDOMMetric`
126
148
  - evidence/auth: `screenshot`, `authRejectedIf`
@@ -128,6 +150,9 @@ Supported actions:
128
150
  Locators accept one of `testId`, `role` (plus optional `name`), `label`,
129
151
  `placeholder`, `text`, or `css`; add `exact: true` where useful. String
130
152
  assertions require exactly one of `equals` or `includes`.
153
+ `assertText` matches visible, user-facing `innerText` and therefore fails for a
154
+ hidden locator. Use `assertTextContent` only when raw DOM text, including hidden
155
+ content, is intentionally the oracle.
131
156
  `assertAttribute` additionally requires a bounded `attribute` name and is
132
157
  useful for `aria-*`, `data-*`, `href`, and similar observable state. All
133
158
  assertions retry until `timeoutMs` and report generic failures without exposing
@@ -208,7 +233,11 @@ oracle. Raw JavaScript remains intentionally unsupported.
208
233
  ```jsonc
209
234
  {
210
235
  "viewport": { "width": 844, "height": 847 },
211
- "environment": { "locale": "en-GB", "timezoneId": "Europe/London", "colorScheme": "dark" },
236
+ "environment": {
237
+ "locale": "en-GB",
238
+ "timezoneId": "Europe/London",
239
+ "colorScheme": "dark"
240
+ },
212
241
  "steps": [
213
242
  { "action": "goto", "path": "/settings" },
214
243
  { "action": "authRejectedIf", "urlIncludes": "/login" },
@@ -216,7 +245,11 @@ oracle. Raw JavaScript remains intentionally unsupported.
216
245
  "action": "assertVisible",
217
246
  "locator": { "role": "heading", "name": "Settings", "exact": true }
218
247
  },
219
- { "action": "wheel", "locator": { "css": ".settings-panel" }, "deltaY": 500 },
248
+ {
249
+ "action": "wheel",
250
+ "locator": { "css": ".settings-panel" },
251
+ "deltaY": 500
252
+ },
220
253
  {
221
254
  "action": "assertDOMMetric",
222
255
  "locator": { "css": ".settings-panel" },
@@ -226,9 +259,17 @@ oracle. Raw JavaScript remains intentionally unsupported.
226
259
  {
227
260
  "action": "click",
228
261
  "locator": { "testId": "save-settings" },
229
- "expectResponse": { "path": "/api/settings", "method": "PUT", "status": 200 }
262
+ "expectResponse": {
263
+ "path": "/api/settings",
264
+ "method": "PUT",
265
+ "status": 200
266
+ }
267
+ },
268
+ {
269
+ "action": "assertText",
270
+ "locator": { "testId": "toast" },
271
+ "includes": "Saved"
230
272
  },
231
- { "action": "assertText", "locator": { "testId": "toast" }, "includes": "Saved" },
232
273
  { "action": "screenshot", "name": "settings-saved" }
233
274
  ]
234
275
  }
@@ -301,6 +342,8 @@ After any runner invocation that actually performed browser testing, include
301
342
  all non-empty `artifacts.screenshots`, `artifacts.videos`, and
302
343
  `artifacts.traces`, and `artifacts.downloads` groups in the final response.
303
344
  These links are mandatory so the user can open the evidence directly.
345
+ Also include `visualInspection` with `inspected` or `unavailable`; never infer a
346
+ visual pass from a successful runner status alone.
304
347
 
305
348
  See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`,
306
349
  `references/qa-design.md`, and `references/auth-scaffold-spec.md`.
@@ -19,6 +19,12 @@ proof.
19
19
  When verifying a fix, prefer a focused regression flow over a broad tour of the
20
20
  application. If multiple independent states matter, assert each one explicitly.
21
21
 
22
+ For explicit exploratory/manual QA, keep exploration bounded rather than turning
23
+ it into an open-ended crawl. Run at most three minimal rounds. Each round should
24
+ start from one concrete hypothesis, produce a deterministic observation plus a
25
+ meaningful screenshot, and use that evidence to decide whether another round is
26
+ justified. Verification tasks remain one browser run per profile.
27
+
22
28
  ## Choose resilient locators
23
29
 
24
30
  Prefer locators that match how users and accessibility APIs identify controls:
@@ -42,13 +48,14 @@ by an assertion is enough. Use `waitFor` only when the next operation depends on
42
48
  a distinct attached/detached/visible/hidden transition.
43
49
 
44
50
  After navigation and visible interactions, the runner also waits for DOM
45
- readiness, completion of requests started by that action, disappearance of
46
- common visible `aria-busy`/progress/loading/spinner/skeleton markers, and a
47
- 500 ms stable interval. If those signals remain busy through the flow timeout,
48
- the run fails rather than interacting with a loading shell. This is a safe
49
- baseline, not an application-specific oracle: explicitly wait for a custom
50
- loader to become hidden and assert the loaded content when the application uses
51
- different readiness semantics.
51
+ readiness, tracks requests causally started by that action through a bounded
52
+ readiness window, waits for common visible
53
+ `aria-busy`/progress/loading/spinner/skeleton markers, and keeps a 500 ms stable
54
+ interval. EventSource/WebSocket traffic is ignored, and a long poll is not
55
+ allowed to pin the entire flow timeout. A visible busy indicator may still hold
56
+ readiness until `timeoutMs`. This is a safe baseline, not an application-specific
57
+ oracle: explicitly wait for a custom loader to become hidden and assert the
58
+ loaded content when the application uses different readiness semantics.
52
59
 
53
60
  `waitForTimeout` is bounded to five seconds and should be exceptional—for a
54
61
  known animation, debounce, or externally scheduled transition with no
@@ -58,7 +65,9 @@ behavior. If a normal operation legitimately needs more time, adjust the flow's
58
65
 
59
66
  All assertion actions retry until that timeout. This makes an action followed
60
67
  directly by `assertText`, `assertVisible`, `assertURL`, or another assertion
61
- safe for asynchronously rendered outcomes. Use `assertAttribute` for
68
+ safe for asynchronously rendered outcomes. `assertText` requires the locator to
69
+ be visible and matches rendered `innerText`; use `assertTextContent` only when
70
+ hidden/raw DOM text is deliberately part of the oracle. Use `assertAttribute` for
62
71
  observable state such as `aria-expanded`, `aria-invalid`, or `data-state`
63
72
  instead of reading DOM state through executable JavaScript.
64
73
 
@@ -177,6 +186,12 @@ they explain the chronology without becoming screenshot or assertion oracles.
177
186
  Assertions determine pass/fail; evidence explains it. Preserve and link every
178
187
  artifact group returned on both passed and failed runs.
179
188
 
189
+ For visual QA, do not stop at artifact generation. Open at least one meaningful
190
+ PNG with the model's image-capable `read` path and inspect layout, clipping,
191
+ overlap, state styling, and other visual defects relevant to the scenario. If
192
+ the active model cannot read images, explicitly report visual inspection as
193
+ unavailable rather than treating deterministic assertions as a visual pass.
194
+
180
195
  ## Diagnose failures without weakening the test
181
196
 
182
197
  Classify the first failing step:
@@ -17,6 +17,7 @@ const QA_WORKSPACE_RELATIVE = "browser-qa";
17
17
  const EVIDENCE_RELATIVE = "evidence";
18
18
  const UI_READY_SETTLE_MS = 500;
19
19
  const UI_READY_POLL_MS = 100;
20
+ const ACTION_REQUEST_WAIT_MAX_MS = 2_000;
20
21
  const FORM_VIDEO_STEP_DELAY_MS = UI_READY_SETTLE_MS;
21
22
  const POPUP_VIDEO_SETTLE_MS = 250;
22
23
  const CLICK_VIDEO_DELAY_MS = 120;
@@ -160,7 +161,7 @@ async function main() {
160
161
  const args = parseArgs(rawArgs);
161
162
  const selected = args.profile
162
163
  ? selectProfile(readAuthConfig(cwd, true, true).profiles, args.profile)
163
- : createPublicProfile(args.baseUrl);
164
+ : createPublicProfile(args.baseUrl, args.allowOrigin);
164
165
  const profileId = selected.id;
165
166
  const profile = selected.profile;
166
167
  const secrets = collectSecrets(profile.auth);
@@ -249,6 +250,9 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
249
250
  runtimeSecrets.push(...collectStorageStateSecrets(initialStorageState));
250
251
  await runStage(progress, "trace_start", deadline, () => context.tracing.start({ screenshots: true, snapshots: true, sources: false }), profileId);
251
252
  await runStage(progress, "flow_execute", deadline, () => executeFlow({ context, page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets, observations, videos }), profileId);
253
+ await runStage(progress, "storage_state_refresh", deadline, async () => {
254
+ if (await refreshRuntimeSecrets(context, runtimeSecrets)) discardRecordedVideos(videos);
255
+ }, profileId);
252
256
  await runStage(progress, "secret_exposure_check", deadline, () => assertBrowserDoesNotExposeSecrets(context, page, runtimeSecrets, profileId), profileId);
253
257
  await runStage(progress, "final_screenshot", deadline, () => page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true }), profileId);
254
258
  } catch (error) {
@@ -256,15 +260,25 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
256
260
  failure = error;
257
261
  if (page && !page.isClosed() && !error.suppressEvidence) {
258
262
  try {
263
+ await runCleanupStage(progress, "failure_storage_state_refresh", async () => {
264
+ if (await refreshRuntimeSecrets(context, runtimeSecrets)) discardRecordedVideos(videos);
265
+ });
266
+ await runCleanupStage(progress, "failure_secret_exposure_check", () => assertBrowserDoesNotExposeSecrets(context, page, runtimeSecrets, profileId));
259
267
  await runCleanupStage(progress, "failure_screenshot", () => page.screenshot({ path: path.join(evidenceDir, "failure.png"), fullPage: true }));
260
- } catch { /* best effort */ }
268
+ } catch (evidenceError) {
269
+ if (evidenceError instanceof QaStatusError && evidenceError.suppressEvidence) {
270
+ failure = evidenceError;
271
+ outcome = evidenceError.status;
272
+ }
273
+ }
261
274
  }
262
275
  } finally {
263
276
  const cleanupDeadline = Date.now() + CLEANUP_TIMEOUT_MS;
277
+ let discoveredLateSecrets = false;
264
278
  if (context) {
265
279
  try {
266
280
  const finalStorageState = await runCleanupStage(progress, "storage_state_finalize", () => context.storageState(), cleanupDeadline);
267
- runtimeSecrets.push(...collectStorageStateSecrets(finalStorageState));
281
+ discoveredLateSecrets = mergeRuntimeSecrets(runtimeSecrets, collectStorageStateSecrets(finalStorageState));
268
282
  } catch { /* best effort */ }
269
283
  try {
270
284
  await runCleanupStage(progress, "trace_stop", () => context.tracing.stop({ path: tracePath }), cleanupDeadline);
@@ -293,6 +307,14 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
293
307
  } catch { /* best effort */ }
294
308
  }
295
309
  await runCleanupStage(progress, "browser_close", () => browser.close(), cleanupDeadline).catch(() => {});
310
+ if (discoveredLateSecrets) {
311
+ removeVisualEvidence(evidenceDir);
312
+ if (!failure) {
313
+ outcome = "failed";
314
+ failure = new QaStatusError("QA_RUN_FAILED", "authentication changed after the final evidence safety check; visual evidence was discarded", 1, profileId);
315
+ failure.suppressEvidence = true;
316
+ }
317
+ }
296
318
  }
297
319
  try {
298
320
  for (const name of fs.readdirSync(evidenceDir).filter((name) => /^download-[A-Za-z0-9._-]+\.bin$/.test(name))) {
@@ -886,23 +908,29 @@ function authPlaceholderCount(profile) {
886
908
  )).length;
887
909
  }
888
910
 
889
- function createPublicProfile(rawBaseUrl) {
911
+ function createPublicProfile(rawBaseUrl, extraAllowedOrigins = []) {
890
912
  if (typeof rawBaseUrl !== "string") {
891
913
  throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
892
914
  }
893
915
  try {
894
916
  const url = new URL(rawBaseUrl);
895
917
  if (!/^https?:$/.test(url.protocol) || url.username || url.password) throw new Error();
918
+ const allowedOrigins = [url.origin];
919
+ for (const raw of Array.isArray(extraAllowedOrigins) ? extraAllowedOrigins : []) {
920
+ const extra = new URL(raw);
921
+ if (!/^https?:$/.test(extra.protocol) || extra.username || extra.password || extra.href !== `${extra.origin}/`) throw new Error();
922
+ allowedOrigins.push(extra.origin);
923
+ }
896
924
  return {
897
925
  id: "public",
898
926
  profile: {
899
927
  baseUrl: url.href,
900
- allowedOrigins: [url.origin],
928
+ allowedOrigins: [...new Set(allowedOrigins)],
901
929
  auth: { type: "none" },
902
930
  },
903
931
  };
904
932
  } catch {
905
- throw new QaStatusError("QA_RUN_FAILED", "public --base-url must be an http(s) URL without embedded credentials", 1, "public");
933
+ throw new QaStatusError("QA_RUN_FAILED", "public --base-url and --allow-origin values must be http(s) URLs without credentials; --allow-origin must be an exact origin", 1, "public");
906
934
  }
907
935
  }
908
936
 
@@ -1050,6 +1078,15 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1050
1078
  let capturedPopup;
1051
1079
  let unexpectedPopupError;
1052
1080
  const readinessTrackers = new Map();
1081
+ let discardFutureVideos = false;
1082
+ const refreshSecrets = async () => {
1083
+ const changed = await refreshRuntimeSecrets(context, secrets);
1084
+ if (changed) {
1085
+ discardFutureVideos = true;
1086
+ discardRecordedVideos(videos);
1087
+ }
1088
+ return changed;
1089
+ };
1053
1090
  const readinessFor = (candidate) => {
1054
1091
  if (!readinessTrackers.has(candidate)) readinessTrackers.set(candidate, createPageReadinessTracker(candidate));
1055
1092
  return readinessTrackers.get(candidate);
@@ -1078,6 +1115,9 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1078
1115
  }
1079
1116
  const { scope, ownerPage } = await resolveStepTarget({ page, popups, step, allowedOrigins, profileId, index });
1080
1117
  const locator = step.locator === undefined ? undefined : resolveLocator(scope, step.locator, profileId, index);
1118
+ const readinessTracker = readinessFor(ownerPage);
1119
+ const requestScope = UI_READY_ACTIONS.has(step.action) ? readinessTracker.beginActionScope() : undefined;
1120
+ try {
1081
1121
  switch (step.action) {
1082
1122
  case "goto": {
1083
1123
  const target = typeof step.url === "string" ? step.url : step.path;
@@ -1149,7 +1189,7 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1149
1189
  [, popup] = await Promise.all([requireLocator(locator, profileId, index).click({ delay: CLICK_VIDEO_DELAY_MS }), popupPromise]);
1150
1190
  popup.setDefaultTimeout(timeout);
1151
1191
  popup.setDefaultNavigationTimeout(timeout);
1152
- popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard: false };
1192
+ popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard: discardFutureVideos };
1153
1193
  videos.push(popupVideo);
1154
1194
  await popup.waitForLoadState("domcontentloaded", { timeout });
1155
1195
  await waitForUiReady({
@@ -1234,6 +1274,15 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1234
1274
  const target = requireLocator(locator, profileId, index);
1235
1275
  await retryAssertion({
1236
1276
  page: ownerPage, timeout, profileId, index, reason: "text assertion failed",
1277
+ check: async () => (await target.isVisible()) && matches(await target.innerText()),
1278
+ });
1279
+ break;
1280
+ }
1281
+ case "assertTextContent": {
1282
+ const matches = expectedStringMatcher(step, profileId, index, "text content");
1283
+ const target = requireLocator(locator, profileId, index);
1284
+ await retryAssertion({
1285
+ page: ownerPage, timeout, profileId, index, reason: "text content assertion failed",
1237
1286
  check: async () => matches((await target.textContent()) ?? ""),
1238
1287
  });
1239
1288
  break;
@@ -1290,6 +1339,7 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1290
1339
  break;
1291
1340
  }
1292
1341
  case "authRejectedIf": {
1342
+ if (typeof step.urlIncludes !== "string" && !locator) throw flowError(profileId, index, "authRejectedIf requires urlIncludes or locator");
1293
1343
  const urlRejected = typeof step.urlIncludes === "string" && scope.url().includes(step.urlIncludes);
1294
1344
  const locatorRejected = locator ? await locator.isVisible().catch(() => false) : false;
1295
1345
  if (!urlRejected && !locatorRejected) break;
@@ -1298,21 +1348,34 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1298
1348
  case "screenshot": {
1299
1349
  const name = typeof step.name === "string" ? step.name : `step-${index + 1}`;
1300
1350
  if (!isSafeName(name)) throw flowError(profileId, index, "screenshot name is invalid");
1351
+ await refreshSecrets();
1301
1352
  await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
1302
1353
  await ownerPage.screenshot({ path: path.join(evidenceDir, `${name}.png`), fullPage: step.fullPage !== false });
1303
1354
  break;
1304
1355
  }
1305
1356
  default: throw flowError(profileId, index, `unsupported action: ${step.action}`);
1306
1357
  }
1358
+ } catch (error) {
1359
+ requestScope?.stopCapture();
1360
+ requestScope?.dispose();
1361
+ throw error;
1362
+ }
1307
1363
  if (UI_READY_ACTIONS.has(step.action)) {
1308
- await waitForUiReady({
1309
- scope,
1310
- tracker: readinessFor(ownerPage),
1311
- timeout,
1312
- failure: () => flowError(profileId, index, "page did not finish loading"),
1313
- });
1364
+ try {
1365
+ await waitForUiReady({
1366
+ scope,
1367
+ tracker: readinessTracker,
1368
+ requestScope,
1369
+ timeout,
1370
+ failure: () => flowError(profileId, index, "page did not finish loading"),
1371
+ });
1372
+ } finally {
1373
+ requestScope?.stopCapture();
1374
+ requestScope?.dispose();
1375
+ }
1314
1376
  }
1315
1377
  if (unexpectedPopupError) throw unexpectedPopupError;
1378
+ await refreshSecrets();
1316
1379
  await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
1317
1380
  }
1318
1381
  } finally {
@@ -1323,15 +1386,36 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
1323
1386
 
1324
1387
  function createPageReadinessTracker(page) {
1325
1388
  const pendingRequests = new Set();
1326
- const onRequest = (request) => pendingRequests.add(request);
1327
- const onRequestDone = (request) => pendingRequests.delete(request);
1389
+ const actionScopes = new Set();
1390
+ const onRequest = (request) => {
1391
+ if (isLongLivedRequest(request)) return;
1392
+ pendingRequests.add(request);
1393
+ for (const scope of actionScopes) {
1394
+ if (scope.capturing) scope.pendingRequests.add(request);
1395
+ }
1396
+ };
1397
+ const onRequestDone = (request) => {
1398
+ pendingRequests.delete(request);
1399
+ for (const scope of actionScopes) scope.pendingRequests.delete(request);
1400
+ };
1328
1401
  page.on("request", onRequest);
1329
1402
  page.on("requestfinished", onRequestDone);
1330
1403
  page.on("requestfailed", onRequestDone);
1331
1404
  return {
1332
1405
  page,
1333
1406
  pendingRequests,
1407
+ beginActionScope() {
1408
+ const scope = {
1409
+ pendingRequests: new Set(),
1410
+ capturing: true,
1411
+ stopCapture() { scope.capturing = false; },
1412
+ dispose() { actionScopes.delete(scope); },
1413
+ };
1414
+ actionScopes.add(scope);
1415
+ return scope;
1416
+ },
1334
1417
  dispose() {
1418
+ actionScopes.clear();
1335
1419
  page.off("request", onRequest);
1336
1420
  page.off("requestfinished", onRequestDone);
1337
1421
  page.off("requestfailed", onRequestDone);
@@ -1339,8 +1423,18 @@ function createPageReadinessTracker(page) {
1339
1423
  };
1340
1424
  }
1341
1425
 
1342
- async function waitForUiReady({ scope, tracker, timeout, failure }) {
1426
+ function isLongLivedRequest(request) {
1427
+ try {
1428
+ return ["eventsource", "websocket"].includes(request.resourceType?.());
1429
+ } catch {
1430
+ return false;
1431
+ }
1432
+ }
1433
+
1434
+ async function waitForUiReady({ scope, tracker, requestScope = undefined, timeout, failure }) {
1343
1435
  const deadline = Date.now() + timeout;
1436
+ const pendingRequests = requestScope?.pendingRequests ?? tracker.pendingRequests;
1437
+ const requestDeadline = Date.now() + Math.min(ACTION_REQUEST_WAIT_MAX_MS, Math.max(0, timeout - UI_READY_SETTLE_MS));
1344
1438
  try {
1345
1439
  await scope.waitForLoadState("domcontentloaded", { timeout });
1346
1440
  } catch {
@@ -1350,10 +1444,12 @@ async function waitForUiReady({ scope, tracker, timeout, failure }) {
1350
1444
  const remaining = deadline - Date.now();
1351
1445
  if (remaining <= 0) throw failure();
1352
1446
  const hasBusyIndicator = await hasVisibleBusyIndicator(scope);
1353
- if (tracker.pendingRequests.size === 0 && !hasBusyIndicator) {
1447
+ const requestsStillBlock = pendingRequests.size > 0 && Date.now() < requestDeadline;
1448
+ if (!requestsStillBlock && !hasBusyIndicator) {
1354
1449
  await tracker.page.waitForTimeout(Math.min(UI_READY_SETTLE_MS, remaining));
1355
1450
  const stillBusy = await hasVisibleBusyIndicator(scope);
1356
- if (tracker.pendingRequests.size === 0 && !stillBusy) return;
1451
+ const requestsStillBlockAfterSettle = pendingRequests.size > 0 && Date.now() < requestDeadline;
1452
+ if (!requestsStillBlockAfterSettle && !stillBusy) return;
1357
1453
  continue;
1358
1454
  }
1359
1455
  await tracker.page.waitForTimeout(Math.min(UI_READY_POLL_MS, remaining));
@@ -1813,13 +1909,20 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
1813
1909
  const timeout = Math.min(finitePositive(auth.timeoutMs) ?? 15_000, 60_000);
1814
1910
  page.setDefaultTimeout(timeout);
1815
1911
  page.setDefaultNavigationTimeout(timeout);
1816
- await page.goto(auth.loginUrl, { timeout });
1817
- await waitForUiReady({
1818
- scope: page,
1819
- tracker: readinessTracker,
1820
- timeout,
1821
- failure: () => authError(profileId, "form page did not finish loading"),
1822
- });
1912
+ const loginRequestScope = readinessTracker.beginActionScope();
1913
+ try {
1914
+ await page.goto(auth.loginUrl, { timeout });
1915
+ await waitForUiReady({
1916
+ scope: page,
1917
+ tracker: readinessTracker,
1918
+ requestScope: loginRequestScope,
1919
+ timeout,
1920
+ failure: () => authError(profileId, "form page did not finish loading"),
1921
+ });
1922
+ } finally {
1923
+ loginRequestScope.stopCapture();
1924
+ loginRequestScope.dispose();
1925
+ }
1823
1926
  for (const field of auth.fields) {
1824
1927
  if (!isObject(field) || typeof field.selector !== "string" || typeof field.value !== "string") {
1825
1928
  throw authError(profileId, "form fields must contain selector/value strings");
@@ -1827,20 +1930,27 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
1827
1930
  await page.locator(field.selector).fill(field.value);
1828
1931
  await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
1829
1932
  }
1830
- await page.locator(auth.submitSelector).click({ delay: CLICK_VIDEO_DELAY_MS });
1831
- if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
1832
- if (isObject(auth.success) && typeof auth.success.selector === "string") {
1833
- await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
1834
- }
1835
- if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
1836
- throw authError(profileId, "form success.url or success.selector is required");
1933
+ const submitRequestScope = readinessTracker.beginActionScope();
1934
+ try {
1935
+ await page.locator(auth.submitSelector).click({ delay: CLICK_VIDEO_DELAY_MS });
1936
+ if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
1937
+ if (isObject(auth.success) && typeof auth.success.selector === "string") {
1938
+ await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
1939
+ }
1940
+ if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
1941
+ throw authError(profileId, "form success.url or success.selector is required");
1942
+ }
1943
+ await waitForUiReady({
1944
+ scope: page,
1945
+ tracker: readinessTracker,
1946
+ requestScope: submitRequestScope,
1947
+ timeout,
1948
+ failure: () => authError(profileId, "authenticated page did not finish loading"),
1949
+ });
1950
+ } finally {
1951
+ submitRequestScope.stopCapture();
1952
+ submitRequestScope.dispose();
1837
1953
  }
1838
- await waitForUiReady({
1839
- scope: page,
1840
- tracker: readinessTracker,
1841
- timeout,
1842
- failure: () => authError(profileId, "authenticated page did not finish loading"),
1843
- });
1844
1954
  } catch (error) {
1845
1955
  if (error instanceof QaStatusError) throw error;
1846
1956
  throw authError(profileId, "form login was rejected or did not reach the configured success condition");
@@ -1931,7 +2041,10 @@ function cookieAllowed(cookie, allowedOrigins) {
1931
2041
  if (typeof cookie.url === "string") return isAllowedUrl(cookie.url, allowedOrigins);
1932
2042
  if (typeof cookie.domain !== "string") return false;
1933
2043
  const domain = cookie.domain.replace(/^\./, "").toLowerCase();
1934
- return allowedOrigins.some((origin) => new URL(origin).hostname.toLowerCase() === domain);
2044
+ return allowedOrigins.some((origin) => {
2045
+ const host = new URL(origin).hostname.toLowerCase();
2046
+ return host === domain || (domain.includes(".") && host.endsWith(`.${domain}`));
2047
+ });
1935
2048
  }
1936
2049
 
1937
2050
  function loadPlaywright(cwd) {
@@ -1990,13 +2103,18 @@ function findPackageJson(startFile) {
1990
2103
  }
1991
2104
 
1992
2105
  function parseArgs(values) {
1993
- const result = {};
2106
+ const result = { allowOrigin: [] };
1994
2107
  for (let index = 0; index < values.length; index += 2) {
1995
2108
  const flag = values[index];
1996
2109
  const value = values[index + 1];
1997
2110
  if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
1998
2111
  const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1999
- if (!["profile", "flow", "baseUrl", "runId", "runnerTimeoutMs"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
2112
+ if (!["profile", "flow", "baseUrl", "allowOrigin", "runId", "runnerTimeoutMs"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
2113
+ if (key === "allowOrigin") {
2114
+ result.allowOrigin.push(value);
2115
+ continue;
2116
+ }
2117
+ if (result[key] !== undefined) throw new QaStatusError("QA_RUN_FAILED", `duplicate option: ${flag}`, 1);
2000
2118
  result[key] = value;
2001
2119
  }
2002
2120
  return result;
@@ -2251,6 +2369,23 @@ function collectStorageStateSecrets(state) {
2251
2369
  return expandSecretForms(output);
2252
2370
  }
2253
2371
 
2372
+ async function refreshRuntimeSecrets(context, runtimeSecrets) {
2373
+ if (!context || typeof context.storageState !== "function") return false;
2374
+ return mergeRuntimeSecrets(runtimeSecrets, collectStorageStateSecrets(await context.storageState()));
2375
+ }
2376
+
2377
+ function mergeRuntimeSecrets(runtimeSecrets, candidates) {
2378
+ const known = new Set(runtimeSecrets);
2379
+ let added = false;
2380
+ for (const candidate of candidates) {
2381
+ if (!candidate || known.has(candidate)) continue;
2382
+ known.add(candidate);
2383
+ runtimeSecrets.push(candidate);
2384
+ added = true;
2385
+ }
2386
+ return added;
2387
+ }
2388
+
2254
2389
  function expandSecretForms(values) {
2255
2390
  const forms = new Set();
2256
2391
  for (const value of values) {
@@ -2570,6 +2705,10 @@ function removeVisualEvidence(evidenceDir) {
2570
2705
  }
2571
2706
  }
2572
2707
 
2708
+ function discardRecordedVideos(videos) {
2709
+ for (const entry of videos) entry.discard = true;
2710
+ }
2711
+
2573
2712
  function writeJsonPrivate(file, value) {
2574
2713
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
2575
2714
  fs.chmodSync(file, 0o600);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -60,6 +60,8 @@
60
60
  "test": "mise exec node@24.16.0 -- npm run test:inner",
61
61
  "test:inner": "node --import tsx --test \"tests/**/*.test.ts\"",
62
62
  "test:tools-suite": "npm --prefix external/pi-tools-suite test",
63
+ "build:acp": "npm --prefix acp run build",
64
+ "check:acp": "npm --prefix acp run check",
63
65
  "test:browser-qa-e2e": "npm --prefix external/pi-tools-suite run test:browser-qa-e2e",
64
66
  "sync:pi-tools-suite": "node scripts/sync-pi-tools-suite.mjs",
65
67
  "sync:pi-tools-suite:check": "node scripts/sync-pi-tools-suite.mjs --check",
@@ -82,6 +84,7 @@
82
84
  "@earendil-works/pi-coding-agent": "0.84.4",
83
85
  "@earendil-works/pi-tui": "0.84.4",
84
86
  "@mariozechner/clipboard": "^0.3.9",
87
+ "grok-mermaid": "0.2.2",
85
88
  "jsonc-parser": "3.3.1",
86
89
  "typebox": "1.1.38",
87
90
  "vscode-jsonrpc": "^8.2.1",