pi-browser-harness 0.2.0 → 0.3.1
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.
- package/CHANGELOG.md +70 -0
- package/package.json +4 -1
- package/skills/pi-browser-harness/SKILL.md +17 -4
- package/src/cdp/discovery.ts +95 -0
- package/src/cdp/errors.ts +19 -0
- package/src/cdp/session.ts +139 -0
- package/src/cdp/transport.ts +221 -0
- package/src/cdp/types.ts +72 -0
- package/src/client.ts +232 -0
- package/src/domains/click.ts +73 -0
- package/src/domains/dialog.ts +31 -0
- package/src/domains/drag.ts +66 -0
- package/src/domains/files.ts +194 -0
- package/src/domains/history.ts +76 -0
- package/src/domains/js.ts +197 -0
- package/src/domains/keyboard.ts +127 -0
- package/src/domains/navigate.ts +146 -0
- package/src/domains/network.ts +80 -0
- package/src/domains/page.ts +88 -0
- package/src/domains/screenshot.ts +114 -0
- package/src/domains/scroll.ts +53 -0
- package/src/domains/tabs.ts +121 -0
- package/src/domains/viewport.ts +40 -0
- package/src/index.ts +65 -142
- package/src/prompt.ts +16 -3
- package/src/registry.ts +52 -0
- package/src/schemas/common.ts +11 -0
- package/src/setup.ts +20 -21
- package/src/state.ts +22 -54
- package/src/util/js-template.ts +27 -0
- package/src/util/keycodes.ts +10 -0
- package/src/util/paths.ts +21 -0
- package/src/util/result.ts +15 -0
- package/src/util/sharp-shim.ts +49 -0
- package/src/util/time.ts +29 -0
- package/src/util/tool.ts +169 -0
- package/src/util/truncate.ts +35 -0
- package/tsconfig.json +10 -1
- package/src/daemon.ts +0 -881
- package/src/protocol.ts +0 -77
- package/src/renderers.ts +0 -21
- package/src/tools.ts +0 -1363
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Typed minimal shim for the optional `sharp` dependency. Sharp is a heavy
|
|
2
|
+
// native module not installed in every workspace; we import dynamically and
|
|
3
|
+
// distinguish "missing" (no install) from "error" (installed but threw).
|
|
4
|
+
//
|
|
5
|
+
// We declare only the surface we use. The dynamic import returns `unknown`
|
|
6
|
+
// at the boundary; the cast inside loadSharp() is the documented escape
|
|
7
|
+
// hatch — every other consumer sees the typed SharpFactory.
|
|
8
|
+
|
|
9
|
+
export type SharpInstance = {
|
|
10
|
+
metadata(): Promise<{ width?: number; height?: number }>;
|
|
11
|
+
composite(layers: ReadonlyArray<{ input: Buffer; top: number; left: number }>): SharpInstance;
|
|
12
|
+
resize(width: number, height: number, opts?: { fit?: "inside" }): SharpInstance;
|
|
13
|
+
toFile(path: string): Promise<unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SharpFactory = (input: string | Buffer) => SharpInstance;
|
|
17
|
+
|
|
18
|
+
export type SharpLoad =
|
|
19
|
+
| { readonly kind: "ok"; readonly sharp: SharpFactory }
|
|
20
|
+
| { readonly kind: "missing" }
|
|
21
|
+
| { readonly kind: "error"; readonly message: string };
|
|
22
|
+
|
|
23
|
+
export const loadSharp = async (): Promise<SharpLoad> => {
|
|
24
|
+
let mod: unknown;
|
|
25
|
+
try {
|
|
26
|
+
// sharp is an optional dependency; it may not be installed.
|
|
27
|
+
// @ts-ignore — optional peer dependency not required at compile time
|
|
28
|
+
mod = await import("sharp");
|
|
29
|
+
} catch (e) {
|
|
30
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
31
|
+
if (
|
|
32
|
+
msg.includes("Cannot find module") ||
|
|
33
|
+
msg.includes("MODULE_NOT_FOUND") ||
|
|
34
|
+
msg.includes("ERR_MODULE_NOT_FOUND")
|
|
35
|
+
) {
|
|
36
|
+
return { kind: "missing" };
|
|
37
|
+
}
|
|
38
|
+
return { kind: "error", message: msg };
|
|
39
|
+
}
|
|
40
|
+
if (mod === null || mod === undefined) return { kind: "missing" };
|
|
41
|
+
// The CommonJS sharp module's default export IS the factory. ESM-wrapped
|
|
42
|
+
// sharp puts it on .default. Probe both.
|
|
43
|
+
const m = mod as { default?: unknown };
|
|
44
|
+
const candidate: unknown = typeof m.default === "function" ? m.default : mod;
|
|
45
|
+
if (typeof candidate !== "function") {
|
|
46
|
+
return { kind: "error", message: "sharp module did not expose a callable factory" };
|
|
47
|
+
}
|
|
48
|
+
return { kind: "ok", sharp: candidate as SharpFactory };
|
|
49
|
+
};
|
package/src/util/time.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
2
|
+
new Promise((resolve, reject) => {
|
|
3
|
+
if (signal?.aborted) {
|
|
4
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
const onAbort = (): void => {
|
|
8
|
+
clearTimeout(timer);
|
|
9
|
+
reject(signal?.reason ?? new Error("aborted"));
|
|
10
|
+
};
|
|
11
|
+
const timer = setTimeout(() => {
|
|
12
|
+
signal?.removeEventListener("abort", onAbort);
|
|
13
|
+
resolve();
|
|
14
|
+
}, ms);
|
|
15
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type Deadline = {
|
|
19
|
+
readonly remainingMs: () => number;
|
|
20
|
+
readonly expired: () => boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const deadline = (totalMs: number): Deadline => {
|
|
24
|
+
const end = Date.now() + totalMs;
|
|
25
|
+
return {
|
|
26
|
+
remainingMs: () => Math.max(0, end - Date.now()),
|
|
27
|
+
expired: () => Date.now() >= end,
|
|
28
|
+
};
|
|
29
|
+
};
|
package/src/util/tool.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { TSchema, Static } from "typebox";
|
|
2
|
+
import type {
|
|
3
|
+
AgentToolResult,
|
|
4
|
+
AgentToolUpdateCallback,
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
Theme,
|
|
8
|
+
ToolDefinition,
|
|
9
|
+
ToolRenderResultOptions,
|
|
10
|
+
} from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
12
|
+
import type { BrowserClient } from "../client";
|
|
13
|
+
import type { Result } from "./result";
|
|
14
|
+
|
|
15
|
+
export type ToolOk = {
|
|
16
|
+
readonly text: string;
|
|
17
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ToolErrKind =
|
|
21
|
+
| "not_connected"
|
|
22
|
+
| "cdp_error"
|
|
23
|
+
| "timeout"
|
|
24
|
+
| "invalid_state"
|
|
25
|
+
| "io_error"
|
|
26
|
+
| "internal";
|
|
27
|
+
|
|
28
|
+
export type ToolErr = {
|
|
29
|
+
readonly kind: ToolErrKind;
|
|
30
|
+
readonly message: string;
|
|
31
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type HandlerContext = {
|
|
35
|
+
readonly client: BrowserClient;
|
|
36
|
+
readonly signal: AbortSignal | undefined;
|
|
37
|
+
readonly onUpdate: (update: ToolOk) => void;
|
|
38
|
+
readonly extensionCtx: ExtensionContext;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ToolHandler<S extends TSchema> = (
|
|
42
|
+
args: Static<S>,
|
|
43
|
+
ctx: HandlerContext,
|
|
44
|
+
) => Promise<Result<ToolOk, ToolErr>>;
|
|
45
|
+
|
|
46
|
+
export type BrowserToolDefinition<S extends TSchema> = {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly label: string;
|
|
49
|
+
readonly description: string;
|
|
50
|
+
readonly promptSnippet: string;
|
|
51
|
+
readonly promptGuidelines: ReadonlyArray<string>;
|
|
52
|
+
readonly parameters: S;
|
|
53
|
+
readonly handler: ToolHandler<S>;
|
|
54
|
+
readonly renderCall?: (args: Static<S>, theme: Theme) => Component;
|
|
55
|
+
readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
|
|
56
|
+
readonly ensureAlive?: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Type-erased entry for the registry's heterogeneous TOOLS array.
|
|
60
|
+
// renderCall accepts `never` so any concrete args type satisfies it via
|
|
61
|
+
// function-parameter contravariance (a handler that takes SpecificT is
|
|
62
|
+
// assignable to one that takes never, because never is a subtype of everything).
|
|
63
|
+
export type AnyBrowserToolDefinition = Omit<BrowserToolDefinition<TSchema>, "renderCall" | "renderResult"> & {
|
|
64
|
+
readonly renderCall?: (args: never, theme: Theme) => Component;
|
|
65
|
+
readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const defineBrowserTool = <S extends TSchema>(
|
|
69
|
+
def: BrowserToolDefinition<S>,
|
|
70
|
+
): BrowserToolDefinition<S> => def;
|
|
71
|
+
|
|
72
|
+
type OkDetails = { readonly ok: true } & Readonly<Record<string, unknown>>;
|
|
73
|
+
type ErrDetails = { readonly ok: false; readonly kind: ToolErrKind; readonly message: string } & Readonly<Record<string, unknown>>;
|
|
74
|
+
|
|
75
|
+
const toToolResult = (
|
|
76
|
+
r: Result<ToolOk, ToolErr>,
|
|
77
|
+
toolName: string,
|
|
78
|
+
): AgentToolResult<OkDetails | ErrDetails> => {
|
|
79
|
+
if (r.success) {
|
|
80
|
+
const details: OkDetails = { ok: true, ...(r.data.details ?? {}) };
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: r.data.text }],
|
|
83
|
+
details,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const details: ErrDetails = {
|
|
87
|
+
ok: false,
|
|
88
|
+
kind: r.error.kind,
|
|
89
|
+
message: r.error.message,
|
|
90
|
+
...(r.error.details ?? {}),
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
// isError is an extension the pi-coding-agent runtime picks up from the result object;
|
|
94
|
+
// AgentToolResult<T> itself doesn't declare it but the runtime reads it via duck-typing.
|
|
95
|
+
// We cast through unknown to avoid widening the return type.
|
|
96
|
+
...(({ isError: true }) as unknown as object),
|
|
97
|
+
content: [{ type: "text", text: `${toolName} failed (${r.error.kind}): ${r.error.message}` }],
|
|
98
|
+
details,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const registerBrowserTool = (
|
|
103
|
+
pi: ExtensionAPI,
|
|
104
|
+
client: BrowserClient,
|
|
105
|
+
def: AnyBrowserToolDefinition,
|
|
106
|
+
): void => {
|
|
107
|
+
// AnyBrowserToolDefinition is type-erased; we recover the schema-typed surface
|
|
108
|
+
// by casting def to the generic form. The cast is safe because defineBrowserTool
|
|
109
|
+
// ensures the handler, parameters, and renderCall were all created with the same S.
|
|
110
|
+
type S = TSchema;
|
|
111
|
+
const td: ToolDefinition<S> = {
|
|
112
|
+
name: def.name,
|
|
113
|
+
label: def.label,
|
|
114
|
+
description: def.description,
|
|
115
|
+
promptSnippet: def.promptSnippet,
|
|
116
|
+
promptGuidelines: [...def.promptGuidelines],
|
|
117
|
+
parameters: def.parameters,
|
|
118
|
+
...(def.renderCall
|
|
119
|
+
? {
|
|
120
|
+
renderCall: (args: Static<S>, theme: Theme, _ctx: Parameters<NonNullable<ToolDefinition<S>["renderCall"]>>[2]) =>
|
|
121
|
+
// Non-null assertion safe: this branch only runs when renderCall was provided.
|
|
122
|
+
// Cast from never->Component to unknown->Component: safe because at runtime
|
|
123
|
+
// args is always the concrete Static<S> value the tool was defined with.
|
|
124
|
+
(def.renderCall as (args: unknown, theme: Theme) => Component)(args, theme),
|
|
125
|
+
}
|
|
126
|
+
: {}),
|
|
127
|
+
...(def.renderResult
|
|
128
|
+
? {
|
|
129
|
+
renderResult: (
|
|
130
|
+
result: AgentToolResult<unknown>,
|
|
131
|
+
options: ToolRenderResultOptions,
|
|
132
|
+
theme: Theme,
|
|
133
|
+
_ctx: Parameters<NonNullable<ToolDefinition<S>["renderResult"]>>[3],
|
|
134
|
+
) =>
|
|
135
|
+
// Non-null assertion safe: this branch only runs when renderResult was provided.
|
|
136
|
+
def.renderResult!(result, options.expanded ?? false, theme),
|
|
137
|
+
}
|
|
138
|
+
: {}),
|
|
139
|
+
async execute(_toolCallId, args, signal, onUpdate, extensionCtx) {
|
|
140
|
+
if (def.ensureAlive !== false) {
|
|
141
|
+
const alive = await client.ensureAlive();
|
|
142
|
+
if (!alive.success) {
|
|
143
|
+
return toToolResult(
|
|
144
|
+
{ success: false, error: { kind: "not_connected", message: alive.error.message } },
|
|
145
|
+
def.name,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const result = await def.handler(args, {
|
|
150
|
+
client,
|
|
151
|
+
signal,
|
|
152
|
+
onUpdate: (u) => {
|
|
153
|
+
if (onUpdate) {
|
|
154
|
+
// AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails.
|
|
155
|
+
const update: AgentToolResult<OkDetails> = {
|
|
156
|
+
content: [{ type: "text", text: u.text }],
|
|
157
|
+
details: { ok: true, ...(u.details ?? {}) },
|
|
158
|
+
};
|
|
159
|
+
// Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails).
|
|
160
|
+
(onUpdate as AgentToolUpdateCallback<OkDetails>)(update);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
extensionCtx,
|
|
164
|
+
});
|
|
165
|
+
return toToolResult(result, def.name);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
pi.registerTool(td);
|
|
169
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MAX_BYTES,
|
|
6
|
+
DEFAULT_MAX_LINES,
|
|
7
|
+
formatSize,
|
|
8
|
+
truncateHead,
|
|
9
|
+
withFileMutationQueue,
|
|
10
|
+
} from "@mariozechner/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
const tempDirs: string[] = [];
|
|
13
|
+
|
|
14
|
+
export const cleanupTempDirs = async (): Promise<void> => {
|
|
15
|
+
const dirs = tempDirs.splice(0);
|
|
16
|
+
await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true }).catch(() => {})));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type TruncatedOutput = {
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly fullOutputPath?: string;
|
|
22
|
+
readonly wasTruncated: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const applyTruncation = async (output: string, prefix: string): Promise<TruncatedOutput> => {
|
|
26
|
+
const t = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
27
|
+
if (!t.truncated) return { text: t.content, wasTruncated: false };
|
|
28
|
+
const dir = await mkdtemp(join(tmpdir(), `pi-bh-${prefix}-`));
|
|
29
|
+
tempDirs.push(dir);
|
|
30
|
+
const file = join(dir, "output.txt");
|
|
31
|
+
await withFileMutationQueue(file, async () => { await writeFile(file, output, "utf8"); });
|
|
32
|
+
const omitted = t.totalBytes - t.outputBytes;
|
|
33
|
+
const text = `${t.content}\n\n[Output truncated: ${t.outputLines} of ${t.totalLines} lines (${formatSize(t.outputBytes)} of ${formatSize(t.totalBytes)}). ${formatSize(omitted)} omitted. Full output: ${file}]`;
|
|
34
|
+
return { text, fullOutputPath: file, wasTruncated: true };
|
|
35
|
+
};
|
package/tsconfig.json
CHANGED
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
"module": "ESNext",
|
|
5
5
|
"moduleResolution": "bundler",
|
|
6
6
|
"strict": true,
|
|
7
|
+
"noImplicitAny": true,
|
|
8
|
+
"strictNullChecks": true,
|
|
9
|
+
"noUnusedLocals": true,
|
|
10
|
+
"noUnusedParameters": true,
|
|
11
|
+
"noImplicitReturns": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"exactOptionalPropertyTypes": true,
|
|
15
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
7
17
|
"esModuleInterop": true,
|
|
8
18
|
"skipLibCheck": true,
|
|
9
|
-
"forceConsistentCasingInFileNames": true,
|
|
10
19
|
"outDir": "./dist",
|
|
11
20
|
"rootDir": "./src",
|
|
12
21
|
"declaration": true,
|