pi-feats 0.1.2 → 0.1.4

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 (122) hide show
  1. package/README.md +24 -1
  2. package/THIRD_PARTY_NOTICES.md +12 -0
  3. package/assets/pi-feats-preview.png +0 -0
  4. package/extensions/api-server/application-log-store.ts +31 -0
  5. package/extensions/api-server/application-runtime.ts +80 -18
  6. package/extensions/api-server/application-store.ts +6 -2
  7. package/extensions/api-server/profile-store.ts +47 -20
  8. package/extensions/api-server/server.ts +182 -47
  9. package/extensions/browser-harness/cdp/commands.ts +260 -0
  10. package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
  11. package/extensions/browser-harness/cdp/discovery.ts +198 -0
  12. package/extensions/browser-harness/cdp/errors.ts +23 -0
  13. package/extensions/browser-harness/cdp/events.ts +170 -0
  14. package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
  15. package/extensions/browser-harness/cdp/ownership.ts +73 -0
  16. package/extensions/browser-harness/cdp/record-store.ts +61 -0
  17. package/extensions/browser-harness/cdp/session.ts +403 -0
  18. package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
  19. package/extensions/browser-harness/cdp/target-factory.ts +153 -0
  20. package/extensions/browser-harness/cdp/types.ts +82 -0
  21. package/extensions/browser-harness/client.ts +366 -0
  22. package/extensions/browser-harness/daemon/bridge.ts +531 -0
  23. package/extensions/browser-harness/daemon/index.ts +86 -0
  24. package/extensions/browser-harness/daemon/protocol.ts +110 -0
  25. package/extensions/browser-harness/daemon/queue.ts +85 -0
  26. package/extensions/browser-harness/daemon/server.ts +170 -0
  27. package/extensions/browser-harness/daemon/spawn.ts +124 -0
  28. package/extensions/browser-harness/daemon/transport.ts +178 -0
  29. package/extensions/browser-harness/domains/ax-tree.ts +179 -0
  30. package/extensions/browser-harness/domains/box.ts +51 -0
  31. package/extensions/browser-harness/domains/cdp-call.ts +55 -0
  32. package/extensions/browser-harness/domains/click.ts +97 -0
  33. package/extensions/browser-harness/domains/console.ts +219 -0
  34. package/extensions/browser-harness/domains/dialog.ts +34 -0
  35. package/extensions/browser-harness/domains/drag.ts +67 -0
  36. package/extensions/browser-harness/domains/element-call.ts +117 -0
  37. package/extensions/browser-harness/domains/files.ts +219 -0
  38. package/extensions/browser-harness/domains/fill-engine.ts +80 -0
  39. package/extensions/browser-harness/domains/form-batch.ts +65 -0
  40. package/extensions/browser-harness/domains/form.ts +283 -0
  41. package/extensions/browser-harness/domains/headers.ts +89 -0
  42. package/extensions/browser-harness/domains/history.ts +80 -0
  43. package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
  44. package/extensions/browser-harness/domains/js.ts +241 -0
  45. package/extensions/browser-harness/domains/keyboard.ts +197 -0
  46. package/extensions/browser-harness/domains/navigate.ts +161 -0
  47. package/extensions/browser-harness/domains/network.ts +301 -0
  48. package/extensions/browser-harness/domains/page.ts +154 -0
  49. package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
  50. package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
  51. package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
  52. package/extensions/browser-harness/domains/render.ts +43 -0
  53. package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
  54. package/extensions/browser-harness/domains/screenshot.ts +76 -0
  55. package/extensions/browser-harness/domains/scroll.ts +59 -0
  56. package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
  57. package/extensions/browser-harness/domains/search/web-search.ts +144 -0
  58. package/extensions/browser-harness/domains/setup.ts +35 -0
  59. package/extensions/browser-harness/domains/snapshot.ts +256 -0
  60. package/extensions/browser-harness/domains/tabs.ts +202 -0
  61. package/extensions/browser-harness/domains/target.ts +35 -0
  62. package/extensions/browser-harness/domains/viewport.ts +42 -0
  63. package/extensions/browser-harness/index.ts +88 -0
  64. package/extensions/browser-harness/profile/bind.ts +90 -0
  65. package/extensions/browser-harness/profile/browser-process.ts +320 -0
  66. package/extensions/browser-harness/profile/command.ts +189 -0
  67. package/extensions/browser-harness/profile/launch.ts +80 -0
  68. package/extensions/browser-harness/profile/list.ts +131 -0
  69. package/extensions/browser-harness/profile/paths.ts +103 -0
  70. package/extensions/browser-harness/profile/store.ts +84 -0
  71. package/extensions/browser-harness/prompt.ts +16 -0
  72. package/extensions/browser-harness/registry.ts +80 -0
  73. package/extensions/browser-harness/schemas/common.ts +11 -0
  74. package/extensions/browser-harness/schemas/parse.ts +23 -0
  75. package/extensions/browser-harness/setup.ts +47 -0
  76. package/extensions/browser-harness/state.ts +51 -0
  77. package/extensions/browser-harness/steel.ts +215 -0
  78. package/extensions/browser-harness/util/debug.ts +8 -0
  79. package/extensions/browser-harness/util/guards.ts +30 -0
  80. package/extensions/browser-harness/util/js-template.ts +12 -0
  81. package/extensions/browser-harness/util/mutex.ts +20 -0
  82. package/extensions/browser-harness/util/paths.ts +31 -0
  83. package/extensions/browser-harness/util/result.ts +9 -0
  84. package/extensions/browser-harness/util/sharp-shim.ts +42 -0
  85. package/extensions/browser-harness/util/tool.ts +185 -0
  86. package/extensions/browser-harness/util/truncate.ts +41 -0
  87. package/extensions/cli-resources.ts +35 -16
  88. package/extensions/composio/index.ts +163 -0
  89. package/extensions/context-memory/index.ts +58 -0
  90. package/extensions/lib/application-context.ts +22 -0
  91. package/extensions/lib/application-handler-templates.ts +17 -1
  92. package/extensions/lib/context-memory.ts +165 -0
  93. package/extensions/lib/profile-sandbox.ts +3 -0
  94. package/extensions/lib/workflow-harness.ts +238 -0
  95. package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
  96. package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
  97. package/extensions/pi-console-webui/app/layout.tsx +6 -1
  98. package/extensions/pi-console-webui/app/login/page.tsx +5 -9
  99. package/extensions/pi-console-webui/app/not-found.tsx +5 -0
  100. package/extensions/pi-console-webui/app/page.tsx +6 -0
  101. package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
  102. package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
  103. package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
  104. package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
  105. package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
  106. package/extensions/pi-console-webui/components/console-page.tsx +3 -1
  107. package/extensions/pi-console-webui/components/console.tsx +53 -7
  108. package/extensions/pi-console-webui/components/login-form.tsx +14 -0
  109. package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
  110. package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
  111. package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
  112. package/extensions/profiles.ts +18 -7
  113. package/extensions/pulse/store.ts +5 -2
  114. package/extensions/sequential-workflow.ts +340 -256
  115. package/extensions/skill-sources/store.ts +24 -3
  116. package/licenses/pi-browser-harness-MIT.txt +21 -0
  117. package/package.json +25 -4
  118. package/schemas/sequential-workflow-template.schema.json +62 -0
  119. package/skills/browser-harness/SKILL.md +33 -0
  120. package/skills/composio-connections/SKILL.md +75 -0
  121. package/skills/sequential-workflow/SKILL.md +70 -0
  122. package/skills/sequential-workflow-template/SKILL.md +44 -0
@@ -0,0 +1,179 @@
1
+ import type { AxNodeResult } from "../cdp/commands";
2
+ import type { BrowserClient } from "../client";
3
+ import type { Box } from "./box";
4
+ import { cdpCall } from "./cdp-call";
5
+
6
+ export type RawAxNode = AxNodeResult;
7
+ type AxValue = NonNullable<RawAxNode["role"]>;
8
+
9
+ export type SlimNode = {
10
+ role: string;
11
+ name?: string;
12
+ value?: string;
13
+ description?: string;
14
+ state?: string;
15
+ children: SlimNode[];
16
+ _backendId?: number;
17
+ ref?: string;
18
+ box?: Box;
19
+ };
20
+
21
+ export const INTERACTIVE_ROLES = new Set([
22
+ "button",
23
+ "link",
24
+ "textbox",
25
+ "checkbox",
26
+ "radio",
27
+ "combobox",
28
+ "menuitem",
29
+ "tab",
30
+ "switch",
31
+ "slider",
32
+ "searchbox",
33
+ "spinbutton",
34
+ ]);
35
+
36
+ // The AX `value` is stale for freshly-typed or controlled inputs, so for these roles the live DOM property is read instead.
37
+ export const VALUE_ROLES = new Set([
38
+ "textbox",
39
+ "searchbox",
40
+ "spinbutton",
41
+ "combobox",
42
+ "checkbox",
43
+ "radio",
44
+ "switch",
45
+ "slider",
46
+ ]);
47
+
48
+ const stringOf = (v: AxValue | undefined): string | undefined => {
49
+ const x = v?.value;
50
+ if (typeof x === "string" && x !== "") return x;
51
+ if (typeof x === "number") return String(x);
52
+ return undefined;
53
+ };
54
+
55
+ const collectState = (props: RawAxNode["properties"]): string | undefined => {
56
+ if (!props || props.length === 0) return undefined;
57
+ const flags: string[] = [];
58
+ for (const p of props) {
59
+ const name = p.name;
60
+ if (!name) continue;
61
+ const raw = p.value?.value;
62
+ if (raw === true && (name === "focused" || name === "required" || name === "disabled" || name === "checked" || name === "expanded" || name === "selected" || name === "pressed" || name === "modal")) {
63
+ flags.push(name);
64
+ }
65
+ if (typeof raw === "number" && name === "level") flags.push(`level ${raw}`);
66
+ }
67
+ return flags.length > 0 ? flags.join(", ") : undefined;
68
+ };
69
+
70
+ export const buildTree = (
71
+ rawNodes: ReadonlyArray<RawAxNode>,
72
+ opts: { interestingOnly: boolean; maxNodes: number },
73
+ ): SlimNode[] => {
74
+ const byId = new Map<string, RawAxNode>();
75
+ for (const n of rawNodes) byId.set(n.nodeId, n);
76
+
77
+ const rootIds: string[] = [];
78
+ for (const n of rawNodes) {
79
+ if (!n.parentId || !byId.has(n.parentId)) rootIds.push(n.nodeId);
80
+ }
81
+
82
+ let budget = opts.maxNodes;
83
+
84
+ const slim = (node: RawAxNode): SlimNode | undefined => {
85
+ if (budget <= 0) return undefined;
86
+ if (opts.interestingOnly && node.ignored) {
87
+ // Chrome often ignores wrapper divs while their descendants remain meaningful, so recurse through an ignored node's children.
88
+ const out: SlimNode[] = [];
89
+ for (const cid of node.childIds ?? []) {
90
+ const child = byId.get(cid);
91
+ if (!child) continue;
92
+ const slimChild = slim(child);
93
+ if (slimChild) out.push(slimChild);
94
+ }
95
+ return out.length > 0 ? { role: "_hoist", children: out } : undefined;
96
+ }
97
+ budget--;
98
+ const role = stringOf(node.role) ?? "unknown";
99
+ const name = stringOf(node.name);
100
+ const value = stringOf(node.value);
101
+ const description = stringOf(node.description);
102
+ const state = collectState(node.properties);
103
+ const children: SlimNode[] = [];
104
+ for (const cid of node.childIds ?? []) {
105
+ const child = byId.get(cid);
106
+ if (!child) continue;
107
+ const slimChild = slim(child);
108
+ if (!slimChild) continue;
109
+ if (slimChild.role === "_hoist") children.push(...slimChild.children);
110
+ else children.push(slimChild);
111
+ }
112
+ const out: SlimNode = { role, children };
113
+ if (name !== undefined) out.name = name;
114
+ if (value !== undefined) out.value = value;
115
+ if (description !== undefined) out.description = description;
116
+ if (state !== undefined) out.state = state;
117
+ if (node.backendDOMNodeId !== undefined) out._backendId = node.backendDOMNodeId;
118
+ return out;
119
+ };
120
+
121
+ const result: SlimNode[] = [];
122
+ for (const rid of rootIds) {
123
+ const root = byId.get(rid);
124
+ if (!root) continue;
125
+ const s = slim(root);
126
+ if (!s) continue;
127
+ if (s.role === "_hoist") result.push(...s.children);
128
+ else result.push(s);
129
+ }
130
+ return result;
131
+ };
132
+
133
+ export const countNodes = (nodes: ReadonlyArray<SlimNode>): number => {
134
+ let n = 0;
135
+ for (const node of nodes) n += 1 + countNodes(node.children);
136
+ return n;
137
+ };
138
+
139
+ export const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
140
+ nodes.map((n) => {
141
+ const { _backendId: _bid, ...rest } = n;
142
+ return { ...rest, children: stripInternals(n.children) };
143
+ });
144
+
145
+ export const collectInteractiveTargets = (
146
+ nodes: ReadonlyArray<SlimNode>,
147
+ ): Array<{ node: SlimNode; backendId: number }> => {
148
+ const out: Array<{ node: SlimNode; backendId: number }> = [];
149
+ const walk = (ns: ReadonlyArray<SlimNode>): void => {
150
+ for (const n of ns) {
151
+ if (n._backendId !== undefined && INTERACTIVE_ROLES.has(n.role)) {
152
+ out.push({ node: n, backendId: n._backendId });
153
+ }
154
+ walk(n.children);
155
+ }
156
+ };
157
+ walk(nodes);
158
+ return out;
159
+ };
160
+
161
+ export const liveValue = async (client: BrowserClient, backendId: number): Promise<string | undefined> => {
162
+ const resolved = await cdpCall(client, "DOM.resolveNode", { backendNodeId: backendId });
163
+ if (!resolved.success) return undefined;
164
+ const objectId = resolved.data.object.objectId;
165
+ if (!objectId) return undefined;
166
+ const fn = `function () {
167
+ if (this.type === "checkbox" || this.type === "radio") return this.checked ? "checked" : "unchecked";
168
+ if (typeof this.value === "string") return this.value;
169
+ return null;
170
+ }`;
171
+ const r = await cdpCall(client, "Runtime.callFunctionOn", {
172
+ objectId,
173
+ functionDeclaration: fn,
174
+ returnByValue: true,
175
+ });
176
+ if (!r.success) return undefined;
177
+ const value = r.data.result.value;
178
+ return typeof value === "string" ? value : undefined;
179
+ };
@@ -0,0 +1,51 @@
1
+ import type { BrowserClient } from "../client";
2
+ import { type Result, err, ok } from "../util/result";
3
+ import type { ToolErr } from "../util/tool";
4
+ import { cdpCall } from "./cdp-call";
5
+
6
+ export type Box = {
7
+ readonly x: number;
8
+ readonly y: number;
9
+ readonly width: number;
10
+ readonly height: number;
11
+ readonly cx: number;
12
+ readonly cy: number;
13
+ };
14
+
15
+ type BoxModel = {
16
+ readonly content: ReadonlyArray<number>;
17
+ readonly width: number;
18
+ readonly height: number;
19
+ };
20
+
21
+ export const centreOf = (model: BoxModel): Box => {
22
+ const x = model.content[0] ?? 0;
23
+ const y = model.content[1] ?? 0;
24
+ return {
25
+ x: Math.round(x),
26
+ y: Math.round(y),
27
+ width: Math.round(model.width),
28
+ height: Math.round(model.height),
29
+ cx: Math.round(x + model.width / 2),
30
+ cy: Math.round(y + model.height / 2),
31
+ };
32
+ };
33
+
34
+ export const boxOf = async (
35
+ client: BrowserClient,
36
+ backendNodeId: number,
37
+ ): Promise<Result<Box, ToolErr>> => {
38
+ const r = await cdpCall(client, "DOM.getBoxModel", { backendNodeId });
39
+ if (!r.success) return r;
40
+ const model = r.data.model;
41
+ if (model.content.length < 8) {
42
+ return err({ kind: "invalid_state", message: "DOM.getBoxModel returned an incomplete content quad" });
43
+ }
44
+ if (model.width <= 0 || model.height <= 0) {
45
+ return err({
46
+ kind: "invalid_state",
47
+ message: "element has a zero-size box and cannot be clicked",
48
+ });
49
+ }
50
+ return ok(centreOf(model));
51
+ };
@@ -0,0 +1,55 @@
1
+ import type { BrowserClient } from "../client";
2
+ import type { CdpMethod, ParamsOf, ResultOf } from "../cdp/commands";
3
+ import type { CdpError } from "../cdp/errors";
4
+ import { type Result, err } from "../util/result";
5
+ import type { ToolErr } from "../util/tool";
6
+
7
+ export const cdpErrToToolErr = (e: CdpError, method: string): ToolErr => ({
8
+ kind: e.kind === "timeout" ? "timeout" : "cdp_error",
9
+ message: e.message,
10
+ details: { method },
11
+ });
12
+
13
+ export const cdpCall = async <M extends CdpMethod>(
14
+ client: BrowserClient,
15
+ method: M,
16
+ params: ParamsOf<M>,
17
+ opts?: { timeoutMs?: number },
18
+ ): Promise<Result<ResultOf<M>, ToolErr>> => {
19
+ const r = await client.session().call(method, params, opts);
20
+ if (!r.success) return err(cdpErrToToolErr(r.error, method));
21
+ return r;
22
+ };
23
+
24
+ export const cdpCallOnTarget = async <M extends CdpMethod>(
25
+ client: BrowserClient,
26
+ method: M,
27
+ params: ParamsOf<M>,
28
+ sessionId: string,
29
+ opts?: { timeoutMs?: number },
30
+ ): Promise<Result<ResultOf<M>, ToolErr>> => {
31
+ const r = await client.session().callOnTarget(method, params, sessionId, opts);
32
+ if (!r.success) return err(cdpErrToToolErr(r.error, method));
33
+ return r;
34
+ };
35
+
36
+ export const cdpCallBrowser = async <M extends CdpMethod>(
37
+ client: BrowserClient,
38
+ method: M,
39
+ params?: ParamsOf<M>,
40
+ opts?: { timeoutMs?: number },
41
+ ): Promise<Result<ResultOf<M>, ToolErr>> => {
42
+ const r = await client.session().callBrowser(method, params, opts);
43
+ if (!r.success) return err(cdpErrToToolErr(r.error, method));
44
+ return r;
45
+ };
46
+
47
+ export const evalJs = async (
48
+ client: BrowserClient,
49
+ expression: string,
50
+ sessionId?: string,
51
+ ): Promise<Result<unknown, ToolErr>> => {
52
+ const r = await client.evaluateJs(expression, sessionId);
53
+ if (!r.success) return err(cdpErrToToolErr(r.error, "Runtime.evaluate"));
54
+ return r;
55
+ };
@@ -0,0 +1,97 @@
1
+ import { Type } from "typebox";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import type { BrowserClient } from "../client";
4
+ import { Coords, MouseButton } from "../schemas/common";
5
+ import { type Result, ok } from "../util/result";
6
+ import { debugClicksEnabled } from "../util/debug";
7
+ import { defineBrowserTool, type ToolErr } from "../util/tool";
8
+ import { cdpCall } from "./cdp-call";
9
+ import { captureWithCrosshair } from "./screenshot-capture";
10
+ import { interactiveDiff } from "./ref-resolve";
11
+ import { resolveTarget } from "./target";
12
+
13
+ const ClickArgs = Type.Object({
14
+ ref: Type.Optional(
15
+ Type.String({
16
+ description:
17
+ "Stable element ref from browser_snapshot (e.g. 'e12'). PREFERRED over x/y — survives re-renders. When set, x/y are ignored.",
18
+ }),
19
+ ),
20
+ x: Type.Optional(Coords.properties.x),
21
+ y: Type.Optional(Coords.properties.y),
22
+ button: Type.Optional(MouseButton),
23
+ count: Type.Optional(
24
+ Type.Integer({
25
+ minimum: 1,
26
+ maximum: 3,
27
+ default: 1,
28
+ description: "Number of clicks (1 = single, 2 = double). Default: 1",
29
+ }),
30
+ ),
31
+ });
32
+
33
+ const dispatchClick = async (
34
+ client: BrowserClient,
35
+ x: number,
36
+ y: number,
37
+ button: "left" | "right" | "middle",
38
+ count: number,
39
+ ): Promise<Result<void, ToolErr>> => {
40
+ // Some focus/hover handlers and React synthetic events only fire when a mousemove precedes the press, so without it a click lands without focusing.
41
+ const moved = await cdpCall(client, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
42
+ if (!moved.success) return moved;
43
+ const pressed = await cdpCall(client, "Input.dispatchMouseEvent", {
44
+ type: "mousePressed", x, y, button, clickCount: count,
45
+ });
46
+ if (!pressed.success) return pressed;
47
+ const released = await cdpCall(client, "Input.dispatchMouseEvent", {
48
+ type: "mouseReleased", x, y, button, clickCount: count,
49
+ });
50
+ if (!released.success) return released;
51
+ return ok(undefined);
52
+ };
53
+
54
+ export const clickTool = defineBrowserTool({
55
+ name: "browser_click",
56
+ label: "Browser Click",
57
+ description:
58
+ "Click an element. PREFERRED: pass `ref` (e.g. 'e12') from browser_snapshot — it re-resolves the element's position at click time, so it works even after the page re-renders and moves things. Fallback: pass viewport CSS-pixel `x`/`y`. Compositor-level click works through iframes, shadow DOM, and cross-origin content. After clicking, a compact diff of page changes is appended.",
59
+ promptSnippet: "Click an element by ref (preferred) or pixel coordinates",
60
+ promptGuidelines: [
61
+ "PREFER `ref` from browser_snapshot (the outline shows '[eN]' for every interactive element) — it survives re-renders, unlike coordinates which go stale after a save/edit reflows the page.",
62
+ "Fallback: pass (x, y) from the snapshot's '@(x,y)' hint when there's no ref.",
63
+ "A 'ref is stale' error means the page changed — re-run browser_snapshot to get fresh refs.",
64
+ "After clicking, read the appended page-changes diff to confirm the action landed before moving on (no separate snapshot needed for a quick check).",
65
+ "Coordinates are viewport CSS pixels (not device pixels). Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content.",
66
+ "If a click doesn't register, run pi with --browser-debug-clicks (or set BH_DEBUG_CLICKS=1) to get annotated screenshots. For React/Vue components ignoring clicks, try browser_dispatch_key.",
67
+ ],
68
+ parameters: ClickArgs,
69
+ concurrency: "serialized",
70
+ renderCall: (a) => new Text(`🖱️ Click ${a.ref ? `[${a.ref}]` : `(${a.x}, ${a.y})`}`, 0, 0),
71
+ async handler(args, { client }) {
72
+ const button = args.button ?? "left";
73
+ const count = args.count ?? 1;
74
+
75
+ const resolved = await resolveTarget(client, args);
76
+ if (!resolved.success) return resolved;
77
+ const { x, y, label: target } = resolved.data;
78
+
79
+ const clicked = await dispatchClick(client, x, y, button, count);
80
+ if (!clicked.success) return clicked;
81
+
82
+ const diff = await interactiveDiff(client);
83
+ if (debugClicksEnabled()) {
84
+ const debug = await captureWithCrosshair(client, { x, y });
85
+ if (debug.success) {
86
+ return ok({
87
+ text: `Clicked at ${target}\n[DEBUG] Overlay screenshot: ${debug.data.path}${diff}`,
88
+ details: { debugScreenshotPath: debug.data.path, x, y, ref: args.ref },
89
+ });
90
+ }
91
+ }
92
+ return ok({
93
+ text: `Clicked at ${target}${diff}`,
94
+ details: { x, y, ref: args.ref },
95
+ });
96
+ },
97
+ });
@@ -0,0 +1,219 @@
1
+ import { Type } from "typebox";
2
+ import { Markdown, Text } from "@earendil-works/pi-tui";
3
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
4
+ import { type Result, ok } from "../util/result";
5
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
6
+ import { applyTruncation } from "../util/truncate";
7
+ import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer";
8
+ import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards";
9
+
10
+ const ConsoleArgs = Type.Object({
11
+ levels: Type.Optional(
12
+ Type.Array(
13
+ Type.Union(
14
+ [
15
+ Type.Literal("log"),
16
+ Type.Literal("info"),
17
+ Type.Literal("warn"),
18
+ Type.Literal("error"),
19
+ Type.Literal("debug"),
20
+ ],
21
+ ),
22
+ { description: 'Filter to these levels only, e.g. ["error","warn"]. Default: all levels.' },
23
+ ),
24
+ ),
25
+ textPattern: Type.Optional(
26
+ Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /TypeError/) for regex." }),
27
+ ),
28
+ sinceSeq: Type.Optional(
29
+ Type.Integer({ minimum: 0, description: "Only records with seq strictly greater than this. Use the previous call's nextCursor to see only what's new." }),
30
+ ),
31
+ sinceMs: Type.Optional(
32
+ Type.Integer({ minimum: 0, description: "Only records from the last N ms." }),
33
+ ),
34
+ limit: Type.Optional(
35
+ Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
36
+ ),
37
+ });
38
+
39
+ const levelTag = (lvl: ConsoleLevel): string => {
40
+ switch (lvl) {
41
+ case "error": return "[error]";
42
+ case "warn": return "[warn] ";
43
+ case "info": return "[info] ";
44
+ case "debug": return "[debug]";
45
+ default: return "[log] ";
46
+ }
47
+ };
48
+
49
+ const formatLocation = (r: ConsoleRecord): string => {
50
+ if (r.url === undefined || r.url === "") return "";
51
+ const short = r.url.length > 60 ? "…" + r.url.slice(-59) : r.url;
52
+ return r.lineNumber !== undefined ? `${short}:${r.lineNumber}` : short;
53
+ };
54
+
55
+ const renderConsoleMarkdown = (
56
+ records: ReadonlyArray<ConsoleRecord>,
57
+ opts: { total: number; bufferOverflowed: boolean; expanded: boolean; nextCursor: number | undefined },
58
+ ): string => {
59
+ if (records.length === 0) {
60
+ if (opts.bufferOverflowed) return "_No matching console records. (Buffer overflowed since last drain — some events were dropped.)_";
61
+ return "_No matching console records captured yet._";
62
+ }
63
+
64
+ const counts = records.reduce<Record<ConsoleLevel, number>>(
65
+ (acc, r) => { acc[r.level]++; return acc; },
66
+ { log: 0, info: 0, warn: 0, error: 0, debug: 0 },
67
+ );
68
+
69
+ const summaryParts: string[] = [];
70
+ if (counts.error > 0) summaryParts.push(`${counts.error} error${counts.error === 1 ? "" : "s"}`);
71
+ if (counts.warn > 0) summaryParts.push(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`);
72
+ if (counts.info > 0) summaryParts.push(`${counts.info} info`);
73
+ if (counts.log > 0) summaryParts.push(`${counts.log} log${counts.log === 1 ? "" : "s"}`);
74
+ if (counts.debug > 0) summaryParts.push(`${counts.debug} debug`);
75
+
76
+ const headerLines: string[] = [`**${summaryParts.join(" · ")}**`];
77
+ if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older entries dropped");
78
+ if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
79
+
80
+ const renderRow = (r: ConsoleRecord, compact: boolean): string => {
81
+ const tag = levelTag(r.level);
82
+ const loc = formatLocation(r);
83
+ const maxText = compact ? 80 : 200;
84
+ const text = r.text.length > maxText ? r.text.slice(0, maxText - 1) + "…" : r.text;
85
+ const locSuffix = loc !== "" ? ` _at ${loc}_` : "";
86
+ const main = `\`${tag}\` ${text}${locSuffix}`;
87
+ if (!compact && r.stackTrace !== undefined) return `${main}\n\`\`\`\n${r.stackTrace}\n\`\`\``;
88
+ return main;
89
+ };
90
+
91
+ const visible = !opts.expanded && records.length > 5 ? records.slice(-5) : records;
92
+ const rows = visible.map((r) => renderRow(r, !opts.expanded)).join(opts.expanded ? "\n\n" : "\n");
93
+
94
+ const more = !opts.expanded && records.length > 5 ? `\n … ${records.length - 5} earlier (expand to see all)` : "";
95
+ const cursor = opts.nextCursor !== undefined ? `\n\n_nextCursor=${opts.nextCursor}_` : "";
96
+
97
+ return `${headerLines.join("\n")}\n\n${rows}${more}${cursor}`;
98
+ };
99
+
100
+ type ConsoleDetails = {
101
+ total: number;
102
+ returned: number;
103
+ bufferOverflowed: boolean;
104
+ truncated: boolean;
105
+ fullOutputPath?: string;
106
+ records: ReadonlyArray<ConsoleRecord>;
107
+ nextCursor?: number;
108
+ };
109
+
110
+ const CONSOLE_LEVELS: ReadonlyArray<ConsoleLevel> = ["log", "info", "warn", "error", "debug"];
111
+
112
+ const asConsoleLevel = (v: unknown): ConsoleLevel | undefined =>
113
+ CONSOLE_LEVELS.find((l) => l === v);
114
+
115
+ const asConsoleRecord = (v: unknown): ConsoleRecord | undefined => {
116
+ const o = asRecord(v);
117
+ if (o === undefined) return undefined;
118
+ const level = asConsoleLevel(o["level"]);
119
+ const text = asString(o["text"]);
120
+ if (level === undefined || text === undefined) return undefined;
121
+ const url = asString(o["url"]);
122
+ const lineNumber = asNumber(o["lineNumber"]);
123
+ const stackTrace = asString(o["stackTrace"]);
124
+ return {
125
+ seq: asNumber(o["seq"]) ?? 0,
126
+ timestampMs: asNumber(o["timestampMs"]) ?? 0,
127
+ level,
128
+ text,
129
+ source: o["source"] === "log-entry" ? "log-entry" : "console-api",
130
+ ...(url !== undefined ? { url } : {}),
131
+ ...(lineNumber !== undefined ? { lineNumber } : {}),
132
+ ...(stackTrace !== undefined ? { stackTrace } : {}),
133
+ };
134
+ };
135
+
136
+ const asConsoleDetails = (v: unknown): ConsoleDetails | undefined => {
137
+ const o = asRecord(v);
138
+ if (o === undefined) return undefined;
139
+ const records = asArrayOf(o["records"], asConsoleRecord);
140
+ if (records === undefined) return undefined;
141
+ const fullOutputPath = asString(o["fullOutputPath"]);
142
+ const nextCursor = asNumber(o["nextCursor"]);
143
+ return {
144
+ total: asNumber(o["total"]) ?? records.length,
145
+ returned: asNumber(o["returned"]) ?? records.length,
146
+ bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
147
+ truncated: asBoolean(o["truncated"]) ?? false,
148
+ records,
149
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
150
+ ...(nextCursor !== undefined ? { nextCursor } : {}),
151
+ };
152
+ };
153
+
154
+ export const consoleTool = defineBrowserTool({
155
+ name: "browser_console",
156
+ label: "Browser Console",
157
+ description:
158
+ "Read JS errors and console messages from the active tab. Diagnostic — use when an action looks broken or silent.",
159
+ promptSnippet: "Read JS errors / console output on the current tab",
160
+ promptGuidelines: [
161
+ "Use after a click, submit, or navigate produced no visible change — errors often explain it.",
162
+ "Pass `sinceSeq` from the previous call's `nextCursor` to see only new messages after an action.",
163
+ "Buffer is reset on tab switch (capacity 500 records). bufferOverflowed:true means older entries were dropped.",
164
+ "Don't use as a default observation tool — prefer browser_snapshot for page structure.",
165
+ ],
166
+ parameters: ConsoleArgs,
167
+ concurrency: "parallel",
168
+
169
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
170
+ const session = client.session();
171
+ const drained = session.drainConsoleBuffer({
172
+ ...(args.levels !== undefined ? { levels: args.levels } : {}),
173
+ ...(args.textPattern !== undefined ? { textPattern: args.textPattern } : {}),
174
+ ...(args.sinceSeq !== undefined ? { sinceSeq: args.sinceSeq } : {}),
175
+ ...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
176
+ limit: args.limit ?? 50,
177
+ });
178
+
179
+ const last = drained.records.at(-1);
180
+ const nextCursor = last?.seq;
181
+
182
+ const text = renderConsoleMarkdown(drained.records, {
183
+ total: drained.total,
184
+ bufferOverflowed: drained.bufferOverflowed,
185
+ expanded: true,
186
+ nextCursor,
187
+ });
188
+ const trunc = await applyTruncation(text, "console");
189
+
190
+ const details: ConsoleDetails = {
191
+ total: drained.total,
192
+ returned: drained.records.length,
193
+ bufferOverflowed: drained.bufferOverflowed,
194
+ truncated: trunc.wasTruncated,
195
+ records: drained.records,
196
+ ...(nextCursor !== undefined ? { nextCursor } : {}),
197
+ };
198
+ if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
199
+
200
+ return ok({ text: trunc.text, details });
201
+ },
202
+
203
+ renderResult(result, expanded, theme) {
204
+ const details = asConsoleDetails(result.details);
205
+ if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
206
+
207
+ const md = renderConsoleMarkdown(details.records, {
208
+ total: details.total,
209
+ bufferOverflowed: details.bufferOverflowed,
210
+ expanded,
211
+ nextCursor: details.nextCursor,
212
+ });
213
+ const tail = expanded
214
+ ? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
215
+ : `\n\n${keyHint("app.tools.expand", "to expand")}`;
216
+
217
+ return new Markdown(md + tail, 0, 0, getMarkdownTheme());
218
+ },
219
+ });
@@ -0,0 +1,34 @@
1
+ import { Type } from "typebox";
2
+ import { type Result, ok } from "../util/result";
3
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
+ import { cdpCall } from "./cdp-call";
5
+
6
+ const HandleDialogArgs = Type.Object({
7
+ accept: Type.Boolean({ description: "true = accept, false = dismiss" }),
8
+ promptText: Type.Optional(Type.String({ description: "Text to type if dialog is a prompt()" })),
9
+ });
10
+
11
+ export const handleDialogTool = defineBrowserTool({
12
+ name: "browser_handle_dialog",
13
+ label: "Browser Handle Dialog",
14
+ description:
15
+ "Accept or dismiss the currently open JS dialog (alert/confirm/prompt/beforeunload).",
16
+ promptSnippet: "Accept or dismiss a JS dialog",
17
+ promptGuidelines: [
18
+ "Use after browser_page_info reports a dialog is open. Until handled, no other browser action will work.",
19
+ "For prompt() dialogs, supply promptText with the value to submit.",
20
+ ],
21
+ parameters: HandleDialogArgs,
22
+ concurrency: "serialized",
23
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
24
+ const r = await cdpCall(client, "Page.handleJavaScriptDialog", {
25
+ accept: args.accept,
26
+ ...(args.promptText !== undefined ? { promptText: args.promptText } : {}),
27
+ });
28
+ if (!r.success) return r;
29
+ return ok({
30
+ text: `Dialog ${args.accept ? "accepted" : "dismissed"}`,
31
+ details: { accept: args.accept },
32
+ });
33
+ },
34
+ });