pi-ui-extend 1.0.19 → 1.0.20

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.
@@ -251,8 +251,9 @@ The built-in `browser-qa` role runs on `antigravity/gemini-3-flash-preview`, wit
251
251
  `openai-codex/gpt-5.4-mini` as its fallback. Its browser workflow is an explicit
252
252
  private skill under `src/async-subagents/private-skills/`, outside normal Pi skill
253
253
  discovery. The role's first-class `isolatedSkills` setting launches the child with
254
- `--no-skills` plus only that skill, so parent and ordinary sub-agent sessions do
255
- not discover it.
254
+ `--no-skills` plus the private workflow and the packaged `skills/playwright-cli`
255
+ skill. Both are mandatory even when configuration adds other isolated skills;
256
+ parent and ordinary sub-agent sessions do not discover the private workflow.
256
257
 
257
258
  Keep named dev/staging auth profiles in project `.pi/qa_auth.jsonc` (there is no
258
259
  `/qa-auth` command). The private runner supports `form`, `cookie`, `localStorage`,
@@ -17,6 +17,9 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
17
17
  Antigravity-backed role unavailable.
18
18
  - A type profile may declare `isolatedSkills`. Spawning that profile adds
19
19
  `--no-skills` followed by one explicit `--skill` per configured path.
20
+ - The `browser-qa` profile always loads both its private workflow and the
21
+ packaged `skills/playwright-cli` skill. Configuration may append isolated
22
+ skills but cannot remove either mandatory browser skill.
20
23
  - Other sub-agent profiles and the parent session must not discover the private
21
24
  skill automatically.
22
25
 
@@ -66,9 +69,10 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
66
69
 
67
70
  ## Acceptance criteria
68
71
 
69
- 1. `browser-qa` resolves to the intended model/fallback and private skill, and
70
- its isolated child process can register the configured Antigravity model.
71
- 2. Spawn args contain `--no-skills` and only the explicit private skill for this
72
+ 1. `browser-qa` resolves to the intended model/fallback, private workflow, and
73
+ packaged `playwright-cli` skill, and its isolated child process can register
74
+ the configured Antigravity model.
75
+ 2. Spawn args contain `--no-skills` and both mandatory explicit skills for this
72
76
  profile; ordinary profiles retain existing skill discovery behavior.
73
77
  3. Auth profile listing and all error output are redacted; model-authored input
74
78
  cannot execute code in the credential-bearing process.
@@ -79,3 +83,19 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
79
83
  5. Completed test runs report clickable screenshot, video, and trace links
80
84
  whenever those artifacts exist.
81
85
  6. Suite tests/typecheck, host checks, and suite sync pass.
86
+
87
+ ## Real-browser regression test
88
+
89
+ The repository includes a local mock-page E2E that launches real Chromium and
90
+ asserts PNG screenshots, WebM video, sanitized trace output, and absolute
91
+ path/`file:` URI metadata:
92
+
93
+ ```bash
94
+ npx playwright install chromium
95
+ npm run test:browser-qa-e2e
96
+ ```
97
+
98
+ Normal suite tests keep this case skipped; the Publish workflow runs it on
99
+ Linux after installing Chromium. Explicit runs retain the latest artifacts in
100
+ `.pi/qa-runs/browser-qa-e2e/latest/` and print a clickable link for every file.
101
+ Set `BROWSER_QA_KEEP_EVIDENCE=0` to clean up without publishing evidence.
@@ -22,6 +22,7 @@
22
22
  "smoke:tools": "PI_OFFLINE=1 pi --no-session -p \"ping\"",
23
23
  "smoke": "npm run smoke:explicit && npm run smoke:auto && npm run smoke:tools",
24
24
  "test": "bun test test",
25
+ "test:browser-qa-e2e": "BROWSER_QA_RUNNER_E2E=1 bun test test/async-subagents/browser-qa-runner.e2e.test.ts",
25
26
  "test:async-subagents-e2e": "ASYNC_SUBAGENTS_E2E=1 ASYNC_SUBAGENTS_DEBUG_LOGS=1 ASYNC_SUBAGENTS_MODEL=zai/glm-5-turbo bun test --concurrent --max-concurrency=30 test/async-subagents",
26
27
  "test:async-subagents-selection-e2e": "ASYNC_SUBAGENTS_SELECTION_E2E=1 ASYNC_SUBAGENTS_MODEL=zai/glm-5-turbo bun test --concurrent --max-concurrency=30 test/async-subagents/selection-e2e.test.ts",
27
28
  "test:prompt-evals:tool-selection": "PROMPT_EVAL_E2E=1 bun test --concurrent --max-concurrency=10 test/tool-selection-e2e.test.ts",
@@ -288,6 +288,21 @@ export function getBrowserQaSkillPath(): string {
288
288
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "private-skills", "browser-qa", "SKILL.md");
289
289
  }
290
290
 
291
+ export function getPlaywrightCliSkillPath(): string {
292
+ const bundledPath = path.resolve(
293
+ path.dirname(fileURLToPath(import.meta.url)),
294
+ "../../../../..",
295
+ "skills",
296
+ "playwright-cli",
297
+ "SKILL.md",
298
+ );
299
+ const installedPaths = [
300
+ path.join(os.homedir(), ".agents", "skills", "playwright-cli", "SKILL.md"),
301
+ path.join(os.homedir(), ".pi", "agent", "skills", "playwright-cli", "SKILL.md"),
302
+ ];
303
+ return [bundledPath, ...installedPaths].find((candidate) => fs.existsSync(candidate)) ?? bundledPath;
304
+ }
305
+
291
306
  export function getSubagentConfigInitTargetPath(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
292
307
  return explicitSubagentConfigPath(cwd, env) ?? getDefaultSubagentConfigPath();
293
308
  }
@@ -381,7 +396,7 @@ export function resolveAgentTaskConfig(
381
396
  return {
382
397
  profile,
383
398
  extraArgs,
384
- isolatedSkills: arrayOfStrings(profile?.isolatedSkills) ?? [],
399
+ isolatedSkills: resolveIsolatedSkills(selectedType, profile),
385
400
  fallbackModels,
386
401
  retry: resolveRetryConfig(config.retry, profile?.retry),
387
402
  maxResultBytes: profile?.maxResultBytes ?? config.maxResultBytes,
@@ -399,6 +414,12 @@ export function resolveAgentTaskConfig(
399
414
  };
400
415
  }
401
416
 
417
+ function resolveIsolatedSkills(selectedType: string | undefined, profile: SubagentTypeConfig | undefined): string[] {
418
+ const configured = arrayOfStrings(profile?.isolatedSkills) ?? [];
419
+ if (selectedType !== "browser-qa") return configured;
420
+ return [...new Set([getBrowserQaSkillPath(), getPlaywrightCliSkillPath(), ...configured])];
421
+ }
422
+
402
423
  export function resolveSubagentRoutingConfig(config: SubagentConfig): ResolvedSubagentRoutingConfig {
403
424
  return { ...DEFAULT_ROUTING_CONFIG, ...(config.routing ?? {}) };
404
425
  }
@@ -24,6 +24,7 @@ export {
24
24
  existingSubagentConfigFiles,
25
25
  getDefaultSubagentConfigPath,
26
26
  getBrowserQaSkillPath,
27
+ getPlaywrightCliSkillPath,
27
28
  getSubagentConfigInitTargetPath,
28
29
  getSubagentConfigSamplePath,
29
30
  isBlindModelRef,
@@ -1,6 +1,8 @@
1
1
  import type { TaskDetails } from "../tool/types.js";
2
2
  import { EMPTY_STATE, type TaskState } from "./state.js";
3
3
 
4
+ export const TODO_STATE_ENTRY_TYPE = "pi-tools-suite:todo-state";
5
+
4
6
  /**
5
7
  * Discriminator for `details` envelopes that match the persisted `TaskDetails`
6
8
  * shape. Defensive — branch entries from older or corrupt sessions are
@@ -12,10 +14,29 @@ export function isTaskDetails(value: unknown): value is TaskDetails {
12
14
  return Array.isArray(v.tasks) && typeof v.nextId === "number";
13
15
  }
14
16
 
17
+ function taskDetailsFromEntry(entry: unknown): TaskDetails | undefined {
18
+ const candidate = entry as {
19
+ type?: string;
20
+ customType?: string;
21
+ data?: unknown;
22
+ message?: { role?: string; toolName?: string; details?: unknown };
23
+ };
24
+
25
+ if (candidate.type === "custom" && candidate.customType === TODO_STATE_ENTRY_TYPE) {
26
+ return isTaskDetails(candidate.data) ? candidate.data : undefined;
27
+ }
28
+
29
+ const message = candidate.message;
30
+ if (candidate.type !== "message" || message?.role !== "toolResult" || message.toolName !== "todo") return undefined;
31
+ return isTaskDetails(message.details) ? message.details : undefined;
32
+ }
33
+
15
34
  /**
16
- * Walk the current branch in chronological order; the LAST `toolResult` whose
17
- * `toolName === "todo"` and whose `details` shape matches `TaskDetails` wins
18
- * (last-write-wins). When no matching entry exists, returns `EMPTY_STATE`.
35
+ * Walk the current branch in chronological order; the LAST valid snapshot
36
+ * from either a `todo` tool result or a todo custom state entry wins
37
+ * (last-write-wins). Custom entries persist slash-command mutations, which do
38
+ * not otherwise produce tool results. When no matching entry exists, returns
39
+ * `EMPTY_STATE`.
19
40
  *
20
41
  * Pure of module state — `index.ts` writes the returned snapshot into the
21
42
  * store after this returns. The function explicitly does NOT touch the store
@@ -24,14 +45,11 @@ export function isTaskDetails(value: unknown): value is TaskDetails {
24
45
  export function replayFromBranch(ctx: { sessionManager: { getBranch(): Iterable<unknown> } }): TaskState {
25
46
  let result: TaskState = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
26
47
  for (const entry of ctx.sessionManager.getBranch()) {
27
- const e = entry as { type?: string; message?: { role?: string; toolName?: string; details?: unknown } };
28
- if (e.type !== "message") continue;
29
- const msg = e.message;
30
- if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue;
31
- if (!isTaskDetails(msg.details)) continue;
48
+ const details = taskDetailsFromEntry(entry);
49
+ if (!details) continue;
32
50
  result = {
33
- tasks: msg.details.tasks.map((t) => ({ ...t })),
34
- nextId: msg.details.nextId,
51
+ tasks: details.tasks.map((t) => ({ ...t })),
52
+ nextId: details.nextId,
35
53
  };
36
54
  }
37
55
  return result;
@@ -23,7 +23,7 @@ import {
23
23
  syncPersistedPlan,
24
24
  } from "./state/persistence.js";
25
25
  import { AUTO_CLEAR_COMPLETED_MESSAGE, autoClearCompletedTodos } from "./state/auto-clear.js";
26
- import { replayFromBranch } from "./state/replay.js";
26
+ import { replayFromBranch, TODO_STATE_ENTRY_TYPE } from "./state/replay.js";
27
27
  import { isTaskBlocked, selectTasksByStatus, selectTodoCounts } from "./state/selectors.js";
28
28
  import { applyTaskMutation } from "./state/state-reducer.js";
29
29
  import { activateStateScope, commitState, getState, replaceState } from "./state/store.js";
@@ -98,6 +98,7 @@ interface TodoToolRegistrationOptions extends TodoToolHooks {
98
98
 
99
99
  type TodoStateEventContext = { sessionManager?: { getSessionFile?: () => unknown; getSessionId?: () => unknown } };
100
100
  type TodoStateEventEmitter = { events?: { emit?: (channel: string, data: unknown) => void } };
101
+ type TodoStateEntryWriter = { appendEntry<T = unknown>(customType: string, data?: T): void };
101
102
 
102
103
  interface TodosCommandOptions {
103
104
  status?: TaskStatus;
@@ -238,6 +239,20 @@ export function publishTodoState(
238
239
  });
239
240
  }
240
241
 
242
+ function appendTodoStateSnapshot(
243
+ pi: TodoStateEntryWriter,
244
+ action: TaskAction,
245
+ params: Record<string, unknown>,
246
+ ): void {
247
+ const state = getState();
248
+ pi.appendEntry(TODO_STATE_ENTRY_TYPE, {
249
+ action,
250
+ params,
251
+ tasks: state.tasks,
252
+ nextId: state.nextId,
253
+ });
254
+ }
255
+
241
256
  function handlePersistCommand(args: unknown, ctx: { cwd?: string; hasUI?: boolean; ui?: { notify?: (message: string, level?: NotifyLevel) => void } }): boolean {
242
257
  const [command, subcommand = "status"] = getCommandTokens(args);
243
258
  if (command !== "persist") return false;
@@ -310,12 +325,13 @@ function handleScopeCommand(
310
325
  }
311
326
 
312
327
  function clearTodos(
313
- pi: TodoStateEventEmitter,
328
+ pi: TodoStateEventEmitter & TodoStateEntryWriter,
314
329
  ctx: { cwd?: string; hasUI?: boolean; ui?: { notify?: (message: string, level?: NotifyLevel) => void } },
315
330
  ): void {
316
331
  const result = applyTaskMutation(getState(), "clear", { action: "clear" });
317
332
  if (result.op.kind !== "clear") return;
318
333
  commitState(result.state);
334
+ appendTodoStateSnapshot(pi, "clear", { action: "clear" });
319
335
  publishTodoState(pi, ctx, "clear", { action: "clear" });
320
336
  const sync = syncPersistedPlan(ctx.cwd, result.state);
321
337
  const persistedText = sync?.completed ? `\nProject todo plan removed: ${sync.path}` : "";
@@ -449,9 +465,12 @@ export function registerTodosCommand(pi: ExtensionAPI): void {
449
465
  handler: async (args, ctx) => {
450
466
  activateTodoStateScope(ctx);
451
467
  if (handlePersistCommand(args, ctx)) return;
452
- if (handleScopeCommand(args, ctx, () => publishTodoState(pi as TodoStateEventEmitter, ctx))) return;
468
+ if (handleScopeCommand(args, ctx, () => {
469
+ appendTodoStateSnapshot(pi, "update", { command: "scope" });
470
+ publishTodoState(pi as TodoStateEventEmitter, ctx);
471
+ })) return;
453
472
  if (getCommandTokens(args)[0] === "clear") {
454
- clearTodos(pi as TodoStateEventEmitter, ctx);
473
+ clearTodos(pi, ctx);
455
474
  return;
456
475
  }
457
476
  if (!ctx.hasUI) {
@@ -529,7 +548,10 @@ export function registerTodosCommand(pi: ExtensionAPI): void {
529
548
  description: "Select todo ids to continue from a persisted plan; pending/in_progress items outside the scope become deferred.",
530
549
  handler: async (args, ctx) => {
531
550
  activateTodoStateScope(ctx);
532
- handleScopeCommand(`scope ${getCommandText(args)}`, ctx, () => publishTodoState(pi as TodoStateEventEmitter, ctx));
551
+ handleScopeCommand(`scope ${getCommandText(args)}`, ctx, () => {
552
+ appendTodoStateSnapshot(pi, "update", { command: "scope" });
553
+ publishTodoState(pi as TodoStateEventEmitter, ctx);
554
+ });
533
555
  },
534
556
  });
535
557
 
@@ -537,7 +559,7 @@ export function registerTodosCommand(pi: ExtensionAPI): void {
537
559
  description: "Clear all todos on the current branch and remove the persisted project plan if enabled.",
538
560
  handler: async (_args, ctx) => {
539
561
  activateTodoStateScope(ctx);
540
- clearTodos(pi as TodoStateEventEmitter, ctx);
562
+ clearTodos(pi, ctx);
541
563
  },
542
564
  });
543
565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
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": {
@@ -58,6 +58,7 @@
58
58
  "test": "mise exec node@24.16.0 -- npm run test:inner",
59
59
  "test:inner": "node --import tsx --test \"tests/**/*.test.ts\"",
60
60
  "test:tools-suite": "npm --prefix external/pi-tools-suite test",
61
+ "test:browser-qa-e2e": "npm --prefix external/pi-tools-suite run test:browser-qa-e2e",
61
62
  "sync:pi-tools-suite": "node scripts/sync-pi-tools-suite.mjs",
62
63
  "sync:pi-tools-suite:check": "node scripts/sync-pi-tools-suite.mjs --check",
63
64
  "sync:sdk-pin": "node scripts/sync-sdk-pin.mjs",
@@ -86,6 +87,7 @@
86
87
  },
87
88
  "devDependencies": {
88
89
  "@types/node": "24.12.4",
90
+ "playwright": "1.62.1",
89
91
  "tsx": "4.22.1",
90
92
  "typescript": "5.9.3"
91
93
  },