pi-agent-browser-native 0.2.43 → 0.2.45

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +26 -16
  3. package/docs/ARCHITECTURE.md +12 -10
  4. package/docs/COMMAND_REFERENCE.md +49 -27
  5. package/docs/ELECTRON.md +1 -1
  6. package/docs/RELEASE.md +16 -9
  7. package/docs/REQUIREMENTS.md +6 -3
  8. package/docs/SUPPORT_MATRIX.md +18 -14
  9. package/docs/TOOL_CONTRACT.md +87 -46
  10. package/docs/platform-smoke.md +15 -9
  11. package/extensions/agent-browser/index.ts +29 -445
  12. package/extensions/agent-browser/lib/bash-guard.ts +205 -0
  13. package/extensions/agent-browser/lib/electron/cdp.ts +69 -0
  14. package/extensions/agent-browser/lib/electron/cleanup.ts +5 -58
  15. package/extensions/agent-browser/lib/electron/discovery.ts +2 -9
  16. package/extensions/agent-browser/lib/electron/launch.ts +11 -65
  17. package/extensions/agent-browser/lib/electron/text.ts +13 -0
  18. package/extensions/agent-browser/lib/fs-utils.ts +18 -0
  19. package/extensions/agent-browser/lib/input-modes/job.ts +207 -21
  20. package/extensions/agent-browser/lib/input-modes/params.ts +17 -7
  21. package/extensions/agent-browser/lib/input-modes/semantic-action.ts +22 -2
  22. package/extensions/agent-browser/lib/input-modes/types.ts +5 -1
  23. package/extensions/agent-browser/lib/input-modes.ts +1 -0
  24. package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +82 -11
  25. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +153 -30
  26. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +53 -2
  27. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +1 -0
  28. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +751 -32
  29. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +38 -7
  30. package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +0 -46
  31. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +10 -1
  32. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +28 -1
  33. package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +1 -6
  34. package/extensions/agent-browser/lib/orchestration/input-plan.ts +15 -3
  35. package/extensions/agent-browser/lib/orchestration/output-file.ts +86 -0
  36. package/extensions/agent-browser/lib/pi-tool-rendering.ts +231 -0
  37. package/extensions/agent-browser/lib/playbook.ts +26 -26
  38. package/extensions/agent-browser/lib/process.ts +1 -1
  39. package/extensions/agent-browser/lib/prompt-policy.ts +1 -18
  40. package/extensions/agent-browser/lib/results/artifact-manifest.ts +1 -4
  41. package/extensions/agent-browser/lib/results/artifact-state.ts +7 -3
  42. package/extensions/agent-browser/lib/results/contracts.ts +6 -2
  43. package/extensions/agent-browser/lib/results/envelope.ts +11 -2
  44. package/extensions/agent-browser/lib/results/network-routes.ts +7 -4
  45. package/extensions/agent-browser/lib/results/network.ts +7 -1
  46. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +88 -20
  47. package/extensions/agent-browser/lib/results/presentation/batch.ts +84 -12
  48. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +81 -26
  49. package/extensions/agent-browser/lib/results/presentation/errors.ts +13 -0
  50. package/extensions/agent-browser/lib/results/presentation/registry.ts +60 -0
  51. package/extensions/agent-browser/lib/results/presentation.ts +10 -1
  52. package/extensions/agent-browser/lib/results/snapshot-high-value-controls.ts +16 -5
  53. package/extensions/agent-browser/lib/results/snapshot.ts +2 -0
  54. package/extensions/agent-browser/lib/runtime.ts +10 -1
  55. package/extensions/agent-browser/lib/session-page-state.ts +15 -6
  56. package/extensions/agent-browser/lib/web-search.ts +1 -1
  57. package/package.json +5 -5
  58. package/platform-smoke.config.mjs +15 -3
  59. package/scripts/doctor.mjs +70 -1
  60. package/scripts/platform-smoke/build-ubuntu-image.mjs +25 -0
  61. package/scripts/platform-smoke/crabbox-runner.mjs +62 -30
  62. package/scripts/platform-smoke/doctor.mjs +28 -11
  63. package/scripts/platform-smoke/linux-image/Dockerfile +3 -5
  64. package/scripts/platform-smoke/targets.mjs +60 -22
  65. package/scripts/platform-smoke.mjs +1 -0
  66. package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +0 -154
@@ -66,6 +66,10 @@ function mergeNextActions(...groups: Array<AgentBrowserNextAction[] | undefined>
66
66
  return merged.length > 0 ? merged : undefined;
67
67
  }
68
68
 
69
+ function shouldAddAnnotatedScreenshotGuidance(commandInfo: CommandInfo, args: string[] | undefined): boolean {
70
+ return commandInfo.command === "screenshot" && (args?.includes("--annotate") ?? false);
71
+ }
72
+
69
73
  export async function buildToolPresentation(options: {
70
74
  artifactManifest?: SessionArtifactManifest;
71
75
  args?: string[];
@@ -103,7 +107,7 @@ export async function buildToolPresentation(options: {
103
107
 
104
108
  const data = enrichStreamStatusData(commandInfo, envelope?.data);
105
109
  const presentationData = redactPresentationData(commandInfo, data);
106
- const artifacts = await extractFileArtifacts({ artifactRequest, commandInfo: presentationCommandInfo, cwd, data, sessionName });
110
+ const artifacts = await extractFileArtifacts({ artifactManifest, artifactRequest, commandInfo: presentationCommandInfo, cwd, data, sessionName });
107
111
  const artifactVerification = buildArtifactVerificationSummary(artifacts);
108
112
  const artifactSummary = formatArtifactSummary(artifacts);
109
113
  const summary = artifactSummary ?? formatPresentationSummary(commandInfo, data, compiledSemanticAction);
@@ -151,6 +155,11 @@ export async function buildToolPresentation(options: {
151
155
  }
152
156
  }
153
157
 
158
+ if (shouldAddAnnotatedScreenshotGuidance(commandInfo, args) && presentation.content[0]?.type === "text") {
159
+ const guidance = "Annotated screenshot note: dense pages can produce overlapping labels. If the labels are noisy, capture a scoped element screenshot, take a non-annotated screenshot, or use snapshot -i high-value refs as the machine-readable map.";
160
+ presentation.content[0] = { ...presentation.content[0], text: `${presentation.content[0].text}\n\n${guidance}` };
161
+ }
162
+
154
163
  const imagePath = artifactRequest?.absolutePath ?? extractImagePath(commandInfo, cwd, data);
155
164
  const presentationWithImage = imagePath ? await attachInlineImage(presentation, imagePath) : presentation;
156
165
  const compactedPresentation = await compactLargePresentationOutput({
@@ -17,6 +17,7 @@ const SNAPSHOT_HIGH_VALUE_CONTROL_ROLES = new Set([
17
17
  "button",
18
18
  "checkbox",
19
19
  "combobox",
20
+ "link",
20
21
  "menuitem",
21
22
  "option",
22
23
  "radio",
@@ -30,11 +31,12 @@ const SNAPSHOT_HIGH_VALUE_CONTROL_ROLE_PRIORITY: Record<string, number> = {
30
31
  textbox: 1,
31
32
  combobox: 2,
32
33
  button: 3,
33
- tab: 4,
34
- checkbox: 5,
35
- radio: 6,
36
- option: 7,
37
- menuitem: 8,
34
+ link: 4,
35
+ tab: 5,
36
+ checkbox: 6,
37
+ radio: 7,
38
+ option: 8,
39
+ menuitem: 9,
38
40
  };
39
41
 
40
42
  const SNAPSHOT_SURFACE_CONTROL_NAME_PATTERNS = [
@@ -46,6 +48,10 @@ const SNAPSHOT_PRIMARY_ACTION_BUTTON_NAME_PATTERNS = [
46
48
  /^(?:add|apply|ask|confirm|connect|continue|create|launch|new|open|refresh|retry|run|save|search|send|start|submit)\b/i,
47
49
  ];
48
50
 
51
+ const SNAPSHOT_HIGH_VALUE_LINK_NAME_PATTERNS = [
52
+ /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i,
53
+ ];
54
+
49
55
  function getHighValueControlRole(entry: SnapshotRefEntry): string {
50
56
  return entry.isEditable === true && (entry.role === "unknown" || entry.role === "generic") ? "textbox" : entry.role;
51
57
  }
@@ -129,9 +135,14 @@ const SNAPSHOT_HIGH_VALUE_CONTROL_CATEGORY_RULES: readonly HighValueControlCateg
129
135
  },
130
136
  ] as const;
131
137
 
138
+ function isHighValueLinkRef(entry: SnapshotRefEntry): boolean {
139
+ return entry.name.length > 0 && SNAPSHOT_HIGH_VALUE_LINK_NAME_PATTERNS.some((pattern) => pattern.test(entry.name));
140
+ }
141
+
132
142
  export function isHighValueControlEntry(entry: SnapshotRefEntry): boolean {
133
143
  const role = getHighValueControlRole(entry);
134
144
  if (!SNAPSHOT_HIGH_VALUE_CONTROL_ROLES.has(role)) return false;
145
+ if (role === "link") return isHighValueLinkRef(entry);
135
146
  if (entry.isEditable === false && (role === "searchbox" || role === "textbox" || role === "combobox")) return false;
136
147
  return entry.name.length > 0 || isEditableControlRef(entry);
137
148
  }
@@ -221,6 +221,7 @@ export async function buildSnapshotPresentation(
221
221
  ...(roleCountsText ? [`Top roles: ${roleCountsText}`] : []),
222
222
  "",
223
223
  "Compact snapshot view.",
224
+ "Viewport note: compact snapshots are DOM/signal-prioritized, not guaranteed to start with the currently scrolled viewport; use the full raw snapshot, a screenshot, or listed high-value refs when viewport context matters.",
224
225
  ];
225
226
 
226
227
  if (fallbackPreview) {
@@ -294,6 +295,7 @@ export async function buildSnapshotPresentation(
294
295
  fullOutputPath,
295
296
  origin,
296
297
  previewMode: fallbackPreview ? "outline" : "structured",
298
+ viewportOrdering: "dom-signal-prioritized",
297
299
  spillError: spillErrorText,
298
300
  previewRefIds: [...previewRefIds],
299
301
  highValueControlRefIds: visibleHighValueControlEntries.map((entry) => entry.id),
@@ -619,6 +619,15 @@ export function createFreshSessionName(baseSessionName: string, ephemeralSeed: s
619
619
  return `${baseSessionName}-fresh-${suffix}`;
620
620
  }
621
621
 
622
+ function getSingleKeyCommandValidationError(args: string[]): string | undefined {
623
+ const { commandInfo, commandTokens } = parseArgvDescriptor(args);
624
+ const command = commandInfo.command;
625
+ if (command !== "press" && command !== "key" && command !== "keydown" && command !== "keyup") return undefined;
626
+ if (commandTokens.length === 2) return undefined;
627
+ const label = command === "key" ? "key/press" : command;
628
+ return `agent-browser ${label} accepts exactly one key argument. Do not pass a selector or ref to ${label}; focus or click the target first, then run ${command} <key> (for example: focus @e1, then press Enter).`;
629
+ }
630
+
622
631
  export function validateToolArgs(args: string[]): string | undefined {
623
632
  if (args.length === 0) {
624
633
  return "`args` must contain at least one agent-browser command token.";
@@ -634,7 +643,7 @@ export function validateToolArgs(args: string[]): string | undefined {
634
643
  return "Do not pass `--session-mode` in args. Use the top-level agent_browser `sessionMode` field instead, for example { args: [\"--profile\", \"Default\", \"open\", \"https://example.com\"], sessionMode: \"fresh\" }.";
635
644
  }
636
645
 
637
- return undefined;
646
+ return getSingleKeyCommandValidationError(args);
638
647
  }
639
648
 
640
649
  function getInvalidValueFlagDetails(args: string[]): InvalidValueFlagDetails | undefined {
@@ -8,6 +8,9 @@
8
8
 
9
9
  import { isCloseCommand, isReadOnlyDiagnosticSessionTargetCommand } from "./command-taxonomy.js";
10
10
  import { isRecord } from "./parsing.js";
11
+ import { getEditableRefEvidence } from "./results/editable-ref-evidence.js";
12
+ import { enrichSnapshotRefEntries, getSnapshotRefEntries } from "./results/snapshot-refs.js";
13
+ import { parseSnapshotLines } from "./results/snapshot-segments.js";
11
14
 
12
15
  export interface SessionTabTarget {
13
16
  title?: string;
@@ -21,7 +24,7 @@ interface OrderedSessionTabTarget {
21
24
 
22
25
  export interface SessionRefSnapshot {
23
26
  refIds: string[];
24
- refs?: Record<string, { name: string; role: string }>;
27
+ refs?: Record<string, { isContentEditable?: boolean; isEditable?: boolean; name: string; role: string }>;
25
28
  target?: SessionTabTarget;
26
29
  }
27
30
 
@@ -230,11 +233,15 @@ function getRestoredSessionTabTarget(details: Record<string, unknown>, command:
230
233
  return storedTarget;
231
234
  }
232
235
 
233
- function extractRefSnapshotRefs(data: unknown): Record<string, { name: string; role: string }> | undefined {
236
+ function extractRefSnapshotRefs(data: unknown): Record<string, { isContentEditable?: boolean; isEditable?: boolean; name: string; role: string }> | undefined {
234
237
  if (!isRecord(data) || !isRecord(data.refs)) return undefined;
235
- const refs = Object.fromEntries(Object.entries(data.refs).flatMap(([refId, entry]) => {
236
- if (!/^e\d+$/.test(refId) || !isRecord(entry) || typeof entry.name !== "string" || typeof entry.role !== "string") return [];
237
- return [[refId, { name: entry.name, role: entry.role }] as const];
238
+ const snapshotLines = typeof data.snapshot === "string" ? parseSnapshotLines(data.snapshot) : [];
239
+ const lineByRef = new Map(snapshotLines.flatMap((line) => line.ref ? [[line.ref, line.raw] as const] : []));
240
+ const entries = enrichSnapshotRefEntries(getSnapshotRefEntries(data), snapshotLines);
241
+ const refs = Object.fromEntries(entries.flatMap((entry) => {
242
+ if (!/^e\d+$/.test(entry.id) || entry.role.length === 0) return [];
243
+ const isContentEditable = getEditableRefEvidence({ ref: entry.refData, text: lineByRef.get(entry.id) });
244
+ return [[entry.id, { ...(isContentEditable === true ? { isContentEditable: true } : {}), ...(entry.isEditable !== undefined ? { isEditable: entry.isEditable } : {}), name: entry.name, role: entry.role }] as const];
238
245
  }));
239
246
  return Object.keys(refs).length > 0 ? refs : undefined;
240
247
  }
@@ -310,7 +317,9 @@ function getRestoredRefSnapshot(details: Record<string, unknown>): SessionRefSna
310
317
  ? Object.fromEntries(refIds.flatMap((refId) => {
311
318
  const entry = refRecord[refId];
312
319
  if (!isRecord(entry) || typeof entry.name !== "string" || typeof entry.role !== "string") return [];
313
- return [[refId, { name: entry.name, role: entry.role }] as const];
320
+ const isContentEditable = typeof entry.isContentEditable === "boolean" ? entry.isContentEditable : undefined;
321
+ const isEditable = typeof entry.isEditable === "boolean" ? entry.isEditable : undefined;
322
+ return [[refId, { ...(isContentEditable !== undefined ? { isContentEditable } : {}), ...(isEditable !== undefined ? { isEditable } : {}), name: entry.name, role: entry.role }] as const];
314
323
  }))
315
324
  : undefined;
316
325
  return {
@@ -653,7 +653,7 @@ export function createAgentBrowserWebSearchTool(configState: AgentBrowserConfigS
653
653
  promptSnippet: "Search the live web with Exa or Brave for current or external information.",
654
654
  promptGuidelines: [
655
655
  "Use agent_browser_web_search when live web search would help answer the task, find current external information, or discover candidate URLs for agent_browser.",
656
- "The tool chooses Exa or Brave from configured keys; when both are available, Exa is preferred by default unless webSearch.preferredProvider says otherwise. Use provider only when the user/config calls for a specific provider.",
656
+ "agent_browser_web_search chooses Exa or Brave from configured keys; when both are available, Exa is preferred by default unless webSearch.preferredProvider says otherwise. Use provider only when the user/config calls for a specific provider.",
657
657
  "Prefer agent_browser_web_search over opening a search engine results page with agent_browser when a quick result list is enough; use agent_browser for interaction, DOM, screenshots, or auth.",
658
658
  "Do not issue parallel or repeated agent_browser_web_search calls; use one high-signal query, inspect the results, then only run a focused follow-up if needed. If the provider returns HTTP 429, stop searching and tell the user the API plan/rate limit needs time or a plan change.",
659
659
  "After using agent_browser_web_search, cite result URLs in the final answer when web evidence informed the answer.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.43",
3
+ "version": "0.2.45",
4
4
  "description": "pi extension that exposes agent-browser as a native tool for browser automation",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
@@ -62,9 +62,9 @@
62
62
  "typebox": "*"
63
63
  },
64
64
  "devDependencies": {
65
- "@earendil-works/pi-ai": "^0.78.0",
66
- "@earendil-works/pi-coding-agent": "^0.78.0",
67
- "@earendil-works/pi-tui": "^0.78.0",
65
+ "@earendil-works/pi-ai": "^0.78.1",
66
+ "@earendil-works/pi-coding-agent": "^0.78.1",
67
+ "@earendil-works/pi-tui": "^0.78.1",
68
68
  "@types/node": "^25.6.1",
69
69
  "tsx": "^4.21.0",
70
70
  "typebox": "^1.1.38",
@@ -80,7 +80,7 @@
80
80
  "check:platform-smoke": "node --check platform-smoke.config.mjs && node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/targets.mjs && node --check scripts/platform-smoke/artifacts.mjs && tsx --test test/platform-smoke.test.ts",
81
81
  "smoke:platform": "node scripts/platform-smoke.mjs",
82
82
  "smoke:platform:doctor": "node scripts/platform-smoke.mjs doctor",
83
- "smoke:platform:ubuntu-image": "docker build -t pi-agent-browser-native-platform:node24-agent-browser0.27.1 --build-arg AGENT_BROWSER_VERSION=0.27.1 -f scripts/platform-smoke/linux-image/Dockerfile .",
83
+ "smoke:platform:ubuntu-image": "node scripts/platform-smoke/build-ubuntu-image.mjs",
84
84
  "smoke:platform:macos": "node scripts/platform-smoke.mjs run --target macos",
85
85
  "smoke:platform:ubuntu": "node scripts/platform-smoke.mjs run --target ubuntu",
86
86
  "smoke:platform:windows-native": "node scripts/platform-smoke.mjs run --target windows-native",
@@ -3,16 +3,28 @@
3
3
 
4
4
  import { CAPABILITY_BASELINE } from "./scripts/agent-browser-capability-baseline.mjs";
5
5
 
6
+ export const PLATFORM_SMOKE_AGENT_BROWSER_VERSION = CAPABILITY_BASELINE.targetVersion;
7
+ export const PLATFORM_SMOKE_UBUNTU_IMAGE = `pi-agent-browser-native-platform:node24-agent-browser${PLATFORM_SMOKE_AGENT_BROWSER_VERSION}`;
8
+
6
9
  export default {
7
10
  packageName: "pi-agent-browser-native",
8
11
  artifactRoot: ".artifacts/platform-smoke",
9
12
  requiredTargets: ["macos", "ubuntu", "windows-native"],
10
13
  requiredSuites: ["platform-build", "browser-dogfood-smoke"],
14
+ supportedTargets: ["macos", "ubuntu", "windows-native"],
11
15
  requiredCrabbox: {
12
16
  install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
13
- minVersion: "0.24.0",
17
+ minVersion: "0.26.0",
18
+ },
19
+ macos: {
20
+ host: "localhost",
21
+ port: 22,
22
+ },
23
+ ubuntuContainerImage: PLATFORM_SMOKE_UBUNTU_IMAGE,
24
+ windowsParallels: {
25
+ sourceVm: "pi-extension-windows-template",
26
+ snapshot: "crabbox-ready",
14
27
  },
15
- ubuntuContainerImage: "pi-agent-browser-native-platform:node24-agent-browser0.27.1",
16
28
  nodeValidationMajor: 22,
17
- agentBrowserVersion: CAPABILITY_BASELINE.targetVersion,
29
+ agentBrowserVersion: PLATFORM_SMOKE_AGENT_BROWSER_VERSION,
18
30
  };
@@ -22,6 +22,7 @@ const PACKAGE_NAME = "pi-agent-browser-native";
22
22
  const REPO_URL_FRAGMENT = "github.com/fitchmultz/pi-agent-browser-native";
23
23
  const EXTENSION_ENTRYPOINT = "extensions/agent-browser/index.ts";
24
24
  const EXPECTED_VERSION = CAPABILITY_BASELINE.targetVersion;
25
+ const RECOMMENDED_PI_VERSION = "0.78.1";
25
26
  const DEFAULT_AGENT_DIR = resolve(homedir(), ".pi/agent");
26
27
  const THIS_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
28
 
@@ -29,6 +30,27 @@ export function normalizeAgentBrowserVersion(output) {
29
30
  return String(output ?? "").trim().replace(/^agent-browser\s+/, "");
30
31
  }
31
32
 
33
+ export function normalizePiVersion(output) {
34
+ return String(output ?? "").trim().replace(/^pi\s+/, "");
35
+ }
36
+
37
+ function parseVersionParts(version) {
38
+ const match = String(version ?? "").match(/^(\d+)\.(\d+)\.(\d+)(?:\b|[-+])/);
39
+ if (!match) return undefined;
40
+ return match.slice(1).map((part) => Number.parseInt(part, 10));
41
+ }
42
+
43
+ function versionAtLeast(actual, minimum) {
44
+ const actualParts = parseVersionParts(actual);
45
+ const minimumParts = parseVersionParts(minimum);
46
+ if (!actualParts || !minimumParts) return undefined;
47
+ for (let index = 0; index < minimumParts.length; index += 1) {
48
+ if (actualParts[index] > minimumParts[index]) return true;
49
+ if (actualParts[index] < minimumParts[index]) return false;
50
+ }
51
+ return true;
52
+ }
53
+
32
54
  function printHelp() {
33
55
  console.log(`pi-agent-browser-doctor
34
56
 
@@ -45,7 +67,8 @@ Options:
45
67
  Checks:
46
68
  1. agent-browser is installed on PATH.
47
69
  2. agent-browser --version matches the package capability baseline.
48
- 3. Pi settings and repo-local autoload locations do not point at multiple active pi-agent-browser-native sources.
70
+ 3. pi --version is at least the recommended Pi floor for this release.
71
+ 4. Pi settings and repo-local autoload locations do not point at multiple active pi-agent-browser-native sources.
49
72
 
50
73
  Examples:
51
74
  pi-agent-browser-doctor
@@ -101,6 +124,11 @@ async function defaultRunAgentBrowser(args) {
101
124
  return `${stdout}${stderr}`;
102
125
  }
103
126
 
127
+ async function defaultRunPi(args) {
128
+ const { stdout, stderr } = await execFile("pi", args, { maxBuffer: 1024 * 1024 });
129
+ return `${stdout}${stderr}`;
130
+ }
131
+
104
132
  async function defaultPathExists(path) {
105
133
  try {
106
134
  await access(path);
@@ -270,6 +298,43 @@ async function collectRepoLocalSources({ cwd, pathExists }) {
270
298
  return sources;
271
299
  }
272
300
 
301
+ async function checkPiVersion({ runPi }) {
302
+ try {
303
+ const rawOutput = await runPi(["--version"]);
304
+ const version = normalizePiVersion(rawOutput);
305
+ const supported = versionAtLeast(version, RECOMMENDED_PI_VERSION);
306
+ if (supported === false) {
307
+ return {
308
+ status: "warn",
309
+ title: `Pi ${RECOMMENDED_PI_VERSION} or newer is recommended; found ${version || "<empty>"}.`,
310
+ lines: [
311
+ "This package does not hard-pin Pi 0.78.1, but this release was audited against Pi 0.78.1 extension/package behavior.",
312
+ "Update Pi before release validation or lifecycle debugging if you see tool routing, /reload, exact-session, or package-install differences.",
313
+ ],
314
+ };
315
+ }
316
+ if (supported === undefined) {
317
+ return {
318
+ status: "warn",
319
+ title: `Could not parse pi --version output: ${version || "<empty>"}.`,
320
+ lines: [`Pi ${RECOMMENDED_PI_VERSION} or newer is recommended for this release's validation baseline.`],
321
+ };
322
+ }
323
+ return { status: "pass", title: `Pi version is within the recommended baseline: ${version}`, lines: [] };
324
+ } catch (error) {
325
+ const code = error && typeof error === "object" ? error.code : undefined;
326
+ return {
327
+ status: "warn",
328
+ title: "Could not inspect pi --version.",
329
+ lines: [
330
+ `Pi ${RECOMMENDED_PI_VERSION} or newer is recommended for this release's validation baseline, but it is not hard-pinned as a runtime requirement.`,
331
+ "Make sure the same shell that launches pi can run `pi --version` when debugging lifecycle or package-install behavior.",
332
+ code && code !== "ENOENT" ? `Spawn error: ${String(code)}` : undefined,
333
+ ].filter(Boolean),
334
+ };
335
+ }
336
+ }
337
+
273
338
  async function checkAgentBrowserVersion({ runAgentBrowser }) {
274
339
  try {
275
340
  const rawOutput = await runAgentBrowser(["--version"]);
@@ -358,6 +423,7 @@ export async function evaluateDoctor(options = {}) {
358
423
  const readText = options.readText ?? ((path) => readFile(path, "utf8"));
359
424
  const pathExists = options.pathExists ?? defaultPathExists;
360
425
  const runAgentBrowser = options.runAgentBrowser ?? defaultRunAgentBrowser;
426
+ const runPi = options.runPi ?? defaultRunPi;
361
427
  const checks = [];
362
428
  const failures = [];
363
429
  const warnings = [];
@@ -366,6 +432,9 @@ export async function evaluateDoctor(options = {}) {
366
432
  checks.push(versionCheck);
367
433
  if (versionCheck.status === "fail") failures.push(versionCheck);
368
434
 
435
+ const piVersionCheck = await checkPiVersion({ runPi });
436
+ checks.push(piVersionCheck);
437
+
369
438
  if (!options.skipSourceCheck) {
370
439
  const sourceCheck = await checkPiSources({ cwd, agentDir, settingsPaths, readText, pathExists });
371
440
  checks.push(sourceCheck);
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ import { CAPABILITY_BASELINE } from "../agent-browser-capability-baseline.mjs";
5
+
6
+ const version = CAPABILITY_BASELINE.targetVersion;
7
+ const image = `pi-agent-browser-native-platform:node24-agent-browser${version}`;
8
+ const args = [
9
+ "build",
10
+ "-t",
11
+ image,
12
+ "--build-arg",
13
+ `AGENT_BROWSER_VERSION=${version}`,
14
+ "-f",
15
+ "scripts/platform-smoke/linux-image/Dockerfile",
16
+ ".",
17
+ ];
18
+
19
+ console.log(`Building ${image}`);
20
+ const result = spawnSync("docker", args, { stdio: "inherit" });
21
+ if (result.error) {
22
+ console.error(result.error.message);
23
+ process.exit(1);
24
+ }
25
+ process.exit(result.status ?? 1);
@@ -2,6 +2,10 @@
2
2
 
3
3
  import { spawn } from "node:child_process";
4
4
 
5
+ import { CAPABILITY_BASELINE } from "../agent-browser-capability-baseline.mjs";
6
+
7
+ const DEFAULT_UBUNTU_IMAGE = `pi-agent-browser-native-platform:node24-agent-browser${CAPABILITY_BASELINE.targetVersion}`;
8
+
5
9
  function env(name) {
6
10
  return process.env[name] ?? "";
7
11
  }
@@ -14,49 +18,77 @@ function packageSlug(config = {}) {
14
18
  return process.env.PLATFORM_SMOKE_PACKAGE_SLUG || config.packageName || "pi-agent-browser-native";
15
19
  }
16
20
 
17
- export function buildTargetBaseArgs(targetName, config = {}) {
21
+ export function describeTarget(targetName, config = {}) {
22
+ const slug = packageSlug(config);
18
23
  switch (targetName) {
19
24
  case "macos": {
20
25
  const user = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
21
- const host = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
22
- const workRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${user}/crabbox/${packageSlug(config)}`;
23
- return [
24
- "--provider", "ssh",
25
- "--target", "macos",
26
- "--static-host", host,
27
- "--static-user", user,
28
- "--static-port", "22",
29
- "--static-work-root", workRoot,
30
- ];
26
+ const host = env("PLATFORM_SMOKE_MAC_HOST") || config.macos?.host || "localhost";
27
+ const port = String(env("PLATFORM_SMOKE_MAC_PORT") || config.macos?.port || 22);
28
+ const workRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || config.macos?.workRoot || `/Users/${user}/crabbox/${slug}`;
29
+ return {
30
+ provider: "ssh",
31
+ crabboxTarget: "macos",
32
+ shell: "posix",
33
+ workRoot,
34
+ args: [
35
+ "--provider", "ssh",
36
+ "--target", "macos",
37
+ "--static-host", host,
38
+ "--static-user", user,
39
+ "--static-port", port,
40
+ "--static-work-root", workRoot,
41
+ ],
42
+ };
31
43
  }
32
44
  case "ubuntu": {
33
- const image = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
34
- return [
35
- "--provider", "local-container",
36
- "--target", "linux",
37
- "--local-container-image", image,
38
- ];
45
+ const image = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || DEFAULT_UBUNTU_IMAGE;
46
+ return {
47
+ provider: "local-container",
48
+ crabboxTarget: "linux",
49
+ shell: "posix",
50
+ image,
51
+ workRoot: config.localContainer?.workRoot || "/work/crabbox",
52
+ args: [
53
+ "--provider", "local-container",
54
+ "--target", "linux",
55
+ "--local-container-image", image,
56
+ ],
57
+ };
39
58
  }
40
59
  case "windows-native": {
41
- const vm = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
42
- const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
43
- const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
44
- const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${packageSlug(config)}`;
45
- return [
46
- "--provider", "parallels",
47
- "--target", "windows",
48
- "--windows-mode", "normal",
49
- "--parallels-source", vm,
50
- "--parallels-source-snapshot", snapshot,
51
- "--parallels-user", user,
52
- "--parallels-work-root", workRoot,
53
- ];
60
+ const vm = env("PLATFORM_SMOKE_WINDOWS_VM") || config.windowsParallels?.sourceVm || "pi-extension-windows-template";
61
+ const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || config.windowsParallels?.snapshot || "crabbox-ready";
62
+ const user = env("PLATFORM_SMOKE_WINDOWS_USER") || config.windowsParallels?.user || env("USER");
63
+ const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || config.windowsParallels?.workRoot || `C:\\crabbox\\${slug}`;
64
+ return {
65
+ provider: "parallels",
66
+ crabboxTarget: "windows",
67
+ shell: "powershell",
68
+ workRoot,
69
+ windowsMode: "normal",
70
+ sourceVm: vm,
71
+ snapshot,
72
+ args: [
73
+ "--provider", "parallels",
74
+ "--target", "windows",
75
+ "--windows-mode", "normal",
76
+ "--parallels-source", vm,
77
+ "--parallels-source-snapshot", snapshot,
78
+ "--parallels-user", user,
79
+ "--parallels-work-root", workRoot,
80
+ ],
81
+ };
54
82
  }
55
83
  default:
56
84
  throw new Error(`unknown platform smoke target: ${targetName}`);
57
85
  }
58
86
  }
59
87
 
88
+ export function buildTargetBaseArgs(targetName, config = {}) {
89
+ return describeTarget(targetName, config).args;
90
+ }
91
+
60
92
  export function leaseIdFor(targetName, slug) {
61
93
  if (targetName === "macos") return "static_localhost";
62
94
  return slug;
@@ -4,6 +4,10 @@ import { execFileSync, execSync } from "node:child_process";
4
4
  import { accessSync, constants, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
6
 
7
+ import { CAPABILITY_BASELINE } from "../agent-browser-capability-baseline.mjs";
8
+
9
+ const DEFAULT_UBUNTU_IMAGE = `pi-agent-browser-native-platform:node24-agent-browser${CAPABILITY_BASELINE.targetVersion}`;
10
+
7
11
  function env(name) {
8
12
  return process.env[name] ?? "";
9
13
  }
@@ -105,6 +109,17 @@ function checkForbiddenProjectFiles(failures) {
105
109
  }
106
110
 
107
111
  function crabboxProviders(cbox) {
112
+ const jsonOutput = silent(cbox, ["providers", "--json"]);
113
+ if (jsonOutput) {
114
+ try {
115
+ const parsed = JSON.parse(jsonOutput);
116
+ if (Array.isArray(parsed)) return parsed.map((provider) => provider.name ?? provider.id ?? provider.provider).filter(Boolean);
117
+ if (Array.isArray(parsed.providers)) return parsed.providers.map((provider) => provider.name ?? provider.id ?? provider.provider).filter(Boolean);
118
+ if (typeof parsed === "object" && parsed) return Object.keys(parsed.providers ?? parsed);
119
+ } catch {
120
+ // Fall through to text parsing for older or non-JSON provider output.
121
+ }
122
+ }
108
123
  const output = silent(cbox, ["providers"]);
109
124
  if (!output) return [];
110
125
  return output.split(/\r?\n/)
@@ -209,25 +224,27 @@ export async function runDoctor(config) {
209
224
  console.log("\n── Crabbox providers ──");
210
225
  if (cboxPath) {
211
226
  checkRequiredProviders(cbox, failures);
212
- const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
227
+ const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || DEFAULT_UBUNTU_IMAGE;
213
228
  checkCrabboxProvider(cbox, ["--provider", "local-container", "--local-container-image", ubuntuImage], "ubuntu local-container", failures);
214
229
  const macUser = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
215
- const macHost = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
216
- const macRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${macUser}/crabbox/${packageName}`;
217
- checkCrabboxProvider(cbox, ["--provider", "ssh", "--target", "macos", "--static-host", macHost, "--static-user", macUser, "--static-port", "22", "--static-work-root", macRoot], "macOS ssh", failures);
230
+ const macHost = env("PLATFORM_SMOKE_MAC_HOST") || config?.macos?.host || "localhost";
231
+ const macPort = String(env("PLATFORM_SMOKE_MAC_PORT") || config?.macos?.port || 22);
232
+ const macRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || config?.macos?.workRoot || `/Users/${macUser}/crabbox/${packageName}`;
233
+ checkCrabboxProvider(cbox, ["--provider", "ssh", "--target", "macos", "--static-host", macHost, "--static-user", macUser, "--static-port", macPort, "--static-work-root", macRoot], "macOS ssh", failures);
218
234
  }
219
235
 
220
236
  console.log("\n── Docker / Ubuntu ──");
221
237
  const dockerVersion = shell("docker info --format '{{.ServerVersion}}'");
222
238
  if (dockerVersion) ok(`Docker ${dockerVersion}`);
223
239
  else fail("Docker is not available or not running", failures);
224
- const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
240
+ const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || DEFAULT_UBUNTU_IMAGE;
225
241
  ok(`Ubuntu image: ${ubuntuImage}`);
226
242
 
227
243
  console.log("\n── macOS SSH ──");
228
244
  const sshUser = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
229
- const sshHost = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
230
- const sshProbe = shell(`ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${sshUser}@${sshHost} 'node --version && npm --version && git --version && agent-browser --version'`);
245
+ const sshHost = env("PLATFORM_SMOKE_MAC_HOST") || config?.macos?.host || "localhost";
246
+ const sshPort = String(env("PLATFORM_SMOKE_MAC_PORT") || config?.macos?.port || 22);
247
+ const sshProbe = shell(`ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -p ${sshPort} ${sshUser}@${sshHost} 'node --version && npm --version && git --version && agent-browser --version'`);
231
248
  if (sshProbe) {
232
249
  ok(`SSH ${sshUser}@${sshHost}: ${sshProbe.split(/\r?\n/).join(" | ")}`);
233
250
  if (agentBrowserVersion && !sshProbe.includes(agentBrowserVersion)) fail(`macOS SSH agent-browser does not match expected ${agentBrowserVersion}`, failures);
@@ -241,10 +258,10 @@ export async function runDoctor(config) {
241
258
  fail("prlctl not found", failures);
242
259
  } else {
243
260
  ok("prlctl found");
244
- const vmName = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
245
- const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
246
- const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
247
- const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${packageName}`;
261
+ const vmName = env("PLATFORM_SMOKE_WINDOWS_VM") || config?.windowsParallels?.sourceVm || "pi-extension-windows-template";
262
+ const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || config?.windowsParallels?.snapshot || "crabbox-ready";
263
+ const user = env("PLATFORM_SMOKE_WINDOWS_USER") || config?.windowsParallels?.user || env("USER");
264
+ const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || config?.windowsParallels?.workRoot || `C:\\crabbox\\${packageName}`;
248
265
  const list = shell("prlctl list -a --no-header 2>/dev/null");
249
266
  if (!list) {
250
267
  fail("prlctl list returned no VMs", failures);
@@ -1,12 +1,10 @@
1
1
  # Local Crabbox Ubuntu/Linux target image for pi-agent-browser-native platform smoke.
2
- # Build with:
3
- # docker build -t pi-agent-browser-native-platform:node24-agent-browser0.27.1 \
4
- # --build-arg AGENT_BROWSER_VERSION=0.27.1 \
5
- # -f scripts/platform-smoke/linux-image/Dockerfile .
2
+ # Build with npm run smoke:platform:ubuntu-image so the agent-browser version
3
+ # comes from scripts/agent-browser-capability-baseline.mjs.
6
4
 
7
5
  FROM node:24-bookworm
8
6
 
9
- ARG AGENT_BROWSER_VERSION=0.27.1
7
+ ARG AGENT_BROWSER_VERSION
10
8
 
11
9
  USER root
12
10
  RUN apt-get update \