@firstpick/pi-package-webui 0.3.6 → 0.3.8

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.
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // Minimal JSONL RPC stub standing in for the pi coding agent so HTTP endpoint
3
+ // tests can boot the real pi-webui server without a model provider.
4
+ import { createInterface } from "node:readline";
5
+
6
+ const sessionIndex = process.argv.indexOf("--session");
7
+ const sessionFile = sessionIndex !== -1 ? process.argv[sessionIndex + 1] : undefined;
8
+
9
+ let activeBash = 0;
10
+ let peakBash = 0;
11
+
12
+ function respond(payload) {
13
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
14
+ }
15
+
16
+ const rl = createInterface({ input: process.stdin });
17
+ rl.on("line", (line) => {
18
+ if (!line.trim()) return;
19
+ let command;
20
+ try {
21
+ command = JSON.parse(line);
22
+ } catch {
23
+ return;
24
+ }
25
+ const { id, type } = command || {};
26
+ if (!id || !type) return;
27
+ const base = { type: "response", id, command: type, success: true };
28
+
29
+ switch (type) {
30
+ case "get_state":
31
+ respond({
32
+ ...base,
33
+ data: {
34
+ model: { provider: "fake", id: "fake-model" },
35
+ thinkingLevel: "off",
36
+ isStreaming: false,
37
+ isCompacting: false,
38
+ steeringMode: "one-at-a-time",
39
+ followUpMode: "one-at-a-time",
40
+ sessionFile,
41
+ sessionId: "fake-session",
42
+ sessionName: "fake",
43
+ autoCompactionEnabled: false,
44
+ messageCount: 0,
45
+ pendingMessageCount: 0,
46
+ },
47
+ });
48
+ return;
49
+ case "get_messages":
50
+ respond({ ...base, data: { messages: [] } });
51
+ return;
52
+ case "get_available_models":
53
+ respond({ ...base, data: { models: [{ provider: "fake", id: "fake-model", name: "Fake Model" }] } });
54
+ return;
55
+ case "get_session_stats":
56
+ respond({ ...base, data: { tokens: 0 } });
57
+ return;
58
+ case "get_last_assistant_text":
59
+ respond({ ...base, data: { text: "fake last text" } });
60
+ return;
61
+ case "bash": {
62
+ activeBash += 1;
63
+ peakBash = Math.max(peakBash, activeBash);
64
+ setTimeout(() => {
65
+ activeBash -= 1;
66
+ respond({ ...base, data: { output: `peak:${peakBash}`, exitCode: 0, cancelled: false } });
67
+ }, 150);
68
+ return;
69
+ }
70
+ default:
71
+ respond({ ...base, data: {} });
72
+ }
73
+ });
@@ -0,0 +1,146 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { chmod, mkdtemp, rm } from "node:fs/promises";
4
+ import { networkInterfaces, tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
11
+ const serverScript = join(root, "bin", "pi-webui.mjs");
12
+ const fakePi = join(root, "tests", "fixtures", "fake-pi.mjs");
13
+ const port = 30000 + Math.floor(Math.random() * 20000);
14
+
15
+ function lanAddress() {
16
+ for (const entries of Object.values(networkInterfaces())) {
17
+ for (const entry of entries || []) {
18
+ if (entry.family === "IPv4" && !entry.internal) return entry.address;
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ async function request(host, pathname, { method = "GET", body, timeoutMs = 5_000 } = {}) {
25
+ const response = await fetch(`http://${host}:${port}${pathname}`, {
26
+ method,
27
+ headers: body === undefined ? undefined : { "Content-Type": "application/json" },
28
+ body: body === undefined ? undefined : JSON.stringify(body),
29
+ signal: AbortSignal.timeout(timeoutMs),
30
+ });
31
+ let payload;
32
+ try {
33
+ payload = await response.json();
34
+ } catch {
35
+ payload = undefined;
36
+ }
37
+ return { status: response.status, body: payload };
38
+ }
39
+
40
+ const cwd = await mkdtemp(path.join(tmpdir(), "pi-webui-http-harness-"));
41
+ await chmod(fakePi, 0o755);
42
+
43
+ const child = spawn(process.execPath, [serverScript, "--cwd", cwd, "--host", "0.0.0.0", "--port", String(port), "--pi", fakePi], {
44
+ stdio: ["ignore", "pipe", "pipe"],
45
+ });
46
+ let serverOutput = "";
47
+ child.stdout.on("data", (chunk) => {
48
+ serverOutput += String(chunk);
49
+ });
50
+ child.stderr.on("data", (chunk) => {
51
+ serverOutput += String(chunk);
52
+ });
53
+
54
+ try {
55
+ // Wait for the HTTP server to accept requests.
56
+ let health;
57
+ for (let attempt = 0; attempt < 100; attempt++) {
58
+ if (child.exitCode !== null) break;
59
+ try {
60
+ health = await request("127.0.0.1", "/api/health", { timeoutMs: 1_000 });
61
+ if (health.status === 200) break;
62
+ } catch {
63
+ // Server not listening yet.
64
+ }
65
+ await delay(200);
66
+ }
67
+ assert.equal(health?.status, 200, `server should become healthy, output:\n${serverOutput}`);
68
+ assert.equal(health.body.ok, true);
69
+ assert.equal(health.body.piRunning, true, "fake pi RPC process should be attached and running");
70
+
71
+ const tabsResponse = await request("127.0.0.1", "/api/tabs");
72
+ assert.equal(tabsResponse.status, 200);
73
+ const tabList = tabsResponse.body?.data?.tabs || tabsResponse.body?.tabs || [];
74
+ assert.equal(tabList.length, 1, "startup should create one tab for --cwd");
75
+ const tabId = tabList[0].id;
76
+ assert.ok(tabId, "tab should have an id");
77
+
78
+ const state = await request("127.0.0.1", `/api/state?tab=${encodeURIComponent(tabId)}`);
79
+ assert.equal(state.status, 200);
80
+ assert.equal(state.body?.data?.model?.provider, "fake", "state should come from the fake pi RPC");
81
+
82
+ // Native slash command routed through the adapter (/copy → get_last_assistant_text).
83
+ const copy = await request("127.0.0.1", "/api/prompt", {
84
+ method: "POST",
85
+ body: { message: "/copy", tab: tabId },
86
+ });
87
+ assert.equal(copy.status, 200);
88
+ assert.equal(copy.body?.data?.status, "succeeded", "native /copy should succeed through the adapter");
89
+ assert.equal(copy.body?.data?.copyText, "fake last text");
90
+
91
+ // Bash FIFO queue: concurrent requests must execute serially on the RPC.
92
+ const [bashA, bashB] = await Promise.all([
93
+ request("127.0.0.1", "/api/bash", { method: "POST", body: { command: "echo a", tab: tabId }, timeoutMs: 10_000 }),
94
+ request("127.0.0.1", "/api/bash", { method: "POST", body: { command: "echo b", tab: tabId }, timeoutMs: 10_000 }),
95
+ ]);
96
+ assert.equal(bashA.status, 200);
97
+ assert.equal(bashB.status, 200);
98
+ for (const result of [bashA, bashB]) {
99
+ assert.equal(result.body?.data?.output, "peak:1", "bash queue must never run two commands concurrently");
100
+ }
101
+
102
+ // Session-dir confinement: traversal targets are rejected even from localhost.
103
+ const traversalDelete = await request("127.0.0.1", "/api/session-delete", {
104
+ method: "POST",
105
+ body: { sessionPath: path.join(cwd, "outside.jsonl"), confirmed: true, tab: tabId },
106
+ });
107
+ assert.equal(traversalDelete.status, 403, "session delete outside the session dir must return 403");
108
+ assert.match(String(traversalDelete.body?.error || ""), /session directory/i);
109
+
110
+ const lan = lanAddress();
111
+ if (lan) {
112
+ const remoteDelete = await request(lan, "/api/session-delete", {
113
+ method: "POST",
114
+ body: { sessionPath: path.join(cwd, "outside.jsonl"), confirmed: true, tab: tabId },
115
+ });
116
+ assert.equal(remoteDelete.status, 403, "session delete must be localhost-only");
117
+
118
+ const remoteExport = await request(lan, "/api/prompt", {
119
+ method: "POST",
120
+ body: { message: "/export", tab: tabId },
121
+ });
122
+ assert.equal(remoteExport.status, 200, "guarded slash commands return blocked adapter cards, not raw HTTP errors");
123
+ assert.equal(remoteExport.body?.data?.status, "blocked", "guards-driven dispatch must block /export for LAN clients");
124
+
125
+ const remoteClose = await request(lan, "/api/network/close", { method: "POST" });
126
+ assert.equal(remoteClose.status, 403, "network close must be localhost-only");
127
+ } else {
128
+ console.log("http-endpoints-harness: no LAN address detected; skipping remote-client checks");
129
+ }
130
+
131
+ const localClose = await request("127.0.0.1", "/api/network/close", { method: "POST" });
132
+ assert.equal(localClose.status, 202, "network close from localhost should be accepted");
133
+
134
+ const shutdownResponse = await request("127.0.0.1", "/api/shutdown", { method: "POST" });
135
+ assert.equal(shutdownResponse.status, 200);
136
+
137
+ for (let attempt = 0; attempt < 50 && child.exitCode === null; attempt++) {
138
+ await delay(100);
139
+ }
140
+ assert.notEqual(child.exitCode, null, "server should exit after /api/shutdown");
141
+ } finally {
142
+ if (child.exitCode === null) child.kill("SIGKILL");
143
+ await rm(cwd, { recursive: true, force: true });
144
+ }
145
+
146
+ console.log("http-endpoints-harness.test.mjs passed");
@@ -27,6 +27,7 @@ const companionDependencies = {
27
27
  "@firstpick/pi-extension-git-footer-status": "^0.2.1",
28
28
  "@firstpick/pi-extension-release-aur": "^0.1.3",
29
29
  "@firstpick/pi-extension-release-npm": "^0.3.3",
30
+ "@firstpick/pi-extension-safety-guard": "^0.2.3",
30
31
  "@firstpick/pi-extension-setup-skills": "^0.1.5",
31
32
  "@firstpick/pi-extension-stats": "^0.2.0",
32
33
  "@firstpick/pi-extension-todo-progress": "^0.1.7",
@@ -147,6 +148,7 @@ assert.doesNotMatch(html, /<code>\/release-(?:npm|aur)<\/code>/, "Publish menu s
147
148
  assert.doesNotMatch(html, /data-tooltip="[^"]*\/release-(?:npm|aur)/, "Publish tooltip should not show slash command names");
148
149
  assert.match(html, /id="steerButton"[\s\S]*?data-tooltip="Steer usage:/, "Steer should explain type-first usage in a tooltip");
149
150
  assert.match(html, /id="followUpButton"[\s\S]*?data-tooltip="Follow-up usage:/, "Follow-up should explain type-first usage in a tooltip");
151
+ assert.match(html, /id="gitWorkflowButton"[\s\S]*?data-tooltip="Guided Git workflow:[\s\S]*Optional: create or type a PR branch[\s\S]*Push normally, or push the PR branch, generate\/review \/pr, and create the PR/, "Git workflow tooltip should describe the current commit-or-PR flow");
150
152
  assert.ok(
151
153
  html.indexOf('<main class="layout">') < html.indexOf('id="sidePanelBackdrop"') &&
152
154
  html.indexOf('id="sidePanelBackdrop"') < html.indexOf('id="sidePanel"'),
@@ -309,9 +311,25 @@ assert.match(css, /\.footer-line-meta \.footer-meta \{[\s\S]*?flex:\s*1 1 var\(-
309
311
  assert.match(css, /\.footer-thinking \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "git-footer effort chip should have its own styling");
310
312
  assert.match(css, /\.footer-changes \{[\s\S]*?border-color:\s*rgba\(249, 226, 175, 0\.36\)/, "git-footer changes chip should use a higher-contrast warning tint");
311
313
  assert.match(css, /\.footer-changes \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-yellow\)[\s\S]*?font-weight:\s*950/, "git-footer changes value should be bright and bold");
314
+ assert.match(css, /\.footer-changed-files-popover \{[\s\S]*?position:\s*absolute;[\s\S]*?bottom:\s*calc\(100% \+ 0\.48rem\)[\s\S]*?max-height:/, "git-footer changes chip should expose a hover popover for changed files");
315
+ assert.match(css, /\.footer-changes-with-files:hover \.footer-changed-files-popover,[\s\S]*?\.footer-changes-with-files:focus \.footer-changed-files-popover,[\s\S]*?\.footer-changes-with-files:focus-within \.footer-changed-files-popover \{[\s\S]*?display:\s*grid/, "git-footer changed-files popover should open on hover or keyboard focus");
316
+ assert.match(css, /\.footer-changed-file\.modified \.footer-changed-file-status \{ color:\s*var\(--ctp-yellow\); \}/, "modified changed-file rows should keep the changes warning color");
312
317
  assert.match(css, /\.footer-git-extra \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-sky\)[\s\S]*?font-weight:\s*900/, "git-footer extras value should be bright enough to read at footer size");
313
- assert.match(css, /\.footer-meta-action \{[\s\S]*?position:\s*relative;[\s\S]*?border-color:\s*rgba\(148, 226, 213, 0\.26\)/, "clickable footer boxes should have a subtle always-visible highlight");
314
- assert.doesNotMatch(css, /\.footer-meta-action::after/, "clickable footer boxes should not show a corner indicator dot");
318
+ assert.match(css, /\.footer-metric-action,\n\.footer-meta-action \{[\s\S]*?position:\s*relative;[\s\S]*?border-color:\s*rgba\(148, 226, 213, 0\.26\)/, "clickable footer boxes should have a subtle always-visible highlight");
319
+ assert.doesNotMatch(css, /\.footer-(?:metric|meta)-action::after/, "clickable footer boxes should not show a corner indicator dot");
320
+ assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-size:[\s\S]*?width:\s*var\(--git-changes-dialog-size\)[\s\S]*?height:\s*var\(--git-changes-dialog-size\)[\s\S]*?aspect-ratio:\s*1 \/ 1/, "git changes modal should override the base dialog with a square wide diff layout");
321
+ assert.match(css, /\.git-current-file-header \{[\s\S]*?position:\s*sticky[\s\S]*?top:\s*-0\.72rem/, "git changes modal should keep a sticky current-file header inside the diff scroller");
322
+ assert.match(css, /\.git-diff-grid \{[\s\S]*?grid-template-columns:\s*3\.8rem minmax\(22rem, 1fr\) 3\.8rem minmax\(22rem, 1fr\)/, "git changes modal should render a side-by-side diff grid");
323
+ assert.match(html, /id="gitChangesDialog"[\s\S]*id="gitChangesRefreshButton"[\s\S]*id="gitChangesBody"/, "git changes modal should expose refresh controls and a diff body");
324
+ assert.match(app, /chip\.key === "changes"[\s\S]*?options\.onClick = openGitChangesDialog/, "footer CHANGES chip should open the git changes modal");
325
+ assert.match(app, /async function loadGitChangesDialog[\s\S]*api\("\/api\/git-changes"/, "git changes modal should load diff data from the server endpoint");
326
+ assert.match(app, /function gitUntrackedEntryToDiffFile\(entry\)[\s\S]*?renderRowLimit:\s*Number\.POSITIVE_INFINITY[\s\S]*?type: "added"/, "untracked files should render as complete added-file diffs without the row preview cap");
327
+ assert.match(app, /async function loadMissingGitUntrackedContent\(entry[\s\S]*?\/api\/git-changes\/untracked-file\?path=/, "untracked path-only payloads should fetch complete file contents instead of rendering as empty files");
328
+ assert.match(app, /function updateGitChangesCurrentFileHeader\(\)[\s\S]*?querySelectorAll\("\.git-diff-file\[data-git-diff-file\]"\)/, "git changes modal should derive the sticky current-file header from visible file cards");
329
+ assert.match(server, /async function readGitUntrackedEntry\(root, file\)[\s\S]*?content: binary \? "" : buffer\.toString\("utf8"\)/, "server should include complete text contents for untracked files");
330
+ assert.match(server, /url\.pathname === "\/api\/git-changes\/untracked-file" && req\.method === "GET"/, "server should expose a focused untracked-file content endpoint for stale path-only payload fallbacks");
331
+ assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?\["diff", "--cached"/, "server should collect both staged and unstaged git diffs for the changes modal");
332
+ assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
315
333
  assert.match(css, /@media \(max-width: 1050px\)[\s\S]*?\.footer-line-meta \{[\s\S]*?display:\s*flex;[\s\S]*?flex-wrap:\s*wrap;[\s\S]*?\.footer-line-meta \.footer-meta \{[\s\S]*?flex:\s*1 1 var\(--footer-chip-min-width\);[\s\S]*?width:\s*auto;[\s\S]*?\.footer-workspace,\n\s+\.footer-model,\n\s+\.footer-thinking \{ grid-column:\s*auto; \}/, "narrow git-footer metadata should wrap like the top metric row instead of forcing a two-column grid");
316
334
  assert.match(css, /\.footer-line-tui \{[\s\S]*?white-space:\s*nowrap/, "default Web UI footer should use a minimal TUI-like line");
317
335
  assert.match(css, /\.footer-tui-cwd[\s\S]*?max-width:\s*38%/, "TUI-like footer should keep cwd compact on desktop");
@@ -477,12 +495,15 @@ assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggle
477
495
  assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
478
496
  assert.match(app, /GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui"/, "git footer Web UI data should be received as an extension-owned status payload");
479
497
  assert.match(app, /function parseGitFooterWebuiPayloadRaw\(raw\)[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_TYPE[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_VERSION/, "Web UI footer should parse the structured payload emitted by git-footer-status");
498
+ assert.match(app, /function normalizeFooterPayloadChangedFile\(value\)[\s\S]*FOOTER_CHANGED_FILE_KINDS\.has\(value\.kind\)[\s\S]*oldPath/, "git footer payload parsing should preserve changed-file details for changes popovers");
499
+ assert.match(app, /const files = value\.files\.map\(normalizeFooterPayloadChangedFile\)\.filter\(Boolean\)\.slice\(0, 80\);[\s\S]*chip\.files = files;/, "git footer payload chips should retain bounded changed-file lists");
480
500
  assert.match(app, /title: cleanFooterPayloadText\(value\.title, "", 4000\)/, "git footer tooltip titles should preserve long cwd paths instead of truncating at chip display length");
481
501
  assert.match(app, /const sourceTitle = cleanFooterPayloadText\(chip\?\.title, "", 4000\)/, "git footer tooltip rendering should keep full source titles for long cwd paths");
482
502
  assert.match(app, /function renderFooter\(\)[\s\S]*parseGitFooterWebuiPayload\(\)[\s\S]*renderGitFooterPayload\(footerPayloadWithLiveModel\(gitFooterPayload\)\)/, "detailed footer rendering should prefer the git-footer-status extension payload");
483
503
  assert.match(app, /function footerPayloadWithLiveModel\(payload\)[\s\S]*?shortModelLabel\(currentState\.model\)[\s\S]*?footerThinkingDisplay\(\)[\s\S]*?key: "thinking", label: "effort"/, "git footer payload rendering should split model and effort chips from live Web UI state");
484
- assert.match(app, /function footerContextDisplayWithAuto\(value, state = currentState\)[\s\S]*autoCompactionEnabled !== false[\s\S]*`\$\{withoutAuto\} \(auto\)`/, "context displays should append the auto-compaction indicator when enabled");
504
+ assert.match(app, /function footerContextDisplayWithAuto\(value, state = currentState\)[\s\S]*footerAutoCompactionEnabled\(state\)[\s\S]*`\$\{withoutAuto\} \(auto\)`/, "context displays should append the auto-compaction indicator when enabled");
485
505
  assert.match(app, /function footerPayloadWithLiveModel\(payload\)[\s\S]*const contextChip = \(chip\)[\s\S]*footerContextDisplayWithAuto\(chip\?\.value\)[\s\S]*if \(chip\?\.key === "context"\) return \[contextChip\(chip\)\]/, "git footer context chips should use live Web UI auto-compaction state");
506
+ assert.match(app, /async function toggleFooterAutoCompaction\(tabContext = activeTabContext\(\)\)[\s\S]*currentState = \{ \.\.\.currentState, autoCompactionEnabled: enabled \}[\s\S]*api\("\/api\/auto-compaction", \{ method: "POST", body: \{ enabled \}, tabId: tabContext\.tabId \}\)/, "git footer context box should optimistically toggle auto-compaction through the Web UI API");
486
507
  assert.match(app, /function renderGitFooterPayload\(payload\)[\s\S]*classList\.remove\("statusbar-tui-footer"\)[\s\S]*classList\.add\("statusbar-git-footer"\)[\s\S]*payload\.main\.map\(renderGitFooterPayloadMetric\)[\s\S]*payload\.meta\.map/, "enabled git footer payload should use the styled extension chip renderer, not the default TUI line");
487
508
  assert.match(app, /function ensureFooterTooltipNode\(\)[\s\S]*footer-floating-tooltip[\s\S]*document\.body\.append\(footerTooltipNode\)/, "git footer tooltips should render into a single floating viewport-level node");
488
509
  const footerTooltipSource = app.match(/function applyFooterTooltip\(node, tooltip, options = \{\}\)[\s\S]*?\n}\n\nfunction footerMetric/)?.[0] || "";
@@ -492,8 +513,19 @@ assert.match(app, /const GIT_FOOTER_TOOLTIP_COPY = \{[\s\S]*tokens:[\s\S]*cache:
492
513
  assert.match(app, /function gitFooterPayloadTooltip\(chip, options = \{\}\)[\s\S]*GIT_FOOTER_TOOLTIP_COPY\[key\][\s\S]*`Current: \$\{value\}`/, "git footer tooltips should combine explanations with the current chip value");
493
514
  assert.match(app, /function isRedundantFooterTooltipTitle\(sourceTitle, chip, value\)[\s\S]*labels\.map\(\(label\) => `\$\{label\}: \$\{value\}`\)/, "git footer tooltips should suppress duplicate label/current title lines");
494
515
  assert.match(app, /function gitFooterTooltipAlign\(chip\)[\s\S]*\["tokens", "cwd"\][\s\S]*return "start";[\s\S]*\["model", "thinking"\][\s\S]*return "end";/, "git footer tooltip alignment should keep edge boxes readable");
495
- assert.match(app, /function renderGitFooterPayloadMetric\(chip\)[\s\S]*footerMetric\(chip\.icon[\s\S]*gitFooterPayloadTooltip\(chip\)[\s\S]*tooltipAlign: gitFooterTooltipAlign\(chip\)/, "git footer main payload chips should render as styled metrics with explanatory tooltips");
496
- assert.match(app, /function renderGitFooterPayloadMeta\(chip, tab\)[\s\S]*options\.title = gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*options\.tooltipAlign = gitFooterTooltipAlign\(chip\)[\s\S]*footerMeta\(chip\.label, chip\.value, footerMetaClassForPayload\(chip\)/, "git footer meta payload chips should render as styled metadata with explanatory tooltips");
516
+ assert.match(app, /function renderGitFooterPayloadMetric\(chip\)[\s\S]*applyGitFooterContextToggleOptions\(chip, options\)[\s\S]*gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*footerMetric\(chip\.icon/, "git footer main payload chips should render as styled metrics with explanatory tooltips and context action support");
517
+ assert.match(app, /function applyFooterChangedFilesDropdown\(node, chip\)[\s\S]*chip\?\.key !== "changes"[\s\S]*footer-changes-with-files[\s\S]*footer-changed-files-popover/, "git footer changes chip should render a changed-files hover popover when files are present");
518
+ assert.match(app, /function insertChangedFilePathReference\(path\)[\s\S]*formatPathReference\(path\)[\s\S]*input\.focus\(\)/, "clicking changed files should insert an @path reference and focus the composer");
519
+ assert.match(app, /function renderGitFooterPayloadMeta\(chip, tab\)[\s\S]*options\.title = gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*footerMeta\(chip\.label, chip\.value, footerMetaClassForPayload\(chip\), options\)[\s\S]*applyFooterChangedFilesDropdown\(node, chip\)/, "git footer meta payload chips should render as styled metadata with explanatory tooltips and changes popovers");
520
+ assert.match(app, /chip\.key === "git"[\s\S]*setFooterBranchPickerOpen\(!footerBranchPickerOpen\)[\s\S]*Click to switch to another local branch/, "git branch footer chip should open the branch picker");
521
+ assert.match(app, /function renderFooterBranchPicker\(\)[\s\S]*Git branches[\s\S]*applyFooterGitBranch\(branch\.name\)/, "git branch picker should render available branches and switch on selection");
522
+ assert.match(app, /Create new branch[\s\S]*createFooterGitBranch\(\)/, "git branch picker should offer branch creation when no other branches are available");
523
+ assert.match(app, /function promptFooterGitBranchName\(\)[\s\S]*window\.prompt\("New git branch name:"[\s\S]*function createFooterGitBranch\(\)[\s\S]*confirmFooterGitBranchAction\(branchName, \{ create: true, requireConfirm: true/, "new branch creation should prompt for a branch name and require confirmation before creating it");
524
+ assert.match(app, /function footerBranchAgentWarningLines[\s\S]*WARNING:[\s\S]*still running or waiting for input in this Git working tree/, "branch create/switch confirmation should warn when an agent is active in the current git working tree");
525
+ assert.match(app, /if \(footerBranchPickerOpen\) elements\.statusBar\.append\(renderFooterBranchPicker\(\)\)/, "footer should append the branch picker above the status bar when open");
526
+ assert.match(server, /url\.pathname === "\/api\/git-branches"[\s\S]*readGitBranches\(tab\.cwd\)/, "server should expose local git branch listing for the footer picker");
527
+ assert.match(server, /url\.pathname === "\/api\/git-branch"[\s\S]*switchGitBranch\(tab\.cwd, body\.branch, \{ create: body\.create === true \}\)/, "server should expose git branch switching and creation for the footer picker");
528
+ assert.match(server, /async function switchGitBranch[\s\S]*const args = create \? \["switch", "-c", targetBranch\] : \["switch", targetBranch\]/, "server branch creation should run git switch -c for new local branches");
497
529
  assert.match(app, /let latestStats = null/, "default footer should retain session stats for token and context display");
498
530
  assert.match(app, /async function refreshStats\(tabContext = activeTabContext\(\)\)[\s\S]*api\("\/api\/stats"/, "default footer should fetch session stats");
499
531
  assert.match(app, /function renderMinimalFooter\(\)[\s\S]*stats: fallbackFooterStats\(\)/, "minimal default footer should include token, cost, and context stats");
@@ -502,7 +534,7 @@ assert.match(app, /function footerStatsCostDisplay\(stats = latestStats\)[\s\S]*
502
534
  assert.doesNotMatch(app, /Git footer status disabled/, "disabled git footer should show only the minimal footer metadata");
503
535
  assert.doesNotMatch(app, /footerMeta\("runtime"/, "minimal Web UI footer should not render runtime metadata");
504
536
  assert.match(app, /statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "optional feature detection should recognize the git-footer-status Web UI payload");
505
- assert.match(app, /\/git-footer-refresh --webui-silent/, "Web UI should quietly request the extension-owned footer payload when idle and missing");
537
+ assert.match(app, /message: `\/\$\{refreshCommand\} --webui-silent`/, "Web UI should quietly request the extension-owned footer payload when idle and missing, using the loaded RPC command name");
506
538
  assert.match(app, /function requestGitFooterWebuiPayload\(tabContext = activeTabContext\(\), \{ force = false \} = \{\}\)[\s\S]*?!force && statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "git footer payload refresh should support forced refresh even when a live payload already exists");
507
539
  assert.doesNotMatch(app, /function requestGitFooterWebuiPayload\([\s\S]*?statusEntries\.delete\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "forced git footer refreshes should keep the existing payload visible while the refresh runs");
508
540
  assert.match(app, /function applyOptimisticModelSelection\(model, tabContext = activeTabContext\(\)\)[\s\S]*?currentState = \{ \.\.\.currentState, model: nextModel \}[\s\S]*?renderStatus\(\)[\s\S]*?requestGitFooterWebuiPayload\(tabContext, \{ force: true \}\)/, "model changes should update current state and footer immediately before async refreshes complete");
@@ -526,13 +558,16 @@ assert.match(app, /nativeSkillsButton\.hidden = !isOptionalFeatureEnabled\("tuiS
526
558
  assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
527
559
  assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
528
560
  assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
561
+ assert.match(app, /id: "safetyGuard"[\s\S]*?@firstpick\/pi-extension-safety-guard/, "optional features should include the safety guard companion");
529
562
  assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-skills/, "optional features should include the TUI skills command companion");
530
563
  assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
531
- assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
564
+ assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("safety-guard"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
565
+ assert.match(app, /function combineIdenticalDuplicateCommands\(commands\)[\s\S]*duplicateGroups[\s\S]*duplicateCount: group\.length/, "identical duplicate RPC commands should be combined into one visible command entry");
566
+ assert.match(app, /if \(kind === "prompt" && attachments\.length === 0\) message = resolveRpcSlashCommandMessage\(message\)/, "manual slash prompts should resolve combined duplicate command aliases before reaching Pi RPC");
532
567
  assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
533
568
  assert.match(app, /const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled\(detectedReleasePrompt\.featureId\) \? detectedReleasePrompt : null/, "release confirmation dialogs should use specialized rendering only when their release optional feature is enabled");
534
569
  assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
535
- assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*hasAvailableCommand\(commandName\)/, "publish workflow launch should guard on loaded slash commands");
570
+ assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*resolveAvailableCommandName\(commandName, \{ rpcOnly: true \}\)/, "publish workflow launch should guard on loaded slash commands, including duplicate-suffixed RPC command names");
536
571
  assert.match(app, /if \(!isOptionalFeatureEnabled\("gitWorkflow"\)\)/, "guided git workflow should guard on enabled /git-staged-msg feature");
537
572
  assert.doesNotMatch(html, /gitWorkflowProcessSelect/, "guided git workflow should not expose process selection as a dropdown");
538
573
  assert.match(app, /const GIT_WORKFLOW_PROCESSES = \[[\s\S]*value: "stage", label: "Stage"[\s\S]*value: "message", label: "Message"[\s\S]*value: "commit", label: "Commit"[\s\S]*value: "push", label: "Push"/, "guided git workflow should define Stage/Message/Commit/Push process buttons");
@@ -550,8 +585,8 @@ assert.match(app, /function bindGitWorkflowToActiveTab\(\) \{\n\s+gitWorkflow =
550
585
  assert.match(app, /function setGitWorkflow\(patch, \{ tabId = activeTabId \} = \{\}\)[\s\S]*if \(tabId === activeTabId\) \{[\s\S]*renderGitWorkflow\(\);/, "guided git workflow should not render inactive terminal workflows globally");
551
586
  assert.match(html, /id="gitPrDialog"[\s\S]*id="gitPrTitleInput"[\s\S]*id="gitPrBodyEditor"[\s\S]*id="gitPrCreateButton"/, "guided git workflow should expose a PR review dialog with title and body editing");
552
587
  assert.match(app, /addGitWorkflowAction\("Create PR", \(\) => createGitPrBranch\(\), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP\)/, "guided git workflow should offer a Create PR branch action after message generation");
553
- assert.match(app, /const GIT_WORKFLOW_CREATE_PR_TOOLTIP = \[[\s\S]*"1\. Ask Pi to generate a type\/feature-name branch from staged changes\."[\s\S]*"5\. Return here so you can choose Commit short or Commit long on that branch\."/, "Create PR should have a step-by-step tooltip");
554
- assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"5\. Return here so you can choose Commit short or Commit long on that branch\."/, "Manual branch should have a step-by-step tooltip");
588
+ assert.match(app, /const GIT_WORKFLOW_CREATE_PR_TOOLTIP = \[[\s\S]*"Create PR branch:"[\s\S]*"1\. Ask Pi to generate a type\/feature-name branch from staged changes\."[\s\S]*"6\. Push and Create PR will push upstream, run \/pr, let you review, then run gh pr create\."/, "Create PR should have an up-to-date step-by-step tooltip");
589
+ assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"Manual PR branch:"[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"6\. Push and Create PR will push upstream, run \/pr, let you review, then run gh pr create\."/, "Manual branch should have an up-to-date step-by-step tooltip");
555
590
  assert.match(app, /addGitWorkflowAction\("Manual branch", \(\) => createGitPrBranchManually\(\), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP\)/, "Manual branch should render with its tooltip");
556
591
  assert.match(css, /\.git-workflow-actions button\[data-tooltip\]::after \{[\s\S]*content:\s*attr\(data-tooltip\)[\s\S]*white-space:\s*pre-line/, "guided git workflow action tooltips should render multiline step lists");
557
592
  assert.match(app, /function gitBranchNamePromptMessage\(\)[\s\S]*hasAvailableCommand\("git-branch-name"\)[\s\S]*return "\/git-branch-name"/, "guided git workflow should ask the agent to generate PR branch names when the prompt is available");
@@ -726,7 +761,7 @@ assert.match(app, /busyPromptBehaviorMenu\?\.addEventListener\("click"[\s\S]*cho
726
761
  assert.match(app, /setBusyPromptBehavior\(controls\.busyBehavior\.select\.value\)/, "native settings should update the busy prompt behavior tag immediately");
727
762
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
728
763
  assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
729
- assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "Publish workflows should send slash commands directly without replacing the draft");
764
+ assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", `\/\$\{resolvedCommandName\}\$\{commandRest\}`\)/, "Publish workflows should send resolved slash commands directly without replacing the draft");
730
765
  assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?await handleNativeSlashSelectorCommand\(command\)/, "skills/tools command menu should open native selector dialogs directly");
731
766
  assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "generic native command menu should fall back to slash-command prompt execution");
732
767
  assert.match(app, /function setOptionsMenuOpen\(open\)/, "Options menu should have explicit open state");
@@ -759,8 +794,11 @@ assert.match(app, /function openNativeSettingsDialog\(\)[\s\S]*?\/api\/steering-
759
794
  assert.match(app, /function openNativeNameDialog\(\)[\s\S]*?sendPrompt\("prompt", `\/name \$\{name\}`\)/, "native /name selector should prompt before running the slash command");
760
795
  assert.match(app, /function openNativeForkSelector\(\)[\s\S]*?\/api\/fork-messages[\s\S]*?\/api\/fork/, "native /fork selector should pair fork-point loading with the fork action");
761
796
  assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*?\/api\/sessions\?scope=\$\{encodeURIComponent\(selectedScope\)\}/, "native /resume selector should list current-cwd or all sessions");
797
+ assert.match(app, /\/api\/session-rename/, "native /resume selector should rename session metadata");
798
+ assert.match(app, /\/api\/session-delete/, "native /resume selector should delete sessions with confirmation");
762
799
  assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tree[\s\S]*?\/api\/tree-navigate/, "native /tree selector should list tree entries and navigate through the backend helper");
763
- assert.match(app, /Provider credential entry is intentionally not implemented in the browser yet/, "native /login should remain a safe non-secret guidance dialog");
800
+ assert.match(app, /async function openNativeAuthSelector\(mode\)[\s\S]*?\/api\/auth-providers[\s\S]*?Browser login is not implemented yet/, "native /login should list provider status without browser credential entry");
801
+ assert.match(app, /\/api\/auth-logout[\s\S]*?confirmed: true/, "native /logout should remove stored credentials through a confirmed localhost-only endpoint");
764
802
  assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate", "webui-helper"\]\)/, "internal Web UI helper commands should stay out of command pickers");
765
803
  assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
766
804
  assert.match(app, /const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history"/, "prompt history should be persisted per browser for keyboard recall");
@@ -874,8 +912,8 @@ assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to m
874
912
  assert.match(app, /function restoreSidePanelState\(\) \{\n\s+if \(isSidePanelOverlayView\(\)\)/, "mobile and narrow overlay layouts should start with side panel collapsed even if desktop state was expanded");
875
913
  assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
876
914
  assert.match(app, /addTransientMessage\(\{ role: "native", title: "\/reload"/, "native /reload should produce visible transcript output");
877
- assert.match(app, /await copyText\(response\.data\.copyText\)/, "native /copy should use the shared browser clipboard helper when available");
878
- assert.match(app, /Clipboard access failed:[\s\S]*?response\.data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
915
+ assert.match(app, /copyText\(data\.copyText\)\.catch/, "native /copy should use the shared browser clipboard helper when available");
916
+ assert.match(app, /Clipboard access failed:[\s\S]*?data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
879
917
  assert.match(app, /setTimeout\(\(\) => \{[\s\S]*?refreshAll\(tabContext\)\.catch/, "frontend should refresh state after native /reload restarts the RPC process");
880
918
  assert.match(app, /api\("\/api\/path-fast-picks"/, "frontend should load/save fast picks through the server API");
881
919
  assert.match(app, /loadLegacyFastPicks\(/, "frontend should migrate existing browser-local fast picks");
@@ -885,7 +923,9 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
885
923
  assert.ok(manifest.icons?.some((icon) => icon.src === "/apple-touch-icon.png" && icon.sizes === "180x180"), "PWA manifest should include a conventional 180px apple touch icon");
886
924
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
887
925
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
888
- assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v25"/, "PWA service worker should define an app-shell cache");
926
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v\d+"/, "PWA service worker should define a versioned app-shell cache");
927
+ assert.match(serviceWorker, /fetchThenCache\(request\)\.catch\(/, "PWA service worker should serve the app shell network-first with offline cache fallback");
928
+ assert.match(serviceWorker, /ignoreSearch: true/, "PWA service worker offline fallback should ignore ?v= cache busters");
889
929
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
890
930
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
891
931
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
@@ -902,7 +942,7 @@ assert.match(server, /AuthStorage, SessionManager/, "server should import AuthSt
902
942
  assert.match(server, /const CODEX_TOKEN_REFRESH_SKEW_MS = 5 \* 60 \* 1000/, "server should refresh Codex OAuth tokens before they expire");
903
943
  assert.match(server, /url\.pathname === "\/api\/codex-usage" && req\.method === "GET"/, "server should expose a sanitized Codex usage endpoint");
904
944
  assert.match(server, /OPENAI_CODEX_USAGE_ENDPOINT/, "server should query Codex usage from the backend, not the browser");
905
- assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(\)/, "server should define Pi native slash commands for autocomplete from the parity matrix");
945
+ assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(nativeParityMatrix\)/, "server should define Pi native slash commands for autocomplete from the parity matrix");
906
946
  assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "native command descriptions should come from the parity matrix source of truth");
907
947
  assert.match(server, /function parseSlashCommand\(message\)/, "server should parse native slash commands before prompt forwarding");
908
948
  assert.match(server, /function generatedTabTitleFromPrompt\(message\)/, "server should derive concise automatic tab titles from first prompts");
@@ -970,6 +1010,9 @@ assert.match(server, /case "copy": \{[\s\S]*?get_last_assistant_text[\s\S]*?copy
970
1010
  assert.match(server, /case "export": \{[\s\S]*?handleNativeExportCommand\(tab, parsed\.args, req\)/, "native /export should run through the Web UI export helper");
971
1011
  assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "native /export should expose short-lived opaque download URLs");
972
1012
  assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should auto-start native command downloads");
1013
+ assert.match(app, /function safeHttpUrl\(value/, "frontend should validate server-provided URLs through a shared helper");
1014
+ assert.match(app, /const url = safeHttpUrl\(download\?\.url\)/, "native downloads must reject non-http(s) URL schemes");
1015
+ assert.match(app, /const href = safeHttpUrl\(url\);/, "network status links must reject non-http(s) URL schemes");
973
1016
  assert.match(server, /case "\/api\/bash": \{[\s\S]*?type: "bash", command, excludeFromContext: body\.excludeFromContext === true/, "server should expose user bash execution with exclude-from-context support");
974
1017
  assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
975
1018
  assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash through a per-tab FIFO queue");
@@ -1008,6 +1051,7 @@ assert.match(server, /type: "set_follow_up_mode"/, "server should expose follow-
1008
1051
  assert.match(server, /type: "set_auto_compaction"/, "server should expose auto-compaction changes for native /settings");
1009
1052
  assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
1010
1053
  assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
1054
+ assert.match(server, /\["safetyGuard", "@firstpick\/pi-extension-safety-guard"\]/, "server should allow installing the safety guard optional feature");
1011
1055
  assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
1012
1056
  assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
1013
1057
  assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
@@ -1017,9 +1061,9 @@ assert.match(server, /installRootDeclaresPackage\(.*?@firstpick\/pi-package-webu
1017
1061
  assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkout Web UI launches should still use the checkout root for optional feature installs");
1018
1062
  assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no declared package root can be found");
1019
1063
  assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
1020
- assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
1064
+ assert.match(server, /requireLocalhostRoute\(req, url\.pathname\)/, "optional feature install endpoint should use shared localhost trust policy");
1021
1065
  assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "GET"[\s\S]*?getSkillFileData/, "server should expose GET /api/skill-file for editable skill content");
1022
- assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "POST"[\s\S]*?Saving skill files is only allowed from localhost[\s\S]*?saveSkillFileData/, "server should expose localhost-only POST /api/skill-file for saving skill content");
1066
+ assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "POST"[\s\S]*?requireLocalhostRoute\(req, url\.pathname\)[\s\S]*?saveSkillFileData/, "server should expose localhost-only POST /api/skill-file for saving skill content");
1023
1067
  assert.match(server, /function resolveEditableSkillFile\(tab, request = \{\}\)[\s\S]*?path\.basename\(skill\.filePath\) !== "SKILL\.md"/, "skill file API should validate that edits target resolved SKILL.md resources");
1024
1068
  assert.match(server, /function resolveExplicitSkillFilePath\(tab, filePath, requestedName = ""\)[\s\S]*?Skill path must point to \/skills\/<name>\/SKILL\.md[\s\S]*?allowedRoots/, "skill file API should allow exact read SKILL.md paths from trusted Pi skill roots");
1025
1069
  assert.match(server, /Skill path is outside allowed Pi skill locations/, "explicit skill path fallback should reject paths outside Pi skill roots");
@@ -1056,8 +1100,7 @@ assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui
1056
1100
  assert.match(startScript, /export PI_WEBUI_DEV=1/, "start-webui.sh --dev should mark the Web UI server as dev mode");
1057
1101
  assert.match(startScript, /"\$\{webui_cmd\[@\]\}" --cwd "\$cwd" --host "\$host" --port "\$port" "\$\{pass_args\[@\]\}"/, "start-webui.sh should launch through the selected server command without forwarding --dev");
1058
1102
 
1059
- assert.match(pkg.scripts?.test || "", /node tests\/mobile-static\.test\.mjs/, "package test script should run the mobile static harness");
1060
- assert.match(pkg.scripts?.test || "", /node tests\/native-parity\.test\.mjs/, "package test script should run the native parity harness");
1103
+ assert.match(pkg.scripts?.test || "", /node tests\/run-all\.mjs/, "package test script should run every tests/*.test.mjs through the shared runner");
1061
1104
  assert.ok(pkg.files?.includes("start-webui.sh"), "npm package should include the Bash helper launcher");
1062
1105
  assert.ok(pkg.files?.includes("start-webui.ps1"), "npm package should include the PowerShell helper launcher");
1063
1106
  for (const [name, range] of Object.entries(companionDependencies)) {
@@ -1069,6 +1112,7 @@ for (const extensionPath of [
1069
1112
  "../pi-extension-git-footer-status/index.ts",
1070
1113
  "../pi-extension-release-aur/index.ts",
1071
1114
  "../pi-extension-release-npm/index.ts",
1115
+ "../pi-extension-safety-guard/index.ts",
1072
1116
  "../pi-extension-setup-skills/index.ts",
1073
1117
  "../pi-extension-stats/index.ts",
1074
1118
  "../pi-extension-todo-progress/index.ts",
@@ -1076,6 +1120,7 @@ for (const extensionPath of [
1076
1120
  "node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
1077
1121
  "node_modules/@firstpick/pi-extension-release-aur/index.ts",
1078
1122
  "node_modules/@firstpick/pi-extension-release-npm/index.ts",
1123
+ "node_modules/@firstpick/pi-extension-safety-guard/index.ts",
1079
1124
  "node_modules/@firstpick/pi-extension-setup-skills/index.ts",
1080
1125
  "node_modules/@firstpick/pi-extension-stats/index.ts",
1081
1126
  "node_modules/@firstpick/pi-extension-todo-progress/index.ts",
@@ -1090,6 +1135,6 @@ assert.ok(pkg.pi?.prompts?.includes("node_modules/@firstpick/pi-prompts-git-pr/p
1090
1135
  assert.ok(pkg.pi?.themes?.includes("../pi-package-themes-bundle/themes"), "webui Pi manifest should load sibling bundled themes when present");
1091
1136
  assert.ok(pkg.pi?.themes?.includes("node_modules/@firstpick/pi-themes-bundle/themes"), "webui Pi manifest should load nested bundled themes when present");
1092
1137
  assert.ok(pkg.scripts?.check?.includes("node --check public/app.js"), "check script should syntax-check app.js");
1093
- assert.ok(pkg.scripts?.check?.includes("node tests/mobile-static.test.mjs"), "check script should include mobile static assertions");
1138
+ assert.ok(pkg.scripts?.check?.includes("node tests/run-all.mjs"), "check script should run the shared test runner");
1094
1139
 
1095
1140
  console.log("mobile static checks passed");