pi-ui-extend 1.0.28 → 1.0.31

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/dist/app/app.d.ts CHANGED
@@ -86,6 +86,7 @@ export declare class PiUiExtendApp {
86
86
  private setInput;
87
87
  private resetInputAfterProgrammaticEdit;
88
88
  private restoreTabInputState;
89
+ private restoreEditorSnapshot;
89
90
  private clearPersistedInputDraft;
90
91
  private insertVoiceTranscript;
91
92
  private pasteInternalClipboard;
package/dist/app/app.js CHANGED
@@ -345,6 +345,13 @@ export class PiUiExtendApp {
345
345
  restoreSessionStatus: () => this.restoreSessionStatus(),
346
346
  setInput: (value) => this.setInput(value),
347
347
  getInput: () => this.input,
348
+ setInputState: (state) => this.restoreTabInputState(state),
349
+ getInputState: () => this.inputEditor.draftState,
350
+ setInputSnapshot: (snapshot) => this.restoreEditorSnapshot(snapshot),
351
+ getInputSnapshot: () => ({
352
+ text: this.inputEditor.expandedText,
353
+ images: this.inputEditor.images.map((image) => ({ ...image })),
354
+ }),
348
355
  get entries() { return app.entries; },
349
356
  deleteConversationEntry: (entryId) => this.conversationViewport.deleteEntry(entryId),
350
357
  });
@@ -982,6 +989,11 @@ export class PiUiExtendApp {
982
989
  this.inputEditor.restoreDraftState(state);
983
990
  this.autocompleteController.dispose();
984
991
  }
992
+ restoreEditorSnapshot(snapshot) {
993
+ this.restoreTabInputState({ text: snapshot.text, cursor: snapshot.text.length });
994
+ for (const image of snapshot.images)
995
+ this.inputEditor.attachImage(image.data, image.mimeType);
996
+ }
985
997
  async clearPersistedInputDraft() {
986
998
  await this.tabsController.setInputStateForTab(this.tabsController.activeInputTabId(), { text: "", cursor: 0 });
987
999
  }
@@ -1,6 +1,7 @@
1
1
  import { type Theme } from "../../theme.js";
2
2
  import { type ToastKind, type ToastNotifier } from "../../ui.js";
3
- import type { Entry, ExtensionInputMouseEvent, ExtensionWidgetRegistration, ExtensionWidgetTheme, PixExtensionUIContext, PixMenuController, WidgetPlacement, WidgetTuiHandle } from "../types.js";
3
+ import type { InputEditorDraftState } from "../../input-editor.js";
4
+ import type { Entry, ExtensionInputMouseEvent, ExtensionWidgetRegistration, ExtensionWidgetTheme, PixExtensionUIContext, PixEditorSnapshot, PixMenuController, WidgetPlacement, WidgetTuiHandle } from "../types.js";
4
5
  export type ExtensionTerminalInputResult = {
5
6
  consume: boolean;
6
7
  data?: string;
@@ -21,6 +22,10 @@ export type ExtensionUiControllerHost = {
21
22
  restoreSessionStatus(): void;
22
23
  setInput(value: string): void;
23
24
  getInput(): string;
25
+ setInputState(state: InputEditorDraftState): void;
26
+ getInputState(): InputEditorDraftState;
27
+ setInputSnapshot(snapshot: PixEditorSnapshot): void;
28
+ getInputSnapshot(): PixEditorSnapshot;
24
29
  readonly entries: readonly Entry[];
25
30
  deleteConversationEntry(entryId: string): void;
26
31
  };
@@ -242,16 +242,25 @@ export class ExtensionUiController {
242
242
  pasteToEditor: (text) => {
243
243
  if (!this.isScopeActive(contextScopeKey))
244
244
  return;
245
- this.host.setInput(text);
245
+ this.host.setInputState({ text, cursor: text.length });
246
246
  renderIfRunning();
247
247
  },
248
248
  setEditorText: (text) => {
249
249
  if (!this.isScopeActive(contextScopeKey))
250
250
  return;
251
- this.host.setInput(text);
251
+ this.host.setInputState({ text, cursor: text.length });
252
252
  renderIfRunning();
253
253
  },
254
254
  getEditorText: () => this.isScopeActive(contextScopeKey) ? this.host.getInput() : "",
255
+ getEditorSnapshot: () => this.isScopeActive(contextScopeKey)
256
+ ? this.host.getInputSnapshot()
257
+ : { text: "", images: [] },
258
+ setEditorSnapshot: (snapshot) => {
259
+ if (!this.isScopeActive(contextScopeKey))
260
+ return;
261
+ this.host.setInputSnapshot(snapshot);
262
+ renderIfRunning();
263
+ },
255
264
  editor: async (title, prefill) => await this.editorDialog(title, prefill, contextScopeKey),
256
265
  addAutocompleteProvider: () => undefined,
257
266
  setEditorComponent: () => undefined,
@@ -396,7 +405,7 @@ export class ExtensionUiController {
396
405
  const scopeKey = this.normalizeScopeKey(options.scopeKey);
397
406
  if (this.activeCustomUis.has(scopeKey))
398
407
  throw new Error("Another extension custom UI is already active.");
399
- const savedInput = options.savedInput ?? (this.isScopeActive(scopeKey) ? this.host.getInput() : undefined);
408
+ const savedInput = options.savedInput ?? (this.isScopeActive(scopeKey) ? this.host.getInputState() : undefined);
400
409
  return await new Promise((resolve, reject) => {
401
410
  const active = {
402
411
  key: CUSTOM_UI_WIDGET_KEY,
@@ -408,7 +417,7 @@ export class ExtensionUiController {
408
417
  };
409
418
  this.activeCustomUis.set(scopeKey, active);
410
419
  if (options.editorInput !== undefined)
411
- this.host.setInput(options.editorInput);
420
+ this.host.setInputState({ text: options.editorInput, cursor: options.editorInput.length });
412
421
  const done = (value) => {
413
422
  if (active.settled || this.activeCustomUis.get(scopeKey) !== active)
414
423
  return;
@@ -449,8 +458,8 @@ export class ExtensionUiController {
449
458
  return;
450
459
  active.settled = true;
451
460
  this.activeCustomUis.delete(active.scopeKey);
452
- if (this.isScopeActive(active.scopeKey) && active.savedInput !== undefined && this.host.getInput() !== active.savedInput) {
453
- this.host.setInput(active.savedInput);
461
+ if (this.isScopeActive(active.scopeKey) && active.savedInput !== undefined) {
462
+ this.host.setInputState(active.savedInput);
454
463
  }
455
464
  try {
456
465
  active.component?.dispose?.();
@@ -477,7 +486,7 @@ export class ExtensionUiController {
477
486
  return undefined;
478
487
  const active = this.activeCustomUis.get(scopeKey);
479
488
  if (active && active.savedInput === undefined)
480
- active.savedInput = this.host.getInput();
489
+ active.savedInput = this.host.getInputState();
481
490
  return active;
482
491
  }
483
492
  isScopeActive(scopeKey) {
@@ -437,6 +437,11 @@ export type AboveInputRenderer = {
437
437
  set(key: string, content: ExtensionWidgetContent): void;
438
438
  clear(key: string): void;
439
439
  };
440
+ /** Attachment-aware editor content with virtual paste/image tags resolved. */
441
+ export type PixEditorSnapshot = {
442
+ text: string;
443
+ images: readonly ImageContent[];
444
+ };
440
445
  export type PixExtensionUIContext = Omit<ExtensionUIContext, "notify"> & {
441
446
  notify(message: string, type?: ToastKind): void;
442
447
  toast: ToastNotifier;
@@ -444,6 +449,8 @@ export type PixExtensionUIContext = Omit<ExtensionUIContext, "notify"> & {
444
449
  renderAboveInput(key: string, content: ExtensionWidgetContent): void;
445
450
  showMenu<T>(items: readonly PixMenuItem<T>[], options: PixMenuOptions): Promise<T | undefined>;
446
451
  menu: PixMenuController;
452
+ getEditorSnapshot(): PixEditorSnapshot;
453
+ setEditorSnapshot(snapshot: PixEditorSnapshot): void;
447
454
  };
448
455
  export type WidgetTuiHandle = {
449
456
  requestRender(force?: boolean): void;
@@ -20,7 +20,8 @@ export default function questionExtension(pi) {
20
20
  const selections = await runQuestionnaire(questions, ctx);
21
21
  if (selections == null)
22
22
  return createQuestionToolResult(createCanceledQuestionResult("user_canceled"), questions);
23
- return createQuestionToolResult(createSuccessfulQuestionResult(questions, selections), questions);
23
+ const images = selections.flatMap((selection) => "customText" in selection ? selection.images ?? [] : []);
24
+ return createQuestionToolResult(createSuccessfulQuestionResult(questions, selections), questions, images);
24
25
  },
25
26
  });
26
27
  }
@@ -1,6 +1,6 @@
1
- import type { CanceledQuestionResult, NormalizedQuestion, QuestionResultDetails, QuestionSelection, QuestionToolResult, SuccessfulQuestionResult } from "./types.js";
1
+ import type { CanceledQuestionResult, NormalizedQuestion, QuestionImageContent, QuestionResultDetails, QuestionSelection, QuestionToolResult, SuccessfulQuestionResult } from "./types.js";
2
2
  export declare function createSuccessfulQuestionResult(questions: NormalizedQuestion[], selections: QuestionSelection[]): SuccessfulQuestionResult;
3
3
  export declare function createCanceledQuestionResult(reason: CanceledQuestionResult["reason"], questions?: NormalizedQuestion[]): CanceledQuestionResult;
4
4
  export declare function createFallbackPrompt(questions: NormalizedQuestion[]): string;
5
5
  export declare function summarizeQuestionResult(result: QuestionResultDetails, questions?: NormalizedQuestion[]): string;
6
- export declare function createQuestionToolResult(details: QuestionResultDetails, questions?: NormalizedQuestion[]): QuestionToolResult;
6
+ export declare function createQuestionToolResult(details: QuestionResultDetails, questions?: NormalizedQuestion[], images?: QuestionImageContent[]): QuestionToolResult;
@@ -1,4 +1,7 @@
1
1
  import { throwInvalid } from "./contract.js";
2
+ function formatAttachedImages(count) {
3
+ return `${count} image${count === 1 ? "" : "s"} attached`;
4
+ }
2
5
  export function createSuccessfulQuestionResult(questions, selections) {
3
6
  const selectionsById = new Map(selections.map((selection) => [selection.id, selection]));
4
7
  return {
@@ -8,13 +11,16 @@ export function createSuccessfulQuestionResult(questions, selections) {
8
11
  throwInvalid(`Missing answer for question "${question.id}".`, "Retry after collecting one answer for each normalized question.");
9
12
  if ("customText" in selection) {
10
13
  const customText = selection.customText.trim();
11
- if (!customText)
12
- throwInvalid(`Custom Answer for question "${question.id}" is empty after trimming.`, "Retry with non-empty Custom Answer text or choose a predefined Choice.");
14
+ const imageCount = selection.images?.length ?? 0;
15
+ if (!customText && imageCount === 0)
16
+ throwInvalid(`Custom Answer for question "${question.id}" is empty after trimming.`, "Retry with non-empty Custom Answer text, an image, or choose a predefined Choice.");
17
+ const label = customText || formatAttachedImages(imageCount);
13
18
  return {
14
19
  id: question.id,
15
- value: customText,
16
- label: customText,
20
+ value: customText || label,
21
+ label,
17
22
  wasCustom: true,
23
+ ...(imageCount > 0 ? { imageCount } : {}),
18
24
  };
19
25
  }
20
26
  const choiceIndex = question.choices.findIndex((choice) => choice.value === selection.choiceValue);
@@ -68,17 +74,23 @@ export function summarizeQuestionResult(result, questions = []) {
68
74
  const questionLabels = new Map(questions.map((question) => [question.id, question.label]));
69
75
  return `question answers: ${result.answers.map((answer) => {
70
76
  const questionLabel = questionLabels.get(answer.id) ?? answer.id;
71
- if (answer.wasCustom)
72
- return `${questionLabel}: ${answer.label} (custom answer)`;
77
+ if (answer.wasCustom) {
78
+ const imageLabel = answer.imageCount ? formatAttachedImages(answer.imageCount) : undefined;
79
+ const imageSuffix = imageLabel && answer.label !== imageLabel ? `; ${imageLabel}` : "";
80
+ return `${questionLabel}: ${answer.label} (custom answer${imageSuffix})`;
81
+ }
73
82
  return `${questionLabel}: ${answer.label} (choice ${answer.index})`;
74
83
  }).join("; ")}.`;
75
84
  }
76
- export function createQuestionToolResult(details, questions = []) {
85
+ export function createQuestionToolResult(details, questions = [], images = []) {
77
86
  return {
78
- content: [{
87
+ content: [
88
+ {
79
89
  type: "text",
80
90
  text: summarizeQuestionResult(details, questions),
81
- }],
91
+ },
92
+ ...images.map((image) => ({ ...image })),
93
+ ],
82
94
  details,
83
95
  };
84
96
  }
@@ -97,6 +97,9 @@ function formatHeader(title, width) {
97
97
  function clampIndex(index, length) {
98
98
  return Math.max(0, Math.min(Math.max(0, length - 1), index));
99
99
  }
100
+ function formatAttachedImages(count) {
101
+ return `${count} image${count === 1 ? "" : "s"} attached`;
102
+ }
100
103
  export async function runQuestionnaire(questions, ctx) {
101
104
  return ctx.ui.custom((tui, theme, _keybindings, done) => {
102
105
  const selections = new Map();
@@ -104,6 +107,7 @@ export async function runQuestionnaire(questions, ctx) {
104
107
  const reviewSubmitIndex = questions.length;
105
108
  const pixCapabilities = tui.pix;
106
109
  const usesSharedEditor = Boolean(pixCapabilities?.delegatedEditorInput && ctx.ui.setEditorText && ctx.ui.getEditorText);
110
+ const usesEditorSnapshots = Boolean(usesSharedEditor && ctx.ui.setEditorSnapshot && ctx.ui.getEditorSnapshot);
107
111
  let questionIndex = 0;
108
112
  let selectedChoiceIndex = 0;
109
113
  let selectedReviewIndex = reviewSubmitIndex;
@@ -129,21 +133,45 @@ export async function runQuestionnaire(questions, ctx) {
129
133
  function sharedEditorText() {
130
134
  if (usesSharedEditor)
131
135
  return ctx.ui.getEditorText?.() ?? "";
132
- return customDrafts.get(currentQuestion().id) ?? "";
136
+ return customDrafts.get(currentQuestion().id)?.text ?? "";
137
+ }
138
+ function sharedEditorSnapshot() {
139
+ if (usesEditorSnapshots) {
140
+ const snapshot = ctx.ui.getEditorSnapshot?.();
141
+ if (snapshot)
142
+ return cloneEditorSnapshot(snapshot);
143
+ }
144
+ return { text: sharedEditorText(), images: [] };
145
+ }
146
+ function cloneEditorSnapshot(snapshot) {
147
+ return {
148
+ text: snapshot.text,
149
+ images: snapshot.images.map((image) => ({ ...image })),
150
+ };
151
+ }
152
+ function setSharedEditorSnapshot(snapshot) {
153
+ const cloned = cloneEditorSnapshot(snapshot);
154
+ customDrafts.set(currentQuestion().id, cloned);
155
+ if (usesEditorSnapshots)
156
+ ctx.ui.setEditorSnapshot?.(cloned);
157
+ else if (usesSharedEditor)
158
+ ctx.ui.setEditorText?.(cloned.text);
133
159
  }
134
160
  function setSharedEditorText(text) {
135
- customDrafts.set(currentQuestion().id, text);
161
+ customDrafts.set(currentQuestion().id, { text, images: [] });
136
162
  if (usesSharedEditor)
137
163
  ctx.ui.setEditorText?.(text);
138
164
  }
139
165
  function clearSharedEditorText() {
140
- if (usesSharedEditor)
166
+ if (usesEditorSnapshots)
167
+ ctx.ui.setEditorSnapshot?.({ text: "", images: [] });
168
+ else if (usesSharedEditor)
141
169
  ctx.ui.setEditorText?.("");
142
170
  }
143
171
  function captureCustomDraft() {
144
172
  if (mode !== "custom")
145
173
  return;
146
- customDrafts.set(currentQuestion().id, sharedEditorText());
174
+ customDrafts.set(currentQuestion().id, sharedEditorSnapshot());
147
175
  }
148
176
  function getCompleteSelections() {
149
177
  const orderedSelections = [];
@@ -172,8 +200,12 @@ export async function runQuestionnaire(questions, ctx) {
172
200
  submitCompleteSelections();
173
201
  }
174
202
  function formatReviewAnswerLabel(question, selection) {
175
- if ("customText" in selection)
176
- return `${CUSTOM_ANSWER_LABEL}: ${selection.customText}`;
203
+ if ("customText" in selection) {
204
+ const imageCount = selection.images?.length ?? 0;
205
+ const text = selection.customText || formatAttachedImages(imageCount);
206
+ const imageSuffix = selection.customText && imageCount > 0 ? ` (+${formatAttachedImages(imageCount)})` : "";
207
+ return `${CUSTOM_ANSWER_LABEL}: ${text}${imageSuffix}`;
208
+ }
177
209
  return question.choices.find((choice) => choice.value === selection.choiceValue)?.label ?? "Unknown";
178
210
  }
179
211
  function selectionIndexForQuestion(question) {
@@ -250,11 +282,13 @@ export async function runQuestionnaire(questions, ctx) {
250
282
  function enterCustomMode() {
251
283
  const question = currentQuestion();
252
284
  const existing = selections.get(question.id);
253
- const prefill = existing && "customText" in existing ? existing.customText : customDrafts.get(question.id) ?? "";
285
+ const prefill = existing && "customText" in existing
286
+ ? { text: existing.customText, images: existing.images ?? [] }
287
+ : customDrafts.get(question.id) ?? { text: "", images: [] };
254
288
  mode = "custom";
255
289
  selectedChoiceIndex = customAnswerIndex(question);
256
290
  customError = undefined;
257
- setSharedEditorText(prefill);
291
+ setSharedEditorSnapshot(prefill);
258
292
  refresh();
259
293
  }
260
294
  function selectChoice(index) {
@@ -271,15 +305,19 @@ export async function runQuestionnaire(questions, ctx) {
271
305
  enterCustomMode();
272
306
  }
273
307
  function submitCustomAnswer() {
274
- const text = sharedEditorText();
275
- const trimmed = text.trim();
276
- if (!trimmed) {
308
+ const snapshot = sharedEditorSnapshot();
309
+ const trimmed = snapshot.text.trim();
310
+ if (!trimmed && snapshot.images.length === 0) {
277
311
  customError = "Custom Answer cannot be empty.";
278
312
  refresh();
279
313
  return;
280
314
  }
281
315
  const question = currentQuestion();
282
- selections.set(question.id, { id: question.id, customText: trimmed });
316
+ selections.set(question.id, {
317
+ id: question.id,
318
+ customText: trimmed,
319
+ ...(snapshot.images.length > 0 ? { images: snapshot.images } : {}),
320
+ });
283
321
  customDrafts.delete(question.id);
284
322
  clearSharedEditorText();
285
323
  advanceAfterAnswer();
@@ -30,6 +30,7 @@ export interface PredefinedQuestionSelection {
30
30
  export interface CustomQuestionSelection {
31
31
  id: string;
32
32
  customText: string;
33
+ images?: QuestionImageContent[];
33
34
  }
34
35
  export type QuestionSelection = PredefinedQuestionSelection | CustomQuestionSelection;
35
36
  export interface QuestionAnswer {
@@ -38,6 +39,7 @@ export interface QuestionAnswer {
38
39
  label: string;
39
40
  wasCustom: boolean;
40
41
  index?: number;
42
+ imageCount?: number;
41
43
  }
42
44
  export interface SuccessfulQuestionResult {
43
45
  answers: QuestionAnswer[];
@@ -54,8 +56,17 @@ export interface TextContent {
54
56
  type: "text";
55
57
  text: string;
56
58
  }
59
+ export interface QuestionImageContent {
60
+ type: "image";
61
+ data: string;
62
+ mimeType: string;
63
+ }
64
+ export interface QuestionEditorSnapshot {
65
+ text: string;
66
+ images: QuestionImageContent[];
67
+ }
57
68
  export interface QuestionToolResult {
58
- content: TextContent[];
69
+ content: Array<TextContent | QuestionImageContent>;
59
70
  details: QuestionResultDetails;
60
71
  }
61
72
  export interface QuestionUiContext {
@@ -64,6 +75,8 @@ export interface QuestionUiContext {
64
75
  custom<T>(factory: (tui: QuestionTui, theme: QuestionTheme, keybindings: unknown, done: (value: T) => void) => QuestionComponent): Promise<T | undefined>;
65
76
  setEditorText?(text: string): void;
66
77
  getEditorText?(): string;
78
+ setEditorSnapshot?(snapshot: QuestionEditorSnapshot): void;
79
+ getEditorSnapshot?(): QuestionEditorSnapshot;
67
80
  notify?(message: string, level: "info" | "warning" | "error"): void;
68
81
  };
69
82
  }
package/dist/sdk.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { isToastKind, Toast, TOAST_KINDS } from "./ui.js";
2
2
  export type { ToastEntry, ToastKind, ToastNotifier, ToastState } from "./ui.js";
3
- export type { AboveInputRenderer, ExtensionWidgetComponent, ExtensionWidgetContent, ExtensionWidgetFactory, PixMenuController, PixMenuItem, PixMenuOptions, PixMenuSelectOptions, PixMenuVariant, PixExtensionUIContext, WidgetPlacement, WidgetTuiHandle, } from "./app/types.js";
3
+ export type { AboveInputRenderer, ExtensionWidgetComponent, ExtensionWidgetContent, ExtensionWidgetFactory, PixMenuController, PixMenuItem, PixMenuOptions, PixMenuSelectOptions, PixMenuVariant, PixExtensionUIContext, PixEditorSnapshot, WidgetPlacement, WidgetTuiHandle, } from "./app/types.js";
@@ -370,7 +370,7 @@ The canonical use case is an **`oracle`** role that consults a flagship model fr
370
370
  "description": "Cross-provider second opinion: consult a flagship from a different provider than the parent to pressure-test a hard decision. Read-only; advise, do not edit.",
371
371
  "model": "openai-codex/gpt-5.6-sol",
372
372
  "fallbackModels": ["zai/glm-5.3"],
373
- "thinking": "xhigh",
373
+ "thinking": "max",
374
374
  "modelByParent": {
375
375
  "zai/*": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"] },
376
376
  "openai-codex/*": "zai/glm-5.3",
@@ -244,7 +244,7 @@
244
244
  "description": "Oracle: cross-provider flagship second opinion for hard or high-stakes uncertainty. Use sparingly to pressure-test architecture, plans, root-cause hypotheses, risk/security calls, or final recommendations when independent disagreement is valuable. Read-only; advise, do not edit.",
245
245
  "model": "openai-codex/gpt-5.6-sol",
246
246
  "fallbackModels": ["zai/glm-5.3"],
247
- "thinking": "xhigh",
247
+ "thinking": "max",
248
248
  "tools": ["read", "grep", "bash"],
249
249
  "modelByParent": {
250
250
  "zai/*": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"] },
@@ -250,7 +250,7 @@ const BUILTIN_CONFIG: SubagentConfig = {
250
250
  "antigravity/*": { model: "zai/glm-5.3", fallbackModels: ["openai-codex/gpt-5.6-sol"] },
251
251
  "anthropic/*": { model: "openai-codex/gpt-5.6-sol", fallbackModels: ["zai/glm-5.3"] },
252
252
  },
253
- thinking: "xhigh",
253
+ thinking: "max",
254
254
  tools: ["read", "grep", "bash"],
255
255
  promptAppend: "You are an oracle: a flagship model from a different provider giving a second opinion to the parent agent. Give a concise, decisive recommendation with key tradeoffs and risks. Disagree when warranted; do not rubber-stamp. Do not edit unless explicitly asked.",
256
256
  },
@@ -35,7 +35,9 @@ source reading, server polling, capability probing, or retries.
35
35
  list without creating `.pi/qa_auth.jsonc`. Otherwise skip profile discovery
36
36
  and use public mode. Choose an auth profile only when the task names its id,
37
37
  safe profile traits make the choice unambiguous, or a public run proves that
38
- the requested page requires login.
38
+ the requested page requires login. If form authentication is required but
39
+ there is no usable profile, follow **Form-auth scaffolding** below instead of
40
+ asking the user to discover selectors.
39
41
  5. Inspect the target code and write a declarative JSONC flow under
40
42
  `$PI_SUBAGENT_AGENT_DIR/browser-qa/flows/`. Never put credentials or raw
41
43
  executable JavaScript in it. The `evaluate` action exposes only the safe
@@ -208,11 +210,43 @@ after login succeeds so credentials are not captured in the trace.
208
210
 
209
211
  ## Credentials and blocked runs
210
212
 
213
+ ### Form-auth scaffolding
214
+
215
+ When a public HTTPS login page (or loopback HTTP page for local development) is
216
+ known and there is no usable profile, invoke the trusted runner once:
217
+
218
+ ```sh
219
+ node <runner> auth scaffold \
220
+ --profile <safe-id> \
221
+ --login-url <public-login-url>
222
+ ```
223
+
224
+ The runner discovers the login form and privately writes a target-specific
225
+ `.pi/qa_auth.jsonc` containing `__PI_QA_SECRET_n__` values. It may create the
226
+ file or replace only the runner's own empty generated template; it refuses to
227
+ overwrite invalid or non-empty auth configuration. Never inspect the result.
228
+ Relay `QA_AUTH_SCAFFOLD_CREATED`, the reported file, profile, placeholder count,
229
+ and action, then ask the user to replace only those placeholder values. Browser
230
+ QA must resume in a new run after the user confirms that edit.
231
+
232
+ By default, login succeeds when the discovered form becomes hidden. A form-less
233
+ page therefore requires an explicit deterministic signal using either
234
+ `--success-url <glob>` or `--success-selector <selector>` with optional
235
+ `--success-state attached|detached|visible|hidden`. Use `--base-url <url>` only
236
+ to override the profile's same-origin application root. These are public
237
+ metadata; never put credential values in command arguments or environment
238
+ variables.
239
+
240
+ Do not scaffold cookie, bearer, storage, storage-state, MFA, CAPTCHA, or
241
+ federated login. For those modes, use the generic empty-template flow below and
242
+ ask the user to configure the profile themselves.
243
+
211
244
  Do not request credentials merely because `.pi/qa_auth.jsonc` is absent. If the
212
245
  task explicitly requires authenticated behavior, or a public run reaches the
213
246
  flow's `authRejectedIf` check, and `profiles` returned no usable profile, run
214
- `node <runner> profiles --require-auth`. Only this explicit authenticated path
215
- may create the private empty template.
247
+ form-auth scaffolding when supported. Otherwise run
248
+ `node <runner> profiles --require-auth`. Only these explicit authenticated paths
249
+ may create the private auth file.
216
250
 
217
251
  If that command or an authenticated run returns `QA_AUTH_UPDATE_REQUIRED`, stop
218
252
  and explicitly report that authenticated browser QA requires credentials or an
@@ -220,7 +254,9 @@ auth-config update. Ask the user to fill the reported file and rerun QA. If
220
254
  `templateCreated` is true, say that a private empty template was created at that
221
255
  path. Relay only the runner's profile, file, reason, action, and template-created
222
256
  state; never read the generated file or attempt to recover by exposing or
223
- replaying credentials.
257
+ replaying credentials. If the action is `fill_credentials`, also relay the
258
+ placeholder count and tell the user to replace only the generated placeholder
259
+ values.
224
260
 
225
261
  For any other blocked run, report the runner status and redacted reason. Do not
226
262
  claim that browser QA passed based on source inspection, unit tests, or a build.
@@ -230,5 +266,5 @@ all non-empty `artifacts.screenshots`, `artifacts.videos`, and
230
266
  `artifacts.traces`, and `artifacts.downloads` groups in the final response.
231
267
  These links are mandatory so the user can open the evidence directly.
232
268
 
233
- See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`, and
234
- `references/qa-design.md`.
269
+ See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`,
270
+ `references/qa-design.md`, and `references/auth-scaffold-spec.md`.
@@ -0,0 +1,78 @@
1
+ # Spec: Browser QA form-auth scaffold
2
+
3
+ ## Type
4
+
5
+ Change
6
+
7
+ ## Goal
8
+
9
+ Let the browser-QA sub-agent prepare a target-specific form-auth configuration
10
+ without reading, writing, receiving, or printing credential values. The user
11
+ should only need to replace clearly marked secret placeholders.
12
+
13
+ ## Scope
14
+
15
+ - Add a trusted runner command that inspects a public login form.
16
+ - Generate `.pi/qa_auth.jsonc` with discovered field and submit selectors.
17
+ - Keep generated values as non-secret placeholders until the user edits them.
18
+ - Preserve the rule that agents never read or edit `.pi/qa_auth.jsonc`.
19
+
20
+ ## Non-goals
21
+
22
+ - Collecting credentials interactively through the agent.
23
+ - Guessing cookie, token, storage-state, MFA, or federated-login credentials.
24
+ - Modifying an existing non-empty auth configuration.
25
+
26
+ ## Behavior
27
+
28
+ - `auth scaffold` requires a project-local browser-QA sub-agent workspace, an
29
+ HTTPS login URL (or loopback HTTP for local development), and a safe profile
30
+ id.
31
+ - The trusted runner opens the unauthenticated login page with fail-closed
32
+ origin routing and discovers the most likely login form, fillable fields,
33
+ submit control, and form container.
34
+ - It writes a private `0600` `.pi/qa_auth.jsonc` containing only generated
35
+ selectors and `__PI_QA_SECRET_n__` placeholders. It may replace the runner's
36
+ empty generated template, but never a non-empty profiles object.
37
+ - By default, successful login means that the discovered form becomes hidden.
38
+ A form-less page requires an explicit success URL pattern or selector/state.
39
+ - A run cannot use a profile while generated placeholders remain. The runner
40
+ returns `QA_AUTH_UPDATE_REQUIRED` without exposing selectors or values.
41
+
42
+ ## Invariants
43
+
44
+ - No credential value is accepted through command arguments or environment
45
+ variables.
46
+ - Scaffold status output contains only path, profile id, counts, and action.
47
+ - The command never prints inspected DOM text, input values, URLs, or selectors.
48
+ - Agents continue to treat `.pi/qa_auth.jsonc` as opaque and user-owned.
49
+
50
+ ## Edge cases
51
+
52
+ - Reject pages without a discoverable fillable field or submit control, and
53
+ form-less pages without an explicit success condition.
54
+ - Reject remote plain-HTTP login pages.
55
+ - Reject cross-origin base URLs and malformed success options.
56
+ - Reject symlinked or permissive auth paths.
57
+ - Refuse to overwrite invalid or non-empty auth configuration.
58
+
59
+ ## Related files
60
+
61
+ - `scripts/browser-qa-runner.mjs`
62
+ - `../SKILL.md`
63
+ - `../../../../../test/async-subagents/browser-qa-runner.test.ts`
64
+
65
+ ## Verification
66
+
67
+ - Unit tests for discovery output, placeholder blocking, private permissions,
68
+ empty-template replacement, and non-empty-config refusal.
69
+ - Existing browser-QA runner tests and repository checks continue to pass.
70
+
71
+ ## Evidence
72
+
73
+ - Confirmed by code: current runner owns Playwright, origin isolation, private
74
+ auth-file creation, validation, and redacted status output.
75
+ - Confirmed by tests: current runner rejects permissive/symlinked auth files and
76
+ creates its empty template with private permissions.
77
+ - Confirmed by docs: the private skill forbids agents from reading or editing
78
+ `.pi/qa_auth.jsonc`.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
4
5
  import fs from "node:fs";
5
6
  import { createRequire } from "node:module";
6
7
  import path from "node:path";
@@ -10,6 +11,7 @@ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
10
11
  import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
11
12
 
12
13
  const CONFIG_RELATIVE = ".pi/qa_auth.jsonc";
14
+ const AUTH_SECRET_PLACEHOLDER = /^__PI_QA_SECRET_[1-9][0-9]*__$/;
13
15
  const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
14
16
  const QA_WORKSPACE_RELATIVE = "browser-qa";
15
17
  const EVIDENCE_RELATIVE = "evidence";
@@ -109,6 +111,12 @@ if (!isMainThread && workerData?.operation === "sanitize_trace") {
109
111
  async function main() {
110
112
  const [command = "profiles", ...rawArgs] = process.argv.slice(2);
111
113
  const cwd = fs.realpathSync(process.cwd());
114
+ if (command === "auth") {
115
+ const [authCommand, ...authArgs] = rawArgs;
116
+ if (authCommand !== "scaffold") throw new QaStatusError("QA_RUN_FAILED", `unknown auth command: ${authCommand ?? "(missing)"}`, 1);
117
+ await runAuthScaffold(cwd, parseAuthScaffoldArgs(authArgs));
118
+ return;
119
+ }
112
120
  if (command === "profiles") {
113
121
  const requireAuth = rawArgs.length === 1 && rawArgs[0] === "--require-auth";
114
122
  if (rawArgs.length > 0 && !requireAuth) {
@@ -122,7 +130,7 @@ async function main() {
122
130
 
123
131
  const args = parseArgs(rawArgs);
124
132
  const selected = args.profile
125
- ? selectProfile(readAuthConfig(cwd, true).profiles, args.profile)
133
+ ? selectProfile(readAuthConfig(cwd, true, true).profiles, args.profile)
126
134
  : createPublicProfile(args.baseUrl);
127
135
  const profileId = selected.id;
128
136
  const profile = selected.profile;
@@ -290,7 +298,189 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
290
298
  writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
291
299
  }
292
300
 
293
- function readAuthConfig(cwd, required = false) {
301
+ async function runAuthScaffold(cwd, args) {
302
+ const profileId = args.profile;
303
+ if (!isSafeName(profileId) || profileId.length > 128) throw new QaStatusError("QA_RUN_FAILED", "--profile must be a safe profile id no longer than 128 characters", 1);
304
+ const loginUrl = normalizeScaffoldUrl(args.loginUrl, "--login-url");
305
+ const login = new URL(loginUrl);
306
+ const baseUrl = normalizeScaffoldUrl(args.baseUrl ?? `${login.origin}/`, "--base-url", login.origin);
307
+ const success = scaffoldSuccess(args);
308
+ const agentDir = resolveBrowserQaAgentDirectory(cwd, process.env[SUBAGENT_AGENT_DIR_ENV]);
309
+ const progress = createRunnerProgress(path.join(agentDir, QA_WORKSPACE_RELATIVE));
310
+ assertAuthScaffoldWritable(cwd);
311
+ const timeout = Math.min(parseRunnerTimeout(args.runnerTimeoutMs, profileId), 60_000);
312
+ const deadline = Date.now() + timeout;
313
+ let browser;
314
+ let context;
315
+ try {
316
+ progress("auth_scaffold_started", { timeoutMs: timeout });
317
+ const playwright = loadPlaywright(cwd);
318
+ browser = await runStage(progress, "auth_scaffold_browser_launch", deadline, () => playwright.chromium.launch({ headless: true }), profileId);
319
+ context = await runStage(progress, "auth_scaffold_context_create", deadline, () => browser.newContext({
320
+ ...DEFAULT_ENVIRONMENT,
321
+ serviceWorkers: "block",
322
+ viewport: DEFAULT_VIEWPORT,
323
+ }), profileId);
324
+ await runStage(progress, "auth_scaffold_origin_guard", deadline, () => installOriginGuard(context, [login.origin], { type: "none" }), profileId);
325
+ const page = await runStage(progress, "auth_scaffold_page_create", deadline, () => context.newPage(), profileId);
326
+ await runStage(progress, "auth_scaffold_login_load", deadline, () => page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout }), profileId);
327
+ const discovered = await runStage(progress, "auth_scaffold_form_discovery", deadline, () => discoverLoginForm(page), profileId);
328
+ if (!success && !discovered.formSelector) {
329
+ throw new QaStatusError("QA_RUN_FAILED", "no login form was found; provide an explicit success condition for a form-less login page", 1, profileId);
330
+ }
331
+ const profile = {
332
+ description: args.description ?? `Browser QA form login (${profileId})`,
333
+ traits: ["auth:form"],
334
+ baseUrl,
335
+ allowedOrigins: [login.origin],
336
+ auth: {
337
+ type: "form",
338
+ loginUrl,
339
+ fields: discovered.fields.map((selector, index) => ({ selector, value: `__PI_QA_SECRET_${index + 1}__` })),
340
+ submitSelector: discovered.submitSelector,
341
+ success: success ?? { selector: discovered.formSelector, state: "hidden" },
342
+ },
343
+ };
344
+ const replacedEmptyTemplate = writeAuthScaffold(cwd, profileId, profile);
345
+ writeStatus({
346
+ status: "QA_AUTH_SCAFFOLD_CREATED",
347
+ file: CONFIG_RELATIVE,
348
+ profile: profileId,
349
+ action: "fill_credentials",
350
+ placeholderCount: discovered.fields.length,
351
+ replacedEmptyTemplate,
352
+ });
353
+ } catch (error) {
354
+ if (error instanceof QaStatusError) {
355
+ if (error.details?.timedOut) terminateRunnerDescendants();
356
+ throw error;
357
+ }
358
+ if (error instanceof RunnerStageTimeoutError) {
359
+ terminateRunnerDescendants();
360
+ throw new QaStatusError("QA_RUN_FAILED", "auth scaffold timed out; verify the public login page and retry", EXIT_RUNNER_TIMEOUT, profileId, { timedOut: true });
361
+ }
362
+ throw new QaStatusError("QA_RUN_FAILED", "auth scaffold failed; verify the public login page and retry", 1, profileId);
363
+ } finally {
364
+ if (context) await runCleanupStage(progress, "auth_scaffold_context_close", () => context.close()).catch(terminateOnCleanupTimeout);
365
+ if (browser) await runCleanupStage(progress, "auth_scaffold_browser_close", () => browser.close()).catch(terminateOnCleanupTimeout);
366
+ progress("auth_scaffold_finished");
367
+ }
368
+ }
369
+
370
+ function terminateOnCleanupTimeout(error) {
371
+ if (error instanceof RunnerStageTimeoutError) terminateRunnerDescendants();
372
+ }
373
+
374
+ function normalizeScaffoldUrl(raw, flag, expectedOrigin) {
375
+ if (typeof raw !== "string") throw new QaStatusError("QA_RUN_FAILED", `${flag} is required`, 1);
376
+ try {
377
+ const url = new URL(raw);
378
+ if (!isSecureScaffoldUrl(url) || url.username || url.password || (expectedOrigin && url.origin !== expectedOrigin)) throw new Error();
379
+ return url.href;
380
+ } catch {
381
+ throw new QaStatusError("QA_RUN_FAILED", `${flag} must use HTTPS or loopback HTTP without credentials${expectedOrigin ? " on the login origin" : ""}`, 1);
382
+ }
383
+ }
384
+
385
+ function isSecureScaffoldUrl(url) {
386
+ if (url.protocol === "https:") return true;
387
+ if (url.protocol !== "http:") return false;
388
+ const hostname = url.hostname.toLowerCase();
389
+ return hostname === "localhost"
390
+ || hostname.endsWith(".localhost")
391
+ || /^127(?:\.[0-9]{1,3}){3}$/.test(hostname)
392
+ || hostname === "[::1]"
393
+ || hostname === "::1";
394
+ }
395
+
396
+ function scaffoldSuccess(args) {
397
+ if (args.successUrl && args.successSelector) throw new QaStatusError("QA_RUN_FAILED", "use only one of --success-url or --success-selector", 1);
398
+ if (args.successState && !args.successSelector) throw new QaStatusError("QA_RUN_FAILED", "--success-state requires --success-selector", 1);
399
+ if (args.successUrl) return { url: boundedScaffoldString(args.successUrl, "--success-url") };
400
+ if (!args.successSelector) return undefined;
401
+ const state = args.successState ?? "visible";
402
+ if (validLocatorState(state) !== state) throw new QaStatusError("QA_RUN_FAILED", "--success-state is invalid", 1);
403
+ return { selector: boundedScaffoldString(args.successSelector, "--success-selector"), state };
404
+ }
405
+
406
+ function boundedScaffoldString(value, flag) {
407
+ if (typeof value !== "string" || !value.trim() || value.length > 1024 || /[\r\n\0]/.test(value)) {
408
+ throw new QaStatusError("QA_RUN_FAILED", `${flag} must be a non-empty single-line string no longer than 1024 characters`, 1);
409
+ }
410
+ return value;
411
+ }
412
+
413
+ async function discoverLoginForm(page) {
414
+ const discovered = await page.evaluate(() => {
415
+ const quoteAttribute = (value) => value.replace(/[\0-\x1f\x7f"\\]/g, (character) => {
416
+ if (character === "\0") return "�";
417
+ if (character === '"') return '\\"';
418
+ if (character === "\\") return "\\\\";
419
+ return `\\${character.codePointAt(0).toString(16)} `;
420
+ });
421
+ const unique = (selector) => {
422
+ try { return document.querySelectorAll(selector).length === 1; } catch { return false; }
423
+ };
424
+ const selectorFor = (element) => {
425
+ if (element.id) {
426
+ const selector = `[id="${quoteAttribute(element.id)}"]`;
427
+ if (unique(selector)) return selector;
428
+ }
429
+ for (const attribute of ["data-testid", "data-test", "name", "aria-label"]) {
430
+ const value = element.getAttribute(attribute);
431
+ if (!value) continue;
432
+ const selector = `${element.tagName.toLowerCase()}[${attribute}="${quoteAttribute(value)}"]`;
433
+ if (unique(selector)) return selector;
434
+ }
435
+ const parts = [];
436
+ let current = element;
437
+ while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
438
+ const tag = current.tagName.toLowerCase();
439
+ const siblings = [...current.parentElement.children].filter((item) => item.tagName === current.tagName);
440
+ parts.unshift(`${tag}:nth-of-type(${siblings.indexOf(current) + 1})`);
441
+ const selector = parts.join(" > ");
442
+ if (unique(selector)) return selector;
443
+ current = current.parentElement;
444
+ }
445
+ return parts.join(" > ");
446
+ };
447
+ const fillable = (element) => {
448
+ if (element.disabled || element.getAttribute("aria-hidden") === "true") return false;
449
+ if (element instanceof HTMLTextAreaElement) return true;
450
+ if (!(element instanceof HTMLInputElement)) return false;
451
+ return ["email", "password", "text", "tel", "url", "search", "number"].includes((element.type || "text").toLowerCase());
452
+ };
453
+ const visible = (element) => Boolean(element.getClientRects().length) && getComputedStyle(element).visibility !== "hidden";
454
+ const forms = [...document.querySelectorAll("form")];
455
+ const ranked = forms.map((form) => {
456
+ const fields = [...form.querySelectorAll("input, textarea")].filter((element) => fillable(element) && visible(element));
457
+ const passwordCount = fields.filter((element) => element instanceof HTMLInputElement && element.type === "password").length;
458
+ return { form, fields, score: passwordCount * 100 + fields.length };
459
+ }).filter((entry) => entry.fields.length > 0).sort((left, right) => right.score - left.score);
460
+ const selected = ranked[0];
461
+ const root = selected?.form ?? document;
462
+ const fields = selected?.fields ?? [...document.querySelectorAll("input, textarea")].filter((element) => fillable(element) && visible(element));
463
+ const submit = [...root.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])')].find(visible);
464
+ return {
465
+ fields: fields.map(selectorFor),
466
+ submitSelector: submit ? selectorFor(submit) : undefined,
467
+ formSelector: selected ? selectorFor(selected.form) : undefined,
468
+ };
469
+ });
470
+ if (!isObject(discovered) || !Array.isArray(discovered.fields) || discovered.fields.length === 0
471
+ || discovered.fields.length > 10 || !discovered.fields.every(isBoundedSelector)
472
+ || !isBoundedSelector(discovered.submitSelector)
473
+ || (discovered.formSelector !== undefined && !isBoundedSelector(discovered.formSelector))) {
474
+ throw new Error("no supported login form with fillable fields and a submit control was found");
475
+ }
476
+ return discovered;
477
+ }
478
+
479
+ function isBoundedSelector(value) {
480
+ return typeof value === "string" && value.length > 0 && value.length <= 1024 && !/[\0-\x1f\x7f]/.test(value);
481
+ }
482
+
483
+ function readAuthConfig(cwd, required = false, allowPlaceholders = false) {
294
484
  const candidate = path.join(cwd, CONFIG_RELATIVE);
295
485
  if (!fs.existsSync(candidate)) {
296
486
  if (!required) return { present: false, profiles: {} };
@@ -333,6 +523,18 @@ function readAuthConfig(cwd, required = false) {
333
523
  { action: "provide_credentials", templateCreated: false },
334
524
  );
335
525
  }
526
+ const profileValues = Object.values(value.profiles);
527
+ if (required && !allowPlaceholders && profileValues.length > 0
528
+ && profileValues.every((profile) => authPlaceholderCount(profile) > 0)) {
529
+ const placeholderCount = profileValues.reduce((count, profile) => count + authPlaceholderCount(profile), 0);
530
+ throw new QaStatusError(
531
+ "QA_AUTH_UPDATE_REQUIRED",
532
+ `credentials are required; replace ${placeholderCount} generated credential placeholder${placeholderCount === 1 ? "" : "s"} in the auth config`,
533
+ EXIT_AUTH_UPDATE_REQUIRED,
534
+ undefined,
535
+ { action: "fill_credentials", templateCreated: false, placeholderCount },
536
+ );
537
+ }
336
538
  if (Object.keys(value.profiles).some((id) => !isSafeName(id))) {
337
539
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth profile ids may contain only letters, digits, dot, underscore, or dash", EXIT_AUTH_UPDATE_REQUIRED);
338
540
  }
@@ -357,9 +559,28 @@ function selectProfile(profiles, requestedId) {
357
559
  if (!isObject(profile.auth) || typeof profile.auth.type !== "string") {
358
560
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile auth configuration is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
359
561
  }
562
+ const placeholderCount = authPlaceholderCount(profile);
563
+ if (placeholderCount > 0) {
564
+ throw new QaStatusError(
565
+ "QA_AUTH_UPDATE_REQUIRED",
566
+ `credentials are required; replace ${placeholderCount} generated credential placeholder${placeholderCount === 1 ? "" : "s"} in the auth config`,
567
+ EXIT_AUTH_UPDATE_REQUIRED,
568
+ requestedId,
569
+ { action: "fill_credentials", templateCreated: false, placeholderCount },
570
+ );
571
+ }
360
572
  return { id: requestedId, profile };
361
573
  }
362
574
 
575
+ function authPlaceholderCount(profile) {
576
+ if (!isObject(profile) || !isObject(profile.auth) || profile.auth.type !== "form" || !Array.isArray(profile.auth.fields)) return 0;
577
+ return profile.auth.fields.filter((field) => (
578
+ isObject(field)
579
+ && typeof field.value === "string"
580
+ && AUTH_SECRET_PLACEHOLDER.test(field.value.trim())
581
+ )).length;
582
+ }
583
+
363
584
  function createPublicProfile(rawBaseUrl) {
364
585
  if (typeof rawBaseUrl !== "string") {
365
586
  throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
@@ -436,6 +657,7 @@ function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
436
657
  if (!Array.isArray(auth.fields) || auth.fields.length === 0 || auth.fields.some((field) => !isObject(field) || typeof field.selector !== "string" || !field.selector || typeof field.value !== "string" || !field.value)) throw authError(profileId, "form fields require non-empty selector/value strings");
437
658
  if (typeof auth.submitSelector !== "string" || !auth.submitSelector) throw authError(profileId, "form submitSelector is missing");
438
659
  if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) throw authError(profileId, "form success.url or success.selector is required");
660
+ if (auth.success.state !== undefined && (typeof auth.success.selector !== "string" || validLocatorState(auth.success.state) !== auth.success.state)) throw authError(profileId, "form success.state requires a selector and valid locator state");
439
661
  break;
440
662
  case "storageState":
441
663
  if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
@@ -1211,7 +1433,9 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
1211
1433
  }
1212
1434
  await page.locator(auth.submitSelector).click();
1213
1435
  if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
1214
- if (isObject(auth.success) && typeof auth.success.selector === "string") await page.locator(auth.success.selector).waitFor({ timeout });
1436
+ if (isObject(auth.success) && typeof auth.success.selector === "string") {
1437
+ await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
1438
+ }
1215
1439
  if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
1216
1440
  throw authError(profileId, "form success.url or success.selector is required");
1217
1441
  }
@@ -1354,6 +1578,26 @@ function parseArgs(values) {
1354
1578
  return result;
1355
1579
  }
1356
1580
 
1581
+ function parseAuthScaffoldArgs(values) {
1582
+ const result = {};
1583
+ for (let index = 0; index < values.length; index += 2) {
1584
+ const flag = values[index];
1585
+ const value = values[index + 1];
1586
+ if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
1587
+ const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1588
+ if (!["profile", "loginUrl", "baseUrl", "description", "successUrl", "successSelector", "successState", "runnerTimeoutMs"].includes(key)) {
1589
+ throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
1590
+ }
1591
+ if (result[key] !== undefined) throw new QaStatusError("QA_RUN_FAILED", `duplicate option: ${flag}`, 1);
1592
+ result[key] = value;
1593
+ }
1594
+ if (result.description !== undefined) {
1595
+ result.description = boundedScaffoldString(result.description, "--description");
1596
+ if (result.description.length > 200) throw new QaStatusError("QA_RUN_FAILED", "--description must be no longer than 200 characters", 1);
1597
+ }
1598
+ return result;
1599
+ }
1600
+
1357
1601
  function parseRunnerTimeout(value, profileId) {
1358
1602
  if (value === undefined) return DEFAULT_RUNNER_TIMEOUT_MS;
1359
1603
  const parsed = Number(value);
@@ -1764,6 +2008,67 @@ function createAuthTemplate(cwd) {
1764
2008
  return true;
1765
2009
  }
1766
2010
 
2011
+ function writeAuthScaffold(cwd, profileId, profile) {
2012
+ const initialTarget = assertAuthScaffoldWritable(cwd);
2013
+ const content = `// Generated by the trusted browser QA runner from the public login form.\n// Replace only __PI_QA_SECRET_n__ values yourself; agents must not read or edit this file.\n${JSON.stringify({ profiles: { [profileId]: profile } }, null, 2)}\n`;
2014
+ const temporaryFile = `${initialTarget.file}.scaffold-${randomUUID()}.tmp`;
2015
+ try {
2016
+ fs.writeFileSync(temporaryFile, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
2017
+ const descriptor = fs.openSync(temporaryFile, "r+");
2018
+ try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
2019
+
2020
+ const currentTarget = assertAuthScaffoldWritable(cwd);
2021
+ if (currentTarget.replacedEmptyTemplate !== initialTarget.replacedEmptyTemplate) {
2022
+ throw new QaStatusError("QA_RUN_FAILED", "auth config changed during scaffold creation; no file was overwritten", 1);
2023
+ }
2024
+ if (currentTarget.replacedEmptyTemplate) {
2025
+ fs.renameSync(temporaryFile, currentTarget.file);
2026
+ } else {
2027
+ try {
2028
+ fs.linkSync(temporaryFile, currentTarget.file);
2029
+ } catch (error) {
2030
+ if (isAlreadyExistsError(error)) throw new QaStatusError("QA_RUN_FAILED", "auth config changed during scaffold creation; no file was overwritten", 1);
2031
+ throw error;
2032
+ }
2033
+ fs.unlinkSync(temporaryFile);
2034
+ }
2035
+ return currentTarget.replacedEmptyTemplate;
2036
+ } finally {
2037
+ if (fs.existsSync(temporaryFile)) fs.unlinkSync(temporaryFile);
2038
+ }
2039
+ }
2040
+
2041
+ function assertAuthScaffoldWritable(cwd) {
2042
+ const root = fs.realpathSync(cwd);
2043
+ const directory = path.join(root, path.dirname(CONFIG_RELATIVE));
2044
+ const file = path.join(root, CONFIG_RELATIVE);
2045
+ assertInside(root, directory, "auth config directory");
2046
+ if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
2047
+ const directoryStat = fs.lstatSync(directory);
2048
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
2049
+ throw new QaStatusError("QA_RUN_FAILED", "auth config directory must be a real project-local directory", 1);
2050
+ }
2051
+
2052
+ let replacedEmptyTemplate = false;
2053
+ if (fs.existsSync(file)) {
2054
+ const existing = resolveExistingPrivateFile(root, CONFIG_RELATIVE, "auth config");
2055
+ const existingContent = fs.readFileSync(existing, "utf8");
2056
+ const errors = [];
2057
+ const value = parseJsonc(existingContent, errors, { allowTrailingComma: true });
2058
+ if (errors.length > 0 || !isObject(value) || !isObject(value.profiles)) {
2059
+ throw new QaStatusError("QA_RUN_FAILED", "existing auth config is invalid; scaffold creation will not overwrite it", 1);
2060
+ }
2061
+ if (Object.keys(value.profiles).length > 0) {
2062
+ throw new QaStatusError("QA_RUN_FAILED", "existing auth config has profiles; scaffold creation will not overwrite it", 1);
2063
+ }
2064
+ if (existingContent !== AUTH_TEMPLATE) {
2065
+ throw new QaStatusError("QA_RUN_FAILED", "existing empty auth config was not generated by the trusted runner; scaffold creation will not overwrite it", 1);
2066
+ }
2067
+ replacedEmptyTemplate = true;
2068
+ }
2069
+ return { file, replacedEmptyTemplate };
2070
+ }
2071
+
1767
2072
  function isAlreadyExistsError(error) {
1768
2073
  return error !== null && typeof error === "object" && error.code === "EEXIST";
1769
2074
  }
@@ -341,7 +341,7 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
341
341
  "description": "Oracle: cross-provider flagship second opinion for hard or high-stakes uncertainty. Use sparingly to pressure-test architecture, plans, root-cause hypotheses, risk/security calls, or final recommendations when independent disagreement is valuable. Read-only; advise, do not edit.",
342
342
  "model": "openai-codex/gpt-5.6-sol",
343
343
  "fallbackModels": ["zai/glm-5.3"],
344
- "thinking": "xhigh",
344
+ "thinking": "max",
345
345
  "tools": ["read", "grep", "bash"],
346
346
  "modelByParent": {
347
347
  "zai/*": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.28",
3
+ "version": "1.0.31",
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": {