pi-ui-extend 1.0.21 → 1.0.23

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.
@@ -7,11 +7,14 @@ import { renderConversationShellEntry } from "./conversation-shell-renderer.js";
7
7
  import { renderConversationToolEntry, renderThinkingEntry } from "./conversation-tool-renderer.js";
8
8
  export function renderConversationEntry(entry, width, options) {
9
9
  const { left: userContentLeft, contentWidth: userContentWidth } = horizontalPaddingLayout(width);
10
- const userLine = (text, entryId, syntaxHighlight, segments) => ({
10
+ const userLine = (text, entryId, syntaxHighlight, segments, links) => ({
11
11
  text: padHorizontalText(text, width),
12
12
  colorOverride: options.colors.userForeground,
13
13
  backgroundOverride: options.colors.userMessageBackground,
14
14
  ...(segments && segments.length > 0 ? { segments: segments.map((segment) => ({ ...segment, start: segment.start + userContentLeft, end: segment.end + userContentLeft })) } : {}),
15
+ ...(links && links.length > 0 ? {
16
+ links: links.map((link) => ({ ...link, start: link.start + userContentLeft, end: link.end + userContentLeft })),
17
+ } : {}),
15
18
  ...(syntaxHighlight === undefined ? {} : { syntaxHighlight }),
16
19
  ...(entryId === undefined ? {} : { target: { kind: "user-message", id: entryId } }),
17
20
  });
@@ -23,7 +26,10 @@ export function renderConversationEntry(entry, width, options) {
23
26
  });
24
27
  const userMessageLines = (userEntry) => {
25
28
  const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => ({
26
- ...userLine(line.text, userEntry.id, line.syntaxHighlight, line.segments),
29
+ ...userLine(line.text, userEntry.id, line.syntaxHighlight, [
30
+ ...(line.segments ?? []),
31
+ ...(line.links?.map((link) => ({ ...link, foreground: options.colors.info, underline: true })) ?? []),
32
+ ], line.links),
27
33
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
28
34
  ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
29
35
  }));
@@ -86,7 +92,9 @@ function renderAssistantLines(text, width, options) {
86
92
  ? { start: contentLeft, end: contentLeft + line.text.length, foreground: options.colors.assistantForeground, bold: true }
87
93
  : undefined;
88
94
  const existingSegments = line.segments?.map((segment) => ({ ...segment, start: segment.start + contentLeft, end: segment.end + contentLeft })) ?? [];
89
- const allSegments = headingSegment ? [headingSegment, ...existingSegments] : existingSegments;
95
+ const links = line.links?.map((link) => ({ ...link, start: link.start + contentLeft, end: link.end + contentLeft })) ?? [];
96
+ 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];
90
98
  lines.push({
91
99
  text: padHorizontalText(line.text, width),
92
100
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
@@ -94,6 +102,7 @@ function renderAssistantLines(text, width, options) {
94
102
  colorOverride: options.colors.assistantForeground,
95
103
  backgroundOverride: options.colors.assistantMessageBackground,
96
104
  ...(allSegments.length > 0 ? { segments: allSegments } : {}),
105
+ ...(links.length > 0 ? { links } : {}),
97
106
  ...(line.syntaxHighlight ? { syntaxHighlight: line.syntaxHighlight } : {}),
98
107
  });
99
108
  }
@@ -57,6 +57,7 @@ export class AppRenderController {
57
57
  this.deps.mouseController.syncConversationSelectionForRender(scrollMetrics.start, bodyHeight, topReservedRows, conversationColumns);
58
58
  this.deps.mouseController.renderedTargets.clear();
59
59
  this.deps.mouseController.renderedRowTexts.clear();
60
+ this.deps.mouseController.renderedLinks.clear();
60
61
  this.deps.mouseController.renderedRowBackgrounds.clear();
61
62
  this.deps.mouseController.renderedImageTargets.clear();
62
63
  this.deps.mouseController.statusModelTarget = undefined;
@@ -109,6 +110,8 @@ export class AppRenderController {
109
110
  this.deps.mouseController.renderedTargets.set(row, rendered.target);
110
111
  if (rendered?.imageTargets?.length)
111
112
  this.deps.mouseController.renderedImageTargets.set(row, rendered.imageTargets);
113
+ if (rendered?.links?.length)
114
+ this.deps.mouseController.renderedLinks.set(row, rendered.links);
112
115
  this.deps.mouseController.renderedRowTexts.set(row, rendered?.text ?? "");
113
116
  setRenderedBackground(row, rendered?.backgroundOverride);
114
117
  appendFrameOutput(row, this.renderFrameRow(row, this.deps.screenStyler.styleBaseLine(row, rendered, conversationColumns)));
@@ -128,6 +131,8 @@ export class AppRenderController {
128
131
  this.deps.mouseController.renderedTargets.set(row, rendered.line.target);
129
132
  if (rendered.line?.imageTargets?.length)
130
133
  this.deps.mouseController.renderedImageTargets.set(row, rendered.line.imageTargets);
134
+ if (rendered.line?.links?.length)
135
+ this.deps.mouseController.renderedLinks.set(row, rendered.line.links);
131
136
  this.deps.mouseController.renderedRowTexts.set(row, rendered.text);
132
137
  setRenderedBackground(row, rendered.line?.backgroundOverride);
133
138
  appendFrameOutput(row, this.renderFrameRow(row, rendered.output(row)));
@@ -182,6 +187,8 @@ export class AppRenderController {
182
187
  this.deps.mouseController.renderedTargets.set(row, rendered.line.target);
183
188
  if (rendered.line?.imageTargets?.length)
184
189
  this.deps.mouseController.renderedImageTargets.set(row, rendered.line.imageTargets);
190
+ if (rendered.line?.links?.length)
191
+ this.deps.mouseController.renderedLinks.set(row, rendered.line.links);
185
192
  this.deps.mouseController.renderedRowTexts.set(row, rendered.text);
186
193
  setRenderedBackground(row, rendered.line?.backgroundOverride);
187
194
  appendFrameOutput(row, this.renderFrameRow(row, rendered.output(row)));
@@ -97,6 +97,7 @@ export declare class AppMouseController {
97
97
  id: string;
98
98
  } | import("../types.js").ToastLineTarget | undefined>;
99
99
  readonly renderedRowTexts: Map<number, string>;
100
+ readonly renderedLinks: Map<number, readonly RenderedLink[]>;
100
101
  readonly renderedRowBackgrounds: Map<number, string>;
101
102
  readonly renderedImageTargets: Map<number, readonly ImageClickTarget[]>;
102
103
  statusModelTarget: StatusModelTarget | undefined;
@@ -18,6 +18,7 @@ export class AppMouseController {
18
18
  commandController;
19
19
  renderedTargets = new Map();
20
20
  renderedRowTexts = new Map();
21
+ renderedLinks = new Map();
21
22
  renderedRowBackgrounds = new Map();
22
23
  renderedImageTargets = new Map();
23
24
  statusModelTarget;
@@ -308,7 +309,7 @@ export class AppMouseController {
308
309
  const text = this.renderedRowTexts.get(event.y);
309
310
  if (!text)
310
311
  return undefined;
311
- for (const link of detectFileLinks(text, this.host.cwd())) {
312
+ for (const link of [...(this.renderedLinks.get(event.y) ?? []), ...detectFileLinks(text, this.host.cwd())]) {
312
313
  const startColumn = stringDisplayWidth(text.slice(0, link.start)) + 1;
313
314
  const endColumn = startColumn + stringDisplayWidth(text.slice(link.start, link.end));
314
315
  if (event.x >= startColumn && event.x < endColumn)
@@ -8,10 +8,16 @@ export type RenderedMarkdownLine = {
8
8
  end: number;
9
9
  bold: true;
10
10
  }[];
11
+ links?: readonly RenderedMarkdownLink[];
11
12
  heading?: boolean;
12
13
  sourceStart?: number;
13
14
  sourceEnd?: number;
14
15
  };
16
+ export type RenderedMarkdownLink = {
17
+ start: number;
18
+ end: number;
19
+ url: string;
20
+ };
15
21
  export type RenderedMarkdownTextLine = {
16
22
  text: string;
17
23
  copyText?: string;
@@ -21,6 +27,7 @@ export type RenderedMarkdownTextLine = {
21
27
  end: number;
22
28
  bold: true;
23
29
  }[] | undefined;
30
+ links?: readonly RenderedMarkdownLink[] | undefined;
24
31
  syntaxHighlight?: SyntaxLineHighlight | undefined;
25
32
  heading?: boolean;
26
33
  };
@@ -44,6 +44,7 @@ export function formatMarkdownTables(text, maxWidth) {
44
44
  export function renderMarkdownLine(text, start = 0) {
45
45
  const safeStart = Math.max(0, Math.min(text.length, start));
46
46
  const segments = [];
47
+ const links = [];
47
48
  let rendered = text.slice(0, safeStart);
48
49
  let index = safeStart;
49
50
  let inCode = false;
@@ -66,10 +67,20 @@ export function renderMarkdownLine(text, start = 0) {
66
67
  continue;
67
68
  }
68
69
  }
70
+ if (!inCode && char === "[" && text[index - 1] !== "!" && !isEscaped(text, index)) {
71
+ const link = markdownLinkAt(text, index);
72
+ if (link) {
73
+ const linkStart = rendered.length;
74
+ rendered += link.label;
75
+ links.push({ start: linkStart, end: rendered.length, url: link.url });
76
+ index = link.end;
77
+ continue;
78
+ }
79
+ }
69
80
  rendered += char;
70
81
  index += 1;
71
82
  }
72
- return { text: rendered, segments, ...(isHeading ? { heading: true } : {}) };
83
+ return { text: rendered, segments, ...(links.length > 0 ? { links } : {}), ...(isHeading ? { heading: true } : {}) };
73
84
  }
74
85
  export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
75
86
  const lines = [];
@@ -84,7 +95,7 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
84
95
  const syntaxHighlight = markdownLineSyntaxHighlight(fence, Boolean(opensFence || closesFence), start);
85
96
  const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
86
97
  const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
87
- const logicalLine = markdownLine ?? { text: rawLine, segments: [] };
98
+ const logicalLine = markdownLine ?? { text: rawLine, segments: [], links: [] };
88
99
  for (const wrapped of wrapRenderedMarkdownLine(logicalLine, width, options)) {
89
100
  const wrappedSyntaxHighlight = syntaxHighlight && wrapped.sourceStart !== undefined && wrapped.sourceEnd !== undefined
90
101
  ? {
@@ -103,6 +114,7 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
103
114
  ...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
104
115
  ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
105
116
  ...(wrapped.segments.length > 0 ? { segments: wrapped.segments } : {}),
117
+ ...(wrapped.links && wrapped.links.length > 0 ? { links: wrapped.links } : {}),
106
118
  ...(wrappedSyntaxHighlight ? { syntaxHighlight: wrappedSyntaxHighlight } : {}),
107
119
  ...(isHeadingLine ? { heading: true } : {}),
108
120
  });
@@ -141,11 +153,15 @@ function wrapRenderedMarkdownLine(line, width, options) {
141
153
  if (stringDisplayWidth(line.text) <= safeWidth)
142
154
  return [line];
143
155
  const ranges = wrapDisplayLineByWordsWithRanges(line.text, safeWidth, options);
156
+ const links = line.links ?? [];
144
157
  return ranges.map((range, index) => ({
145
158
  text: range.text,
146
159
  copyText: line.text.slice(range.start, ranges[index + 1]?.start ?? range.end),
147
160
  ...(index < ranges.length - 1 ? { continuesOnNextLine: true } : {}),
148
161
  segments: line.segments.flatMap((segment) => shiftSegmentToRange(segment, range.start, range.end)),
162
+ ...(links.length > 0
163
+ ? { links: links.flatMap((link) => shiftLinkToRange(link, range.start, range.end)) }
164
+ : {}),
149
165
  sourceStart: range.start,
150
166
  sourceEnd: range.end,
151
167
  }));
@@ -320,6 +336,47 @@ function shiftSegmentToRange(segment, rangeStart, rangeEnd) {
320
336
  return [];
321
337
  return [{ ...segment, start: start - rangeStart, end: end - rangeStart }];
322
338
  }
339
+ function shiftLinkToRange(link, rangeStart, rangeEnd) {
340
+ const start = Math.max(link.start, rangeStart);
341
+ const end = Math.min(link.end, rangeEnd);
342
+ if (end <= start)
343
+ return [];
344
+ return [{ ...link, start: start - rangeStart, end: end - rangeStart }];
345
+ }
346
+ function markdownLinkAt(text, start) {
347
+ const labelEnd = findUnescapedCharacter(text, "]", start + 1);
348
+ if (labelEnd <= start + 1 || text[labelEnd + 1] !== "(")
349
+ return undefined;
350
+ const destinationStart = labelEnd + 2;
351
+ let nestedParentheses = 0;
352
+ for (let index = destinationStart; index < text.length; index += 1) {
353
+ if (isEscaped(text, index))
354
+ continue;
355
+ const char = text[index] ?? "";
356
+ if (char === "(") {
357
+ nestedParentheses += 1;
358
+ continue;
359
+ }
360
+ if (char !== ")")
361
+ continue;
362
+ if (nestedParentheses > 0) {
363
+ nestedParentheses -= 1;
364
+ continue;
365
+ }
366
+ const url = text.slice(destinationStart, index);
367
+ if (!/^(?:file|https?):\/\/\S+$/u.test(url))
368
+ return undefined;
369
+ return { label: text.slice(start + 1, labelEnd), url, end: index + 1 };
370
+ }
371
+ return undefined;
372
+ }
373
+ function findUnescapedCharacter(text, character, start) {
374
+ for (let index = start; index < text.length; index += 1) {
375
+ if (text[index] === character && !isEscaped(text, index))
376
+ return index;
377
+ }
378
+ return -1;
379
+ }
323
380
  function parseMarkdownTableBlock(lines, start) {
324
381
  const header = parseMarkdownTableRow(lines[start] ?? "");
325
382
  if (!header)
@@ -247,23 +247,28 @@ For an oh-my-openagent-style workflow, run `/ultrawork` or `/ulw` to ask the par
247
247
 
248
248
  ### Private browser QA and project auth
249
249
 
250
- The built-in `browser-qa` role runs on `antigravity/gemini-3-flash-preview`, with
251
- `openai-codex/gpt-5.4-mini` as its fallback. Its browser workflow is an explicit
252
- private skill under `src/async-subagents/private-skills/`, outside normal Pi skill
253
- discovery. The role's first-class `isolatedSkills` setting launches the child with
250
+ The built-in `browser-qa` role runs on `openai-codex/gpt-5.4-mini`, with
251
+ `antigravity/gemini-3-flash-preview` and then `zai/glm-5.3` as fallbacks. Its browser
252
+ workflow is an explicit private skill under `src/async-subagents/private-skills/`,
253
+ outside normal Pi skill discovery. The role's first-class `isolatedSkills` setting launches the child with
254
254
  `--no-skills` plus one self-contained private workflow. It bundles the relevant
255
255
  scenario-design, locator, waiting, assertion, evidence, and cleanup guidance next
256
256
  to its trusted runner, so browser QA does not depend on a separately installed
257
257
  skill or CLI. The private workflow remains mandatory when configuration appends
258
258
  other isolated skills; parent and ordinary sub-agent sessions do not discover it.
259
259
 
260
- Keep named dev/staging auth profiles in project `.pi/qa_auth.jsonc` (there is no
261
- `/qa-auth` command). The private runner supports `form`, `cookie`, `localStorage`,
260
+ Public browser QA does not require an auth profile or `.pi/qa_auth.jsonc`: run it
261
+ with an explicit base URL, whose exact origin becomes the fail-closed allowlist.
262
+ The runner neither creates nor requests a credential file for that path.
263
+
264
+ For targets that actually require login, keep named dev/staging auth profiles in
265
+ project `.pi/qa_auth.jsonc` (there is no `/qa-auth` command). The private runner
266
+ supports `form`, `cookie`, `localStorage`,
262
267
  `sessionStorage`, `bearer`, and existing Playwright `storageState` auth. Every
263
- profile must declare exact `allowedOrigins`; select the profile id explicitly when
264
- spawning QA. On POSIX, keep the config at mode `0600`. During a run those origins
265
- also form the fail-closed HTTP(S)/WebSocket allowlist and service workers are
266
- blocked. Example:
268
+ profile must declare exact `allowedOrigins`; select the profile id explicitly only
269
+ for authenticated QA. On POSIX, keep the config at mode `0600`. During a run
270
+ those origins also form the fail-closed HTTP(S)/WebSocket allowlist and service
271
+ workers are blocked. Example:
267
272
 
268
273
  ```jsonc
269
274
  {
@@ -288,10 +293,13 @@ blocked. Example:
288
293
  }
289
294
  ```
290
295
 
291
- Do not place credential values in prompts, QA flows, shell arguments, reports,
292
- or evidence. The helper reads JSONC internally, emits only redacted statuses, and
293
- caches generated storage state under `.pi/qa-auth-state`. The launcher provides
294
- each browser QA process with its own
296
+ Do not place credential values in prompts, QA flows, shell arguments, or reports.
297
+ The helper reads JSONC internally and emits only redacted statuses. For form auth,
298
+ video recording begins on the login page and captures the field-filling and submit
299
+ sequence; password inputs remain browser-masked, but the private video may show
300
+ other visible login identifiers and must be treated as sensitive evidence. Tracing
301
+ starts only after login succeeds and is sanitized before retention. The launcher
302
+ provides each browser QA process with its own
295
303
  `.pi/subagents/<run>/<agent-id>/browser-qa/` workspace. Declarative flows,
296
304
  screenshots, video, sanitized traces, and result manifests stay there, so normal
297
305
  session shutdown or `subagents cleanup` removes them with the run directory.
@@ -299,7 +307,9 @@ The runner validates the owning agent metadata and refuses flows outside that
299
307
  workspace; reusing an agent id clears stale browser QA files first. Trace archives
300
308
  have network records and non-image resources removed, then known
301
309
  configured/runtime credential values are redacted and verified before retention.
302
- Missing, ambiguous, rejected, or expired auth returns `QA_PROFILE_REQUIRED` or
310
+ Listing profiles when the auth file is absent returns an empty list without
311
+ creating a template. Only an explicit authenticated request may create the
312
+ private template. Missing, rejected, or expired selected auth returns
303
313
  `QA_AUTH_UPDATE_REQUIRED`, naming only the profile/file/reason needed for the
304
314
  parent to ask the user for an update and rerun. See
305
315
  `src/async-subagents/private-skills/browser-qa/references/qa-auth.example.jsonc`
@@ -4,8 +4,8 @@
4
4
 
5
5
  Provide a cheap, fast `browser-qa` async-subagent that reproduces browser bugs
6
6
  and proves fixes with deterministic assertions plus screenshot, video, and trace
7
- evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
8
- `openai-codex/gpt-5.4-mini`.
7
+ evidence. The role uses `openai-codex/gpt-5.4-mini`, falling back to
8
+ `antigravity/gemini-3-flash-preview` and then `zai/glm-5.3`.
9
9
 
10
10
  ## Private skill isolation
11
11
 
@@ -26,11 +26,16 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
26
26
 
27
27
  ## Authentication contract
28
28
 
29
+ - Public browser QA requires no auth profile and does not create or require
30
+ `.pi/qa_auth.jsonc`. Its explicit base URL supplies the one exact allowed
31
+ origin, and the runner still blocks every other HTTP(S)/WebSocket origin.
29
32
  - Auth profiles live in project-local `.pi/qa_auth.jsonc` and are selected by
30
33
  explicit id. The file must be a real project-local file with mode `0600` on
31
34
  POSIX. Profile listings expose only `id`, description, and traits.
32
- - When authenticated QA first needs credentials and that file is absent, the
33
- runner creates a private empty template and returns `provide_credentials`.
35
+ - Listing profiles when the file is absent returns an empty list without side
36
+ effects. When authenticated QA explicitly requests credentials and that file
37
+ is absent, the runner creates a private empty template and returns
38
+ `provide_credentials`.
34
39
  The sub-agent must explicitly ask the user to fill the reported file and
35
40
  rerun QA; it must not read or edit the credential values itself.
36
41
  - Every profile requires one or more exact `allowedOrigins`. Secret-bearing auth
@@ -46,8 +51,8 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
46
51
  `.pi/subagents/<run>/<agent-id>/browser-qa/` workspace. Multiple profiles use
47
52
  separate browser contexts/evidence directories, and normal sub-agent shutdown
48
53
  or cleanup removes the whole workspace with its run.
49
- - Missing, ambiguous, rejected, or expired auth returns a machine-readable
50
- update-required/profile-required status naming only the profile id, config
54
+ - Missing, rejected, or expired explicitly selected auth returns a
55
+ machine-readable update-required status naming only the profile id, config
51
56
  file, and redacted reason. The parent asks the user to update the file and
52
57
  reruns; there is no `/qa-auth` command.
53
58
 
@@ -83,10 +88,10 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
83
88
  profile; ordinary profiles retain existing skill discovery behavior.
84
89
  3. Auth profile listing and all error output are redacted; model-authored input
85
90
  cannot execute code in the credential-bearing process.
86
- 4. Runner tests cover explicit profile selection, all auth modes, fail-closed
87
- origins, path/mode hardening, private empty-template creation, explicit
88
- credential requests, non-executable flows, and successful redacted evidence
89
- creation.
91
+ 4. Runner tests cover public execution without an auth file, explicit profile
92
+ selection, all auth modes, fail-closed origins, path/mode hardening, private
93
+ empty-template creation only on an explicit auth request, non-executable
94
+ flows, and successful redacted evidence creation.
90
95
  5. Browser QA flows/evidence live only inside the owning sub-agent directory;
91
96
  deleting the run removes them while persistent auth config/state remains.
92
97
  6. Completed test runs report clickable screenshot, video, and trace links
@@ -116,7 +116,7 @@
116
116
  "research": { "model": "zai/glm-5-turbo", "thinking": "low" },
117
117
  "docs": { "model": "zai/glm-4.5-air", "thinking": "low" },
118
118
  "frontend": { "model": "antigravity/gemini-3-flash-preview", "fallbackModels": ["zai/glm-5.3"], "thinking": "medium" },
119
- "browser-qa": { "model": "antigravity/gemini-3-flash-preview", "fallbackModels": ["openai-codex/gpt-5.4-mini"], "thinking": "medium" },
119
+ "browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
120
120
  "tests": { "model": "zai/glm-5-turbo", "thinking": "medium" },
121
121
  "review": { "model": "zai/glm-5.3", "thinking": "high" },
122
122
  "implement": { "model": "zai/glm-5.3", "thinking": "high" },
@@ -132,7 +132,7 @@
132
132
  "research": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["zai/glm-5-turbo"], "thinking": "low" },
133
133
  "docs": { "model": "openai-codex/gpt-5.6-luna", "fallbackModels": ["zai/glm-4.5-air"], "thinking": "low" },
134
134
  "frontend": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
135
- "browser-qa": { "model": "antigravity/gemini-3-flash-preview", "fallbackModels": ["openai-codex/gpt-5.4-mini"], "thinking": "medium" },
135
+ "browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
136
136
  "tests": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["zai/glm-5-turbo"], "thinking": "medium" },
137
137
  "review": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
138
138
  "implement": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
@@ -148,7 +148,7 @@
148
148
  "research": { "model": "antigravity/gemini-3.1-pro-preview", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5-turbo"], "thinking": "medium" },
149
149
  "docs": { "model": "antigravity/gemini-2.5-flash", "fallbackModels": ["openai-codex/gpt-5.3-codex-spark", "zai/glm-4.5-air"], "thinking": "medium" },
150
150
  "frontend": { "model": "antigravity/gemini-3.1-pro-preview-customtools", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5.3"], "thinking": "low" },
151
- "browser-qa": { "model": "antigravity/gemini-3-flash-preview", "fallbackModels": ["openai-codex/gpt-5.4-mini"], "thinking": "medium" },
151
+ "browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
152
152
  "tests": { "model": "antigravity/antigravity-claude-sonnet-4-6", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5-turbo"], "thinking": "high" },
153
153
  "review": { "model": "antigravity/antigravity-claude-sonnet-4-6", "fallbackModels": ["openai-codex/gpt-5.6-sol", "zai/glm-5.3"], "thinking": "high" },
154
154
  "implement": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
@@ -199,8 +199,8 @@
199
199
 
200
200
  "browser-qa": {
201
201
  "description": "Use for browser-based visual QA: reproduce UI bugs and verify fixes with deterministic assertions, screenshots, video, and traces.",
202
- "model": "antigravity/gemini-3-flash-preview",
203
- "fallbackModels": ["openai-codex/gpt-5.4-mini"],
202
+ "model": "openai-codex/gpt-5.4-mini",
203
+ "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"],
204
204
  "thinking": "medium",
205
205
  "tools": ["read", "grep", "bash"]
206
206
  },
@@ -207,8 +207,8 @@ const BUILTIN_CONFIG: SubagentConfig = {
207
207
  },
208
208
  "browser-qa": {
209
209
  description: "Use for browser-based visual QA: reproduce UI bugs and verify fixes with deterministic assertions, screenshots, video, and traces.",
210
- model: "antigravity/gemini-3-flash-preview",
211
- fallbackModels: ["openai-codex/gpt-5.4-mini"],
210
+ model: "openai-codex/gpt-5.4-mini",
211
+ fallbackModels: ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"],
212
212
  thinking: "medium",
213
213
  tools: ["read", "grep", "bash"],
214
214
  isolatedSkills: [getBrowserQaSkillPath()],
@@ -23,14 +23,19 @@ Never read, print, grep, copy, or edit credential values from
23
23
  3. Discover the requested target, expected behavior, and the smallest scenario
24
24
  that can prove it. If the target cannot be reached or started, report the
25
25
  concrete blocker instead of substituting static checks for browser QA.
26
- 4. Run `node <runner> profiles`. Choose a profile only when the task names its
27
- id or safe profile traits make the choice unambiguous. Otherwise stop with
28
- `QA_PROFILE_REQUIRED` and list only ids, descriptions, and traits.
26
+ 4. If the requested behavior requires authentication, run
27
+ `node <runner> profiles`. A missing auth config is valid and returns an empty
28
+ list without creating `.pi/qa_auth.jsonc`. Otherwise skip profile discovery
29
+ and use public mode. Choose an auth profile only when the task names its id,
30
+ safe profile traits make the choice unambiguous, or a public run proves that
31
+ the requested page requires login.
29
32
  5. Inspect the target code and write a declarative JSONC flow under
30
33
  `$PI_SUBAGENT_AGENT_DIR/browser-qa/flows/`. Never put credentials or
31
34
  executable JavaScript in it.
32
- 6. Run:
33
- `node <runner> run --profile <id> --base-url <url> --flow <flow.jsonc>`.
35
+ 6. Run public QA with
36
+ `node <runner> run --base-url <url> --flow <flow.jsonc>`. The URL's exact
37
+ origin becomes the fail-closed allowlist. Only for authenticated QA, add
38
+ `--profile <id>`; the selected profile then owns the URL and allowlist.
34
39
  Profile id, URL, and flow path are non-secret; never pass credentials as
35
40
  arguments or environment variables.
36
41
  7. Report deterministic assertions and every artifact returned by the runner.
@@ -101,17 +106,26 @@ isolated browser context and exclusive evidence directory; the runner closes
101
106
  all owned browser resources on success and failure. Flows, screenshots, video,
102
107
  sanitized traces, and runner result manifests remain under
103
108
  `$PI_SUBAGENT_AGENT_DIR/browser-qa/` so normal sub-agent shutdown or cleanup
104
- deletes them with the run directory. `.pi/qa_auth.jsonc` and reusable
105
- `.pi/qa-auth-state/` remain project-local and persistent.
109
+ deletes them with the run directory. For form auth, recording starts on the login
110
+ page and includes field filling and submission; password inputs remain masked,
111
+ but the private video may show visible login identifiers. Tracing starts only
112
+ after login succeeds so credentials are not captured in the trace.
106
113
 
107
114
  ## Credentials and blocked runs
108
115
 
109
- If the runner returns `QA_AUTH_UPDATE_REQUIRED`, stop and explicitly report that
110
- browser QA requires credentials or an auth-config update. Ask the user to fill
111
- the reported file and rerun QA. If `templateCreated` is true, say that a private
112
- empty template was created at that path. Relay only the runner's profile, file,
113
- reason, action, and template-created state; never read the generated file or
114
- attempt to recover by exposing or replaying credentials.
116
+ Do not request credentials merely because `.pi/qa_auth.jsonc` is absent. If the
117
+ task explicitly requires authenticated behavior, or a public run reaches the
118
+ flow's `authRejectedIf` check, and `profiles` returned no usable profile, run
119
+ `node <runner> profiles --require-auth`. Only this explicit authenticated path
120
+ may create the private empty template.
121
+
122
+ If that command or an authenticated run returns `QA_AUTH_UPDATE_REQUIRED`, stop
123
+ and explicitly report that authenticated browser QA requires credentials or an
124
+ auth-config update. Ask the user to fill the reported file and rerun QA. If
125
+ `templateCreated` is true, say that a private empty template was created at that
126
+ path. Relay only the runner's profile, file, reason, action, and template-created
127
+ state; never read the generated file or attempt to recover by exposing or
128
+ replaying credentials.
115
129
 
116
130
  For any other blocked run, report the runner status and redacted reason. Do not
117
131
  claim that browser QA passed based on source inspection, unit tests, or a build.
@@ -55,13 +55,16 @@ stale credentials into an explicit update request instead of misreporting a
55
55
  product regression.
56
56
 
57
57
  Do not encode credentials, tokens, storage values, or login form secrets in the
58
- flow. The trusted runner applies the selected profile internally and removes
59
- secret-bearing evidence if it detects disclosure.
58
+ flow. The trusted runner applies the selected profile internally. For form auth,
59
+ video starts on the login page and includes field filling and submission; password
60
+ inputs remain masked, but visible identifiers can appear, so treat the video as
61
+ sensitive private evidence. Tracing starts only after login succeeds and is
62
+ sanitized before retention.
60
63
 
61
64
  ## Evidence strategy
62
65
 
63
- The runner always attempts a final or failure screenshot, records video, and
64
- creates a sanitized trace once the browser launches. Add named `screenshot`
66
+ The runner always attempts a final or failure screenshot, records video from the
67
+ first page, and creates a sanitized post-auth trace. Add named `screenshot`
65
68
  steps only at states that materially help explain the result—for example before
66
69
  and after a destructive interaction, or when a transient success message is
67
70
  the oracle.
@@ -102,5 +105,5 @@ result manifest inside `$PI_SUBAGENT_AGENT_DIR/browser-qa/`. The launcher owns
102
105
  that path and the runner validates it before opening a browser. Do not override
103
106
  the environment path or copy evidence into shared `.pi/qa-runs`/`.pi/qa-flows`
104
107
  directories: the agent-local workspace is intentionally removed by the normal
105
- sub-agent shutdown and cleanup lifecycle. Authentication config and reusable
106
- auth state are separate persistent inputs and stay under project `.pi/`.
108
+ sub-agent shutdown and cleanup lifecycle. Authentication config remains a
109
+ separate persistent input under project `.pi/`.
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createHash } from "node:crypto";
4
3
  import fs from "node:fs";
5
4
  import path from "node:path";
6
5
  import { createRequire } from "node:module";
@@ -9,12 +8,11 @@ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
9
8
  import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
10
9
 
11
10
  const CONFIG_RELATIVE = ".pi/qa_auth.jsonc";
12
- const STATE_RELATIVE = path.join(".pi", "qa-auth-state");
13
11
  const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
14
12
  const QA_WORKSPACE_RELATIVE = "browser-qa";
15
13
  const EVIDENCE_RELATIVE = "evidence";
14
+ const FORM_VIDEO_STEP_DELAY_MS = 250;
16
15
  const EXIT_AUTH_UPDATE_REQUIRED = 42;
17
- const EXIT_PROFILE_REQUIRED = 43;
18
16
  const PROFILE_ID = /^[A-Za-z0-9._-]+$/;
19
17
  const AUTH_TEMPLATE = `{
20
18
  // Authenticated browser QA requires at least one project-local profile.
@@ -59,7 +57,7 @@ main().catch((error) => {
59
57
  writeStatus({
60
58
  status: statusError.status,
61
59
  profile: statusError.profileId,
62
- file: CONFIG_RELATIVE,
60
+ ...(statusError.status === "QA_AUTH_UPDATE_REQUIRED" ? { file: CONFIG_RELATIVE } : {}),
63
61
  reason: statusError.reason,
64
62
  ...statusError.details,
65
63
  });
@@ -69,15 +67,21 @@ main().catch((error) => {
69
67
  async function main() {
70
68
  const [command = "profiles", ...rawArgs] = process.argv.slice(2);
71
69
  const cwd = fs.realpathSync(process.cwd());
72
- const config = readAuthConfig(cwd);
73
70
  if (command === "profiles") {
74
- writeStatus({ status: "QA_PROFILES", profiles: safeProfiles(config.profiles) });
71
+ const requireAuth = rawArgs.length === 1 && rawArgs[0] === "--require-auth";
72
+ if (rawArgs.length > 0 && !requireAuth) {
73
+ throw new QaStatusError("QA_RUN_FAILED", `invalid profiles argument: ${rawArgs[0]}`, 1);
74
+ }
75
+ const config = readAuthConfig(cwd, requireAuth);
76
+ writeStatus({ status: "QA_PROFILES", authConfigPresent: config.present, profiles: safeProfiles(config.profiles) });
75
77
  return;
76
78
  }
77
79
  if (command !== "run") throw new QaStatusError("QA_RUN_FAILED", `unknown command: ${command}`, 1);
78
80
 
79
81
  const args = parseArgs(rawArgs);
80
- const selected = selectProfile(config.profiles, args.profile);
82
+ const selected = args.profile
83
+ ? selectProfile(readAuthConfig(cwd, true).profiles, args.profile)
84
+ : createPublicProfile(args.baseUrl);
81
85
  const profileId = selected.id;
82
86
  const profile = selected.profile;
83
87
  const secrets = collectSecrets(profile.auth);
@@ -112,7 +116,7 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
112
116
  const runtimeSecrets = collectSecrets(profile.auth);
113
117
  const tracePath = path.join(evidenceDir, "trace.zip");
114
118
  try {
115
- const contextOptions = await contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins, browser });
119
+ const contextOptions = await contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins });
116
120
  context = await browser.newContext({
117
121
  ...contextOptions,
118
122
  baseURL,
@@ -122,10 +126,11 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
122
126
  });
123
127
  await installOriginGuard(context, allowedOrigins, profile.auth);
124
128
  await applyContextAuth(context, profile.auth, allowedOrigins, baseURL, profileId);
125
- runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
126
- await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
127
129
  page = await context.newPage();
128
130
  video = page.video();
131
+ await applyFormAuth(page, profile.auth, allowedOrigins, profileId);
132
+ runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
133
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
129
134
  await executeFlow({ page, context, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets });
130
135
  await assertPageDoesNotExposeSecrets(page, runtimeSecrets, profileId);
131
136
  await page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true });
@@ -192,9 +197,10 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
192
197
  writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
193
198
  }
194
199
 
195
- function readAuthConfig(cwd) {
200
+ function readAuthConfig(cwd, required = false) {
196
201
  const candidate = path.join(cwd, CONFIG_RELATIVE);
197
202
  if (!fs.existsSync(candidate)) {
203
+ if (!required) return { present: false, profiles: {} };
198
204
  let templateCreated;
199
205
  try {
200
206
  templateCreated = createAuthTemplate(cwd);
@@ -222,7 +228,10 @@ function readAuthConfig(cwd) {
222
228
  if (errors.length > 0) {
223
229
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", `auth config is invalid JSONC (${printParseErrorCode(errors[0].error)})`, EXIT_AUTH_UPDATE_REQUIRED);
224
230
  }
225
- if (!isObject(value) || !isObject(value.profiles) || Object.keys(value.profiles).length === 0) {
231
+ if (!isObject(value) || !isObject(value.profiles)) {
232
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth config must define a profiles object", EXIT_AUTH_UPDATE_REQUIRED);
233
+ }
234
+ if (required && Object.keys(value.profiles).length === 0) {
226
235
  throw new QaStatusError(
227
236
  "QA_AUTH_UPDATE_REQUIRED",
228
237
  "credentials are required; auth config must define a non-empty profiles object",
@@ -234,7 +243,7 @@ function readAuthConfig(cwd) {
234
243
  if (Object.keys(value.profiles).some((id) => !isSafeName(id))) {
235
244
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth profile ids may contain only letters, digits, dot, underscore, or dash", EXIT_AUTH_UPDATE_REQUIRED);
236
245
  }
237
- return value;
246
+ return { ...value, present: true };
238
247
  }
239
248
 
240
249
  function safeProfiles(profiles) {
@@ -248,15 +257,6 @@ function safeProfiles(profiles) {
248
257
  }
249
258
 
250
259
  function selectProfile(profiles, requestedId) {
251
- if (!requestedId) {
252
- throw new QaStatusError(
253
- "QA_PROFILE_REQUIRED",
254
- "select one auth profile by id",
255
- EXIT_PROFILE_REQUIRED,
256
- undefined,
257
- { profiles: safeProfiles(profiles) },
258
- );
259
- }
260
260
  if (!isSafeName(requestedId) || !isObject(profiles[requestedId])) {
261
261
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "requested auth profile is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
262
262
  }
@@ -267,6 +267,26 @@ function selectProfile(profiles, requestedId) {
267
267
  return { id: requestedId, profile };
268
268
  }
269
269
 
270
+ function createPublicProfile(rawBaseUrl) {
271
+ if (typeof rawBaseUrl !== "string") {
272
+ throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
273
+ }
274
+ try {
275
+ const url = new URL(rawBaseUrl);
276
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password) throw new Error();
277
+ return {
278
+ id: "public",
279
+ profile: {
280
+ baseUrl: url.href,
281
+ allowedOrigins: [url.origin],
282
+ auth: { type: "none" },
283
+ },
284
+ };
285
+ } catch {
286
+ throw new QaStatusError("QA_RUN_FAILED", "public --base-url must be an http(s) URL without embedded credentials", 1, "public");
287
+ }
288
+ }
289
+
270
290
  function normalizeAllowedOrigins(value, profileId) {
271
291
  if (!Array.isArray(value) || value.length === 0) {
272
292
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile must define allowedOrigins", EXIT_AUTH_UPDATE_REQUIRED, profileId);
@@ -296,6 +316,8 @@ function normalizeBaseUrl(raw, allowedOrigins, profileId) {
296
316
 
297
317
  function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
298
318
  switch (auth.type) {
319
+ case "none":
320
+ break;
299
321
  case "cookie":
300
322
  if (!Array.isArray(auth.cookies) || auth.cookies.length === 0) throw authError(profileId, "cookie auth requires cookies");
301
323
  for (const cookie of auth.cookies) {
@@ -465,24 +487,16 @@ function isStringArray(value) {
465
487
  return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string");
466
488
  }
467
489
 
468
- async function contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins, browser }) {
490
+ async function contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins }) {
469
491
  const auth = profile.auth;
492
+ if (auth.type === "none") return {};
470
493
  if (auth.type === "storageState") {
471
494
  if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
472
495
  const statePath = resolveExistingPrivateFile(cwd, auth.path, "storageState");
473
496
  return { storageState: filteredStorageState(statePath, allowedOrigins, profileId) };
474
497
  }
475
498
  if (auth.type === "form") {
476
- const stateDirectory = path.join(cwd, STATE_RELATIVE);
477
- const statePath = path.join(stateDirectory, `${profileId}-${authCacheKey(auth, allowedOrigins)}.json`);
478
- if (!fs.existsSync(statePath)) {
479
- ensurePrivateDirectory(cwd, stateDirectory);
480
- for (const name of fs.readdirSync(stateDirectory)) {
481
- if (name.startsWith(`${profileId}-`) && name.endsWith(".json")) fs.rmSync(path.join(stateDirectory, name), { force: true });
482
- }
483
- await createFormState({ statePath, auth, allowedOrigins, browser, profileId });
484
- }
485
- return { storageState: filteredStorageState(statePath, allowedOrigins, profileId) };
499
+ return {};
486
500
  }
487
501
  if (!["cookie", "localStorage", "sessionStorage", "bearer"].includes(auth.type)) {
488
502
  throw authError(profileId, `unsupported auth type: ${auth.type}`);
@@ -490,39 +504,36 @@ async function contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins,
490
504
  return {};
491
505
  }
492
506
 
493
- async function createFormState({ statePath, auth, allowedOrigins, browser, profileId }) {
507
+ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
508
+ if (auth.type !== "form") return;
494
509
  if (typeof auth.loginUrl !== "string" || !isAllowedUrl(auth.loginUrl, allowedOrigins)) {
495
510
  throw authError(profileId, "form loginUrl is missing or outside allowedOrigins");
496
511
  }
497
512
  if (!Array.isArray(auth.fields) || auth.fields.length === 0 || typeof auth.submitSelector !== "string") {
498
513
  throw authError(profileId, "form fields or submitSelector are missing");
499
514
  }
500
- const context = await browser.newContext();
501
515
  try {
502
- await installOriginGuard(context, allowedOrigins, auth);
503
- const page = await context.newPage();
504
- await page.goto(auth.loginUrl);
516
+ const timeout = Math.min(finitePositive(auth.timeoutMs) ?? 15_000, 60_000);
517
+ page.setDefaultTimeout(timeout);
518
+ page.setDefaultNavigationTimeout(timeout);
519
+ await page.goto(auth.loginUrl, { timeout });
520
+ await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
505
521
  for (const field of auth.fields) {
506
522
  if (!isObject(field) || typeof field.selector !== "string" || typeof field.value !== "string") {
507
523
  throw authError(profileId, "form fields must contain selector/value strings");
508
524
  }
509
525
  await page.locator(field.selector).fill(field.value);
526
+ await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
510
527
  }
511
528
  await page.locator(auth.submitSelector).click();
512
- const timeout = finitePositive(auth.timeoutMs) ?? 15_000;
513
529
  if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
514
530
  if (isObject(auth.success) && typeof auth.success.selector === "string") await page.locator(auth.success.selector).waitFor({ timeout });
515
531
  if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
516
532
  throw authError(profileId, "form success.url or success.selector is required");
517
533
  }
518
- fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
519
- await context.storageState({ path: statePath });
520
- fs.chmodSync(statePath, 0o600);
521
534
  } catch (error) {
522
535
  if (error instanceof QaStatusError) throw error;
523
536
  throw authError(profileId, "form login was rejected or did not reach the configured success condition");
524
- } finally {
525
- await context.close().catch(() => {});
526
537
  }
527
538
  }
528
539
 
@@ -550,10 +561,6 @@ async function applyContextAuth(context, auth, allowedOrigins, baseURL, profileI
550
561
  }
551
562
  }
552
563
 
553
- function authCacheKey(auth, allowedOrigins) {
554
- return createHash("sha256").update(JSON.stringify({ auth, allowedOrigins })).digest("hex").slice(0, 16);
555
- }
556
-
557
564
  async function installOriginGuard(context, allowedOrigins, auth) {
558
565
  await context.route("**/*", async (route) => {
559
566
  const request = route.request();
@@ -890,10 +897,6 @@ function createExclusivePrivateDirectory(cwd, target) {
890
897
  createPrivateDirectory(cwd, target, true);
891
898
  }
892
899
 
893
- function ensurePrivateDirectory(cwd, target) {
894
- createPrivateDirectory(cwd, target, false);
895
- }
896
-
897
900
  function createPrivateDirectory(cwd, target, exclusive) {
898
901
  const root = fs.realpathSync(cwd);
899
902
  const resolved = path.resolve(target);
@@ -300,6 +300,13 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
300
300
  "When no mockup exists, choose a clear aesthetic direction and explain it briefly. Verify with targeted build/lint/tests or screenshot-relevant checks when possible."
301
301
  ]
302
302
  },
303
+ "browser-qa": {
304
+ "description": "Use for browser-based visual QA: reproduce UI bugs and verify fixes with deterministic assertions, screenshots, video, and traces.",
305
+ "model": "openai-codex/gpt-5.4-mini",
306
+ "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"],
307
+ "thinking": "medium",
308
+ "tools": ["read", "grep", "bash"]
309
+ },
303
310
  "tests": {
304
311
  "description": "Use for tests: locate coverage, find gaps, run/check targeted test commands, diagnose failing tests.",
305
312
  "model": "zai/glm-5-turbo",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
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": {