pi-lean-portal 0.1.0

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 (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * URL Safety Checks
3
+ *
4
+ * Prevents SSRF (Server-Side Request Forgery) and secret exfiltration
5
+ * by validating URLs before navigation.
6
+ *
7
+ * Patterns from hermes-agent's url_safety.py.
8
+ */
9
+
10
+ // ─── Blocked hostnames ───────────────────────────────────────────────
11
+
12
+ /** Internal/private network ranges that should never be accessed */
13
+ const BLOCKED_HOSTNAMES = [
14
+ "localhost",
15
+ "127.0.0.1",
16
+ "0.0.0.0",
17
+ "::1",
18
+ "169.254.169.254", // AWS/GCP metadata endpoint
19
+ "metadata.google.internal",
20
+ "100.100.100.200", // Alibaba Cloud metadata
21
+ ];
22
+
23
+ /** Regex for private IP ranges (10.x, 172.16-31.x, 192.168.x) */
24
+ const PRIVATE_IP_REGEX = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
25
+
26
+ // ─── Blocked URL patterns ─────────────────────────────────────────────
27
+
28
+ /** File-like schemes that shouldn't be followed */
29
+ const BLOCKED_SCHEMES = ["file:", "ftp:", "data:", "javascript:", "vbscript:"];
30
+
31
+ // ─── Secret detection ────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Patterns that look like API keys, tokens, or passwords in URLs.
35
+ * Follows hermes-agent's approach: checks for known key prefixes rather
36
+ * than generic length-based patterns, reducing false positives.
37
+ * Also checks the percent-decoded URL for encoded secrets.
38
+ */
39
+ const SECRET_PATTERNS = [
40
+ // Generic API keys in query params (specific param names)
41
+ /[?&](api[_-]?key|token|secret|password|passwd|auth|credential|private[_-]?key|access[_-]?key)=/i,
42
+ // Bearer tokens in headers (unlikely in URLs but check anyway)
43
+ /Bearer\s+[A-Za-z0-9\-._~+/]{20,}=*/i,
44
+ // GitHub tokens: ghp_, ghs_, ghg_, gho_, ghu_
45
+ /gh[psogu]_[A-Za-z0-9_]{36}/,
46
+ // Slack tokens: xoxb-, xoxp-, xoxa-, xoxs-, xoxr-
47
+ /xox[bpras]-[A-Za-z0-9-]+/,
48
+ // Stripe: sk_live_, sk_test_, pk_live_, pk_test_, whsec_
49
+ /(sk|pk)_(live|test)_[A-Za-z0-9]+/,
50
+ /whsec_[A-Za-z0-9]+/,
51
+ // AWS access key ID (starts with AKIA)
52
+ /AKIA[A-Z0-9]{16}/,
53
+ // Google API keys (AIza prefix)
54
+ /AIza[0-9A-Za-z\-_]{35}/,
55
+ // Discord bot tokens
56
+ /[MN][A-Za-z\d]{23}\.[XZ][A-Za-z\d]{6}\.[A-Za-z\d]{27}/,
57
+ // SendGrid API keys (SG. prefix)
58
+ /SG\.[A-Za-z0-9-_]{22}\.[A-Za-z0-9-_]{43}/,
59
+ // HubSpot API keys
60
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/,
61
+ // Generic secret in URL path segment (e.g., "secret=..." in path)
62
+ /\/secret(?:s)?\//i,
63
+ ];
64
+
65
+ // ─── Types ────────────────────────────────────────────────────────────
66
+
67
+ export interface UrlSafetyResult {
68
+ /** Whether the URL is safe to navigate to */
69
+ safe: boolean;
70
+ /** Human-readable reason if not safe */
71
+ reason?: string;
72
+ /** The specific issue category */
73
+ category?: "ssrf" | "scheme" | "secret" | "malformed";
74
+ }
75
+
76
+ // ─── Validation ───────────────────────────────────────────────────────
77
+
78
+ export function validateUrl(rawUrl: string): UrlSafetyResult {
79
+ let parsed: URL;
80
+ try {
81
+ parsed = new URL(rawUrl);
82
+ } catch {
83
+ return { safe: false, reason: "Malformed URL", category: "malformed" };
84
+ }
85
+
86
+ // Check scheme
87
+ const scheme = parsed.protocol.toLowerCase();
88
+ if (BLOCKED_SCHEMES.includes(scheme)) {
89
+ return {
90
+ safe: false,
91
+ reason: `Blocked scheme: ${scheme}`,
92
+ category: "scheme",
93
+ };
94
+ }
95
+
96
+ if (scheme !== "http:" && scheme !== "https:") {
97
+ return {
98
+ safe: false,
99
+ reason: `Unsupported scheme: ${scheme}. Only http and https are allowed.`,
100
+ category: "scheme",
101
+ };
102
+ }
103
+
104
+ // Check hostname against blocked list
105
+ const hostname = parsed.hostname.toLowerCase();
106
+ for (const blocked of BLOCKED_HOSTNAMES) {
107
+ if (hostname === blocked || hostname.endsWith("." + blocked)) {
108
+ return {
109
+ safe: false,
110
+ reason: `Blocked hostname: ${hostname} (private/internal network)`,
111
+ category: "ssrf",
112
+ };
113
+ }
114
+ }
115
+
116
+ // Check for private IP ranges
117
+ if (PRIVATE_IP_REGEX.test(hostname)) {
118
+ return {
119
+ safe: false,
120
+ reason: `Blocked IP range: ${hostname} (private network)`,
121
+ category: "ssrf",
122
+ };
123
+ }
124
+
125
+ // Check for IPv6 loopback
126
+ if (
127
+ hostname.startsWith("[") &&
128
+ (hostname.includes("::1") || hostname.includes("::ffff:127"))
129
+ ) {
130
+ return {
131
+ safe: false,
132
+ reason: `Blocked IPv6 address: ${hostname} (loopback)`,
133
+ category: "ssrf",
134
+ };
135
+ }
136
+
137
+ // Check for secrets in URL (raw and percent-decoded)
138
+ const urlStr = parsed.toString();
139
+ try {
140
+ const decodedUrl = decodeURIComponent(urlStr);
141
+ for (const pattern of SECRET_PATTERNS) {
142
+ if (pattern.test(urlStr) || pattern.test(decodedUrl)) {
143
+ return {
144
+ safe: false,
145
+ reason: `Potential secret detected in URL. Remove sensitive parameters before navigating.`,
146
+ category: "secret",
147
+ };
148
+ }
149
+ }
150
+ } catch {
151
+ // If decoding fails, just check the raw URL
152
+ for (const pattern of SECRET_PATTERNS) {
153
+ if (pattern.test(urlStr)) {
154
+ return {
155
+ safe: false,
156
+ reason: `Potential secret detected in URL. Remove sensitive parameters before navigating.`,
157
+ category: "secret",
158
+ };
159
+ }
160
+ }
161
+ }
162
+
163
+ return { safe: true };
164
+ }
package/index.ts ADDED
@@ -0,0 +1,253 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import * as router from "./core/router.js";
3
+
4
+ import { cleanupFetchTempFiles } from "./core/fetch-backend.js";
5
+ import { pluginRegistry } from "./core/plugin-registry.js";
6
+ import {
7
+ loadPluginConfig,
8
+ detectPluginType,
9
+ DEFAULT_BACKENDS_ROOT,
10
+ invalidateConfigCache,
11
+ } from "./core/plugin-config.js";
12
+ import { ChromiumPlugin } from "./backends/chromium/index.js";
13
+ import { PythonPluginAdapter } from "./backends/python-adapter.js";
14
+ import type { PythonBridgeConfig } from "./backends/python-adapter.js";
15
+
16
+ import { sessionManager } from "./core/shared/session-manager.js";
17
+ import { removeAllSnapshotFiles } from "./core/shared/snapshot-cache.js";
18
+ import initBrowserToggle from "./browser-toggle.js";
19
+ import { updateFooterStatus, getLastCtx, setLastCtx } from "./tools/utils.js";
20
+ import { deleteSessionKey, resetTaskIds } from "./core/shared/task-id.js";
21
+ import { resetToggleModuleState } from "./browser-toggle.js";
22
+
23
+ // ─── Tool definitions ────────────────────────────────────────────
24
+
25
+ import {
26
+ browserNavigateTool,
27
+ browserSnapshotTool,
28
+ browserClickTool,
29
+ browserTypeTool,
30
+ browserScrollTool,
31
+ browserBackTool,
32
+ browserPressTool,
33
+ browserConsoleTool,
34
+ browserInspectTool,
35
+ webFetchTool,
36
+ webGuideTool,
37
+ webLearnTool,
38
+ } from "./tools/index.js";
39
+
40
+ // ============================================================
41
+ // Extension entry point
42
+ // ============================================================
43
+ export default function (pi: ExtensionAPI) {
44
+ // --- Ensure idempotent re-invocation ----------------------------
45
+ // pi reuses the cached extension factory on /resume (same cwd),
46
+ // which re-invokes this function with the same module-level
47
+ // singletons. Reset them here so the second load is safe.
48
+ pluginRegistry.clear();
49
+ invalidateConfigCache();
50
+ resetTaskIds();
51
+ resetToggleModuleState();
52
+
53
+ // --- Plugin registration ----------------------------------------
54
+ const { plugins: pluginConfigs, errors: configErrors } = loadPluginConfig();
55
+
56
+ // Log config errors
57
+ for (const err of configErrors) {
58
+ console.warn(`[pi-lean-portal] Plugin config error: ${err}`);
59
+ }
60
+
61
+ // ── First pass: collect and validate all configs ──────────────
62
+ // (synchronous — ensures config array order is captured before any async work)
63
+ const validConfigs: Array<{
64
+ config: (typeof pluginConfigs)[number];
65
+ detection: import("./core/plugin-config.js").PluginDetection;
66
+ }> = [];
67
+
68
+ for (const config of pluginConfigs) {
69
+ let detection;
70
+ try {
71
+ detection = detectPluginType(config.dir, DEFAULT_BACKENDS_ROOT);
72
+ } catch (err) {
73
+ console.error(
74
+ `[pi-lean-portal] Plugin '${config.name}' (dir: '${config.dir}'): ${err instanceof Error ? err.message : String(err)}`,
75
+ );
76
+ continue;
77
+ }
78
+ validConfigs.push({ config, detection });
79
+ }
80
+
81
+ // ── Seed registry with config array order ────────────────────
82
+ // This preserves the user's declared priority even when some
83
+ // plugins load asynchronously (Node via dynamic import) while
84
+ // others register synchronously (Python adapter).
85
+ if (validConfigs.length > 0) {
86
+ pluginRegistry.seedOrder(validConfigs.map(({ config }) => config.name));
87
+ } else {
88
+ // Fallback: no valid configs → register default Chromium plugin
89
+ const plugin = new ChromiumPlugin();
90
+ try {
91
+ pluginRegistry.register(plugin, {
92
+ name: "chromium",
93
+ dir: "chromium",
94
+ enabled: true,
95
+ config: {},
96
+ });
97
+ } catch (err) {
98
+ console.error(
99
+ "[pi-lean-portal] Failed to register default Chromium plugin:",
100
+ err,
101
+ );
102
+ }
103
+ plugin.init({}).catch((err: unknown) => {
104
+ console.error(
105
+ "[pi-lean-portal] Failed to init default Chromium plugin:",
106
+ err,
107
+ );
108
+ });
109
+ }
110
+
111
+ // ── Second pass: load and register plugins ───────────────────
112
+ // Node plugins register asynchronously (after dynamic import resolves).
113
+ // Python plugins register synchronously here.
114
+ // The pre-seeded ordering ensures all plugins get the correct priority level
115
+ // regardless of when register() is called.
116
+ for (const { config, detection } of validConfigs) {
117
+ if (detection.type === "node") {
118
+ // Node-based backend — dynamically import the detected plugin
119
+ (async () => {
120
+ try {
121
+ const mod: any = await import(detection.entryPoint);
122
+ const PluginClass: new () => import("./core/plugin-api.js").BrowserPlugin =
123
+ mod.default;
124
+ if (!PluginClass || typeof PluginClass !== "function") {
125
+ throw new Error(
126
+ `Plugin '${config.name}' (${detection.entryPoint}) must export a default class implementing BrowserPlugin`,
127
+ );
128
+ }
129
+ const plugin = new PluginClass();
130
+ pluginRegistry.register(plugin, config);
131
+ plugin.init?.(config.config).catch((err: unknown) => {
132
+ console.error(
133
+ `[pi-lean-portal] Failed to init plugin '${config.name}':`,
134
+ err,
135
+ );
136
+ });
137
+ } catch (err: unknown) {
138
+ console.error(
139
+ `[pi-lean-portal] Failed to load Node plugin '${config.name}' (dir: '${config.dir}'): ${err instanceof Error ? err.message : String(err)}`,
140
+ );
141
+ }
142
+ })();
143
+ } else if (detection.type === "python") {
144
+ // Python-based backend via JSON-RPC bridge
145
+ const bridgeConfig: PythonBridgeConfig = {
146
+ bridgeScript: detection.entryPoint,
147
+ };
148
+ // Merge any user-provided config overrides
149
+ if (config.config) {
150
+ const userConfig = config.config as Partial<PythonBridgeConfig>;
151
+ if (userConfig.pythonPath)
152
+ bridgeConfig.pythonPath = userConfig.pythonPath;
153
+ if (userConfig.pythonArgs)
154
+ bridgeConfig.pythonArgs = userConfig.pythonArgs;
155
+ if (userConfig.capabilities)
156
+ bridgeConfig.capabilities = userConfig.capabilities;
157
+ if (userConfig.transportTimeoutMs)
158
+ bridgeConfig.transportTimeoutMs = userConfig.transportTimeoutMs;
159
+ }
160
+ const adapter = new PythonPluginAdapter(config.name, bridgeConfig);
161
+ try {
162
+ pluginRegistry.register(adapter, config);
163
+ } catch (err) {
164
+ console.error(
165
+ `[pi-lean-portal] Failed to register Python plugin '${config.name}':`,
166
+ err,
167
+ );
168
+ }
169
+ adapter.init(config.config).catch((err: unknown) => {
170
+ console.error(
171
+ `[pi-lean-portal] Failed to init Python plugin '${config.name}':`,
172
+ err,
173
+ );
174
+ });
175
+ } else {
176
+ // Exhaustiveness guard — PluginType is currently "node" | "python"
177
+ const _exhaustive: never = detection.type;
178
+ console.warn(
179
+ `[pi-lean-portal] Plugin '${config.name}' has unknown type '${_exhaustive as string}'.`,
180
+ );
181
+ }
182
+ }
183
+
184
+ // --- Register tools ---------------------------------------------
185
+ pi.registerTool(webFetchTool);
186
+ pi.registerTool(browserNavigateTool);
187
+ pi.registerTool(browserSnapshotTool);
188
+ pi.registerTool(browserClickTool);
189
+ pi.registerTool(browserTypeTool);
190
+ pi.registerTool(browserScrollTool);
191
+ pi.registerTool(browserBackTool);
192
+ pi.registerTool(browserPressTool);
193
+ pi.registerTool(browserConsoleTool);
194
+ pi.registerTool(browserInspectTool);
195
+ pi.registerTool(webGuideTool);
196
+ pi.registerTool(webLearnTool);
197
+
198
+ // --- Register commands ------------------------------------------
199
+ initBrowserToggle(pi);
200
+
201
+ // --- Profile change callback for TUI status updates ------------
202
+ router.setOnProfileChange((taskId, profileName, profileMode) => {
203
+ // Update TUI status bar on any profile lifecycle event
204
+ const lastCtx = getLastCtx();
205
+ if (lastCtx) {
206
+ updateFooterStatus(lastCtx);
207
+ }
208
+
209
+ // Debug logging when BROWSER_DEBUG is set
210
+ if (process.env.BROWSER_DEBUG) {
211
+ const parts = [`[browser] profile_changed: task=${taskId}`];
212
+ if (profileName) parts.push(`profile=${profileName}`);
213
+ if (profileMode) parts.push(`mode=${profileMode}`);
214
+ console.error(parts.join(" "));
215
+ }
216
+ });
217
+
218
+ // --- Startup ----------------------------------------------------
219
+ pi.on("session_start", async (_event, ctx) => {
220
+ const pluginNames = pluginRegistry.available().join(", ");
221
+ ctx.ui.notify(
222
+ `🌐 Browser extension loaded (plugins: ${pluginNames}). Try: web-fetch for static pages or browser-navigate for interactive browsing.`,
223
+ "info",
224
+ );
225
+ updateFooterStatus(ctx);
226
+ });
227
+
228
+ // --- Cleanup ----------------------------------------------------
229
+ pi.on("session_shutdown", async (_event, ctx) => {
230
+ setLastCtx(null);
231
+ const piSessionId = (ctx as any)?.sessionManager?.getSessionId?.();
232
+ if (piSessionId) {
233
+ deleteSessionKey(piSessionId);
234
+ // Per-conversation fetch cleanup — prevents cross-conversation eviction
235
+ const tid = sessionManager.getTaskIdForPiSessionId(piSessionId);
236
+ if (tid) cleanupFetchTempFiles(tid);
237
+ }
238
+
239
+ // Clean up all registered plugins
240
+ const ordered = pluginRegistry.getOrdered();
241
+ for (const { plugin } of ordered) {
242
+ await plugin.cleanupAll().catch(() => {});
243
+ }
244
+
245
+ await sessionManager.removeAll();
246
+ removeAllSnapshotFiles();
247
+ try {
248
+ ctx?.ui?.setStatus?.("browser", "");
249
+ } catch {
250
+ // ctx.ui may not be available during shutdown
251
+ }
252
+ });
253
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "pi-lean-portal",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension. Interactive web browsing for Pi — Playwright Chromium/Firefox, accessibility-tree snapshots, profiles, cookies, guides. Owns the /web command.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "browser",
9
+ "playwright",
10
+ "web"
11
+ ],
12
+ "license": "AGPL-3.0-only",
13
+ "author": "",
14
+ "type": "module",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/coreyryanhanson/pi-lean-dimension.git",
18
+ "directory": "packages/pi-lean-portal"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "files": [
24
+ "index.ts",
25
+ "browser-toggle.ts",
26
+ "browser-profile.ts",
27
+ "browser-cookies.ts",
28
+ "browser-status.ts",
29
+ "LICENSE",
30
+ "backends/",
31
+ "!backends/python-base/.venv/",
32
+ "!backends/python-base/**/__pycache__/",
33
+ "!backends/python-base/**/*.pyc",
34
+ "!backends/python-base/tests/",
35
+ "!backends/python-base/**/*.egg-info/",
36
+ "core/",
37
+ "tools/",
38
+ "verify-ship-manifest.ts",
39
+ ".npmrc",
40
+ "README.md",
41
+ "ship-manifest.test.ts"
42
+ ],
43
+ "pi": {
44
+ "extensions": [
45
+ "./index.ts"
46
+ ]
47
+ },
48
+ "scripts": {
49
+ "prepack": "cp ../../LICENSE ./LICENSE",
50
+ "test": "vitest run"
51
+ },
52
+ "dependencies": {
53
+ "node-html-parser": "^6.1.0",
54
+ "playwright": "^1.60.0",
55
+ "turndown": "^7.2.0"
56
+ },
57
+ "peerDependencies": {
58
+ "@earendil-works/pi-ai": "*",
59
+ "@earendil-works/pi-coding-agent": "*",
60
+ "@earendil-works/pi-tui": "*",
61
+ "typebox": "*"
62
+ }
63
+ }
@@ -0,0 +1,12 @@
1
+ import { verifyShipManifest } from "./verify-ship-manifest.js";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ describe("publish manifest", () => {
5
+ it("`package.json` `files` array covers every production .ts module across the tree", () => {
6
+ expect(verifyShipManifest(import.meta.url).missing).toEqual([]);
7
+ });
8
+
9
+ it("every `files` entry points at something on disk — a stale entry ships nothing", () => {
10
+ expect(verifyShipManifest(import.meta.url).stale).toEqual([]);
11
+ });
12
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * browser-back tool definition.
3
+ */
4
+
5
+ import { defineTool } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "@earendil-works/pi-ai";
7
+ import { Text } from "@earendil-works/pi-tui";
8
+ import * as router from "../core/router.js";
9
+ import { executeInteractionTool } from "./utils.js";
10
+
11
+ export const browserBackTool = defineTool({
12
+ name: "browser-back",
13
+ label: "Go Back",
14
+ description: "Navigate back in browser history.",
15
+ parameters: Type.Object({}),
16
+
17
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
18
+ return executeInteractionTool(
19
+ ctx,
20
+ "Go back",
21
+ (tid) => router.goBack(tid),
22
+ (result) => ({
23
+ message: `Went back to: ${result.newUrl || "?"}`,
24
+ details: {
25
+ newUrl: result.newUrl,
26
+ newTitle: result.newTitle,
27
+ elementCount: result.elementCount,
28
+ },
29
+ }),
30
+ );
31
+ },
32
+
33
+ renderCall(_args, theme, _context) {
34
+ return new Text(theme.fg("toolTitle", theme.bold("browser-back")), 0, 0);
35
+ },
36
+
37
+ renderResult(result, _options, theme, _context) {
38
+ const d = result.details as Record<string, unknown> | undefined;
39
+ if (d?.error) return new Text(theme.fg("error", "Go back failed"), 0, 0);
40
+ const ec = d?.elementCount as number | undefined;
41
+ return new Text(
42
+ theme.fg(
43
+ "dim",
44
+ `← ${(d?.newUrl as string) || ""}${ec !== undefined ? ` · ${ec} elements` : ""}`,
45
+ ),
46
+ 0,
47
+ 0,
48
+ );
49
+ },
50
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * browser-click tool definition.
3
+ */
4
+
5
+ import { defineTool } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "@earendil-works/pi-ai";
7
+ import { Text } from "@earendil-works/pi-tui";
8
+ import * as router from "../core/router.js";
9
+ import { executeInteractionTool } from "./utils.js";
10
+
11
+ export const browserClickTool = defineTool({
12
+ name: "browser-click",
13
+ label: "Click Element",
14
+ description:
15
+ "Click an element on the page by its @e reference ID (e.g., @e5). " +
16
+ "Use element references from browser-navigate or browser-snapshot output.",
17
+ parameters: Type.Object({
18
+ ref: Type.String({
19
+ description: "Element reference like @e5 (from the accessibility tree)",
20
+ }),
21
+ }),
22
+
23
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
24
+ const { ref } = params as { ref: string };
25
+ return executeInteractionTool(
26
+ ctx,
27
+ "Click",
28
+ (tid) => router.click(tid, ref),
29
+ (result) => ({
30
+ message: [
31
+ `Clicked ${ref}`,
32
+ result.newUrl ? `URL: ${result.newUrl}` : "",
33
+ result.newTitle ? `Title: ${result.newTitle}` : "",
34
+ ]
35
+ .filter(Boolean)
36
+ .join("\n"),
37
+ details: {
38
+ newUrl: result.newUrl,
39
+ newTitle: result.newTitle,
40
+ elementCount: result.elementCount,
41
+ },
42
+ }),
43
+ );
44
+ },
45
+
46
+ renderCall(args, theme, _context) {
47
+ return new Text(
48
+ `${theme.fg("toolTitle", theme.bold("browser-click"))} ${theme.fg("accent", args.ref)}`,
49
+ 0,
50
+ 0,
51
+ );
52
+ },
53
+
54
+ renderResult(result, _options, theme, _context) {
55
+ const d = result.details as Record<string, unknown> | undefined;
56
+ if (d?.error)
57
+ return new Text(theme.fg("error", `Click failed: ${d.error}`), 0, 0);
58
+ const newUrl = d?.newUrl as string | undefined;
59
+ const ec = d?.elementCount as number | undefined;
60
+ if (newUrl) {
61
+ let text = theme.fg("success", `✅ → ${newUrl}`);
62
+ if (ec !== undefined) text += ` · ${ec} elements`;
63
+ return new Text(text, 0, 0);
64
+ }
65
+ return new Text(
66
+ theme.fg(
67
+ "success",
68
+ `✅ clicked${ec !== undefined ? ` · ${ec} elements` : ""}`,
69
+ ),
70
+ 0,
71
+ 0,
72
+ );
73
+ },
74
+ });