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,215 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { cdpError, type CdpError } from "./cdp/errors";
5
+ import { err, ok, type Result } from "./util/result";
6
+
7
+ const STATE_VERSION = 1;
8
+
9
+ type SteelState = {
10
+ readonly version: number;
11
+ readonly piProfile: string;
12
+ readonly sessionContext?: Record<string, unknown>;
13
+ readonly createdAt: string;
14
+ readonly updatedAt: string;
15
+ };
16
+
17
+ type SteelSession = {
18
+ readonly id: string;
19
+ readonly websocketUrl: string;
20
+ readonly debugUrl: string;
21
+ readonly debuggerUrl: string;
22
+ readonly sessionViewerUrl: string;
23
+ };
24
+
25
+ type SteelConfig = { readonly apiUrl: string; readonly apiKey?: string };
26
+
27
+ const agentDir = (): string => process.env["PI_CODING_AGENT_DIR"]?.trim() || join(homedir(), ".pi", "agent");
28
+ const activeProfile = (): string => process.env["PI_ACTIVE_PROFILE"]?.trim() || "default";
29
+ const statePath = (): string => join(agentDir(), "steel.json");
30
+
31
+ const config = (): SteelConfig => ({
32
+ apiUrl: (process.env["STEEL_API_URL"]?.trim() || "http://127.0.0.1:3000").replace(/\/$/, ""),
33
+ ...(process.env["STEEL_API_KEY"]?.trim() ? { apiKey: process.env["STEEL_API_KEY"]?.trim() } : {}),
34
+ });
35
+
36
+ const headers = (value: SteelConfig): HeadersInit => ({
37
+ "content-type": "application/json",
38
+ ...(value.apiKey ? { "steel-api-key": value.apiKey } : {}),
39
+ });
40
+
41
+ const loadState = async (): Promise<SteelState | undefined> => {
42
+ try {
43
+ const parsed: unknown = JSON.parse(await readFile(statePath(), "utf8"));
44
+ if (!parsed || typeof parsed !== "object") return undefined;
45
+ const value = parsed as Partial<SteelState>;
46
+ if (value.version !== STATE_VERSION || value.piProfile !== activeProfile()) return undefined;
47
+ return value as SteelState;
48
+ } catch { return undefined; }
49
+ };
50
+
51
+ const saveState = async (sessionContext: Record<string, unknown> | undefined): Promise<void> => {
52
+ const path = statePath();
53
+ const now = new Date().toISOString();
54
+ const existing = await loadState();
55
+ const state: SteelState = {
56
+ version: STATE_VERSION,
57
+ piProfile: activeProfile(),
58
+ ...(sessionContext ? { sessionContext } : {}),
59
+ createdAt: existing?.createdAt ?? now,
60
+ updatedAt: now,
61
+ };
62
+ await mkdir(dirname(path), { recursive: true });
63
+ const temporary = `${path}.${process.pid}.tmp`;
64
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
65
+ await rename(temporary, path);
66
+ };
67
+
68
+ const publicSessionUrl = (url: string, apiUrl: string, websocket = false): Result<string, CdpError> => {
69
+ try {
70
+ const endpoint = new URL(url);
71
+ const api = new URL(apiUrl);
72
+ // Self-hosted Steel can return a container hostname. The API origin is the
73
+ // endpoint reachable by both Pi and the supervising human.
74
+ endpoint.hostname = api.hostname;
75
+ endpoint.port = api.port;
76
+ endpoint.protocol = websocket ? (api.protocol === "https:" ? "wss:" : "ws:") : api.protocol;
77
+ return ok(endpoint.toString());
78
+ } catch {
79
+ return err(cdpError("invalid_response", "Steel returned an invalid session URL"));
80
+ }
81
+ };
82
+
83
+ const interactiveViewerUrl = (url: string): string => {
84
+ const viewer = new URL(url);
85
+ viewer.searchParams.set("interactive", "true");
86
+ return viewer.toString();
87
+ };
88
+
89
+ const assertNoLiveSession = async (value: SteelConfig): Promise<Result<void, CdpError>> => {
90
+ try {
91
+ const response = await fetch(`${value.apiUrl}/v1/sessions`, { headers: headers(value) });
92
+ const raw: unknown = await response.json();
93
+ const sessions = raw && typeof raw === "object" ? (raw as { sessions?: unknown }).sessions : undefined;
94
+ if (Array.isArray(sessions) && sessions.some((session) => session && typeof session === "object" && (session as { status?: unknown }).status === "live")) {
95
+ return err(cdpError("remote_error", "Steel Browser v0.5.x supports one active session. Release the current browser session before using another Pi Profile."));
96
+ }
97
+ return ok(undefined);
98
+ } catch (error) {
99
+ return err(cdpError("transport_closed", `Steel API is unavailable: ${error instanceof Error ? error.message : String(error)}`));
100
+ }
101
+ };
102
+
103
+ const createSession = async (value: SteelConfig, sessionContext?: Record<string, unknown>): Promise<Result<SteelSession, CdpError>> => {
104
+ try {
105
+ const response = await fetch(`${value.apiUrl}/v1/sessions`, {
106
+ method: "POST",
107
+ headers: headers(value),
108
+ body: JSON.stringify({ ...(sessionContext ? { sessionContext } : {}) }),
109
+ });
110
+ const raw: unknown = await response.json().catch(() => undefined);
111
+ if (!response.ok || !raw || typeof raw !== "object") return err(cdpError("remote_error", `Steel session creation failed (${response.status})`));
112
+ const data = raw as Partial<SteelSession>;
113
+ if (typeof data.id !== "string" || typeof data.websocketUrl !== "string" || typeof data.debugUrl !== "string" || typeof data.debuggerUrl !== "string" || typeof data.sessionViewerUrl !== "string") {
114
+ return err(cdpError("invalid_response", "Steel did not return the session and viewer URLs"));
115
+ }
116
+ return ok({
117
+ id: data.id,
118
+ websocketUrl: data.websocketUrl,
119
+ debugUrl: data.debugUrl,
120
+ debuggerUrl: data.debuggerUrl,
121
+ sessionViewerUrl: data.sessionViewerUrl,
122
+ });
123
+ } catch (error) {
124
+ return err(cdpError("transport_closed", `Steel API is unavailable: ${error instanceof Error ? error.message : String(error)}`));
125
+ }
126
+ };
127
+
128
+ const getSessionContext = async (value: SteelConfig, sessionId: string): Promise<Record<string, unknown> | undefined> => {
129
+ try {
130
+ const response = await fetch(`${value.apiUrl}/v1/sessions/${encodeURIComponent(sessionId)}/context`, { headers: headers(value) });
131
+ const data: unknown = await response.json();
132
+ return data && typeof data === "object" && !Array.isArray(data) ? data as Record<string, unknown> : undefined;
133
+ } catch { return undefined; }
134
+ };
135
+
136
+ export type SteelRuntime = {
137
+ endpoint(): Promise<Result<string, CdpError>>;
138
+ status(): {
139
+ readonly sessionId?: string;
140
+ readonly viewerUrl?: string;
141
+ readonly debuggerUrl?: string;
142
+ readonly sessionViewerUrl?: string;
143
+ readonly hasPersistedContext: boolean;
144
+ };
145
+ release(): Promise<void>;
146
+ };
147
+
148
+ /**
149
+ * Steel Browser v0.5.x has a session API, not a Profiles API. The active Pi
150
+ * Profile is therefore represented by a profile-scoped persisted SessionContext
151
+ * in steel.json. Session lifecycle remains exclusively API-driven; CDP is used
152
+ * only after the API returns the session websocket endpoint.
153
+ */
154
+ export const createSteelRuntime = (): SteelRuntime => {
155
+ let sessionId: string | undefined;
156
+ let endpointUrl: string | undefined;
157
+ let viewerUrl: string | undefined;
158
+ let debuggerUrl: string | undefined;
159
+ let sessionViewerUrl: string | undefined;
160
+ let persistedContext = false;
161
+
162
+ const endpoint = async (): Promise<Result<string, CdpError>> => {
163
+ if (endpointUrl) return ok(endpointUrl);
164
+ const settings = config();
165
+ const available = await assertNoLiveSession(settings);
166
+ if (!available.success) return available;
167
+ const state = await loadState();
168
+ persistedContext = state?.sessionContext !== undefined;
169
+ const session = await createSession(settings, state?.sessionContext);
170
+ if (!session.success) return session;
171
+ const resolved = publicSessionUrl(session.data.websocketUrl, settings.apiUrl, true);
172
+ const viewer = publicSessionUrl(session.data.debugUrl, settings.apiUrl);
173
+ const debuggerEndpoint = publicSessionUrl(session.data.debuggerUrl, settings.apiUrl);
174
+ const viewerSession = publicSessionUrl(session.data.sessionViewerUrl, settings.apiUrl);
175
+ if (!resolved.success) return resolved;
176
+ if (!viewer.success || !debuggerEndpoint.success || !viewerSession.success) {
177
+ return err(cdpError("invalid_response", "Steel returned invalid viewer URLs"));
178
+ }
179
+ sessionId = session.data.id;
180
+ endpointUrl = resolved.data;
181
+ viewerUrl = interactiveViewerUrl(viewer.data);
182
+ debuggerUrl = debuggerEndpoint.data;
183
+ sessionViewerUrl = viewerSession.data;
184
+ return resolved;
185
+ };
186
+
187
+ return {
188
+ endpoint,
189
+ status: () => ({
190
+ ...(sessionId ? { sessionId, viewerUrl, debuggerUrl, sessionViewerUrl } : {}),
191
+ hasPersistedContext: persistedContext,
192
+ }),
193
+ async release() {
194
+ if (!sessionId) return;
195
+ const settings = config();
196
+ const id = sessionId;
197
+ const context = await getSessionContext(settings, id);
198
+ if (context) {
199
+ await saveState(context);
200
+ persistedContext = true;
201
+ }
202
+ sessionId = undefined;
203
+ endpointUrl = undefined;
204
+ viewerUrl = undefined;
205
+ debuggerUrl = undefined;
206
+ sessionViewerUrl = undefined;
207
+ try {
208
+ await fetch(`${settings.apiUrl}/v1/sessions/${encodeURIComponent(id)}/release`, {
209
+ method: "POST",
210
+ headers: headers(settings),
211
+ });
212
+ } catch {}
213
+ },
214
+ };
215
+ };
@@ -0,0 +1,8 @@
1
+ let flagEnabled = false;
2
+
3
+ export const setDebugClicks = (on: boolean): void => {
4
+ flagEnabled = on;
5
+ };
6
+
7
+ export const debugClicksEnabled = (env: NodeJS.ProcessEnv = process.env): boolean =>
8
+ flagEnabled || Boolean(env["BH_DEBUG_CLICKS"]);
@@ -0,0 +1,30 @@
1
+ export const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
2
+ typeof v === "object" && v !== null && !Array.isArray(v);
3
+
4
+ export const asRecord = (v: unknown): Readonly<Record<string, unknown>> | undefined =>
5
+ isRecord(v) ? v : undefined;
6
+
7
+ export const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
8
+
9
+ export const asNumber = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
10
+
11
+ export const asBoolean = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
12
+
13
+ export const asArrayOf = <T>(
14
+ v: unknown,
15
+ item: (x: unknown) => T | undefined,
16
+ ): ReadonlyArray<T> | undefined => {
17
+ if (!Array.isArray(v)) return undefined;
18
+ const out: T[] = [];
19
+ for (const x of v) {
20
+ const parsed = item(x);
21
+ if (parsed === undefined) return undefined;
22
+ out.push(parsed);
23
+ }
24
+ return out;
25
+ };
26
+
27
+ export const errnoCode = (e: unknown): string | undefined => {
28
+ if (!(e instanceof Error) || !("code" in e)) return undefined;
29
+ return asString(e.code);
30
+ };
@@ -0,0 +1,12 @@
1
+ // The ONLY supported way to interpolate untrusted values into evaluation source — never use raw template literals for JS crossing the CDP boundary.
2
+ export const safeJs = (
3
+ strings: TemplateStringsArray,
4
+ ...values: ReadonlyArray<unknown>
5
+ ): string => {
6
+ let out = strings[0] ?? "";
7
+ for (let i = 0; i < values.length; i++) {
8
+ out += JSON.stringify(values[i]);
9
+ out += strings[i + 1] ?? "";
10
+ }
11
+ return out;
12
+ };
@@ -0,0 +1,20 @@
1
+ export type Mutex = {
2
+ acquire(): Promise<() => void>;
3
+ };
4
+
5
+ export const createMutex = (): Mutex => {
6
+ let queue = Promise.resolve<unknown>(undefined);
7
+
8
+ return {
9
+ async acquire(): Promise<() => void> {
10
+ let release = () => {};
11
+ const next = new Promise<void>((resolve) => {
12
+ release = resolve;
13
+ });
14
+ const prev = queue;
15
+ queue = prev.then(() => next);
16
+ await prev;
17
+ return release;
18
+ },
19
+ };
20
+ };
@@ -0,0 +1,31 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { tmpdir } from "node:os";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+
5
+ // `relative` rather than a string prefix: it is separator-agnostic and carries each platform's
6
+ // own case rules, so a Windows path is not rejected for its backslashes or its drive-letter case.
7
+ export const isPathWithin = (root: string, target: string): boolean => {
8
+ const absRoot = resolve(root);
9
+ const absTarget = resolve(target);
10
+ if (absTarget === absRoot) return true;
11
+ const rel = relative(absRoot, absTarget);
12
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
13
+ };
14
+
15
+ const NAMESPACE_RE = /^[a-zA-Z0-9_-]+$/;
16
+
17
+ const requireValidNamespace = (namespace: string): void => {
18
+ if (!NAMESPACE_RE.test(namespace)) {
19
+ throw new Error(`Invalid namespace (must match ${NAMESPACE_RE}): ${JSON.stringify(namespace)}`);
20
+ }
21
+ };
22
+
23
+ export const screenshotPath = (namespace: string, ext: "png" | "jpeg" = "png"): string => {
24
+ requireValidNamespace(namespace);
25
+ return join(tmpdir(), `pi-browser-screenshot-${namespace}-${randomUUID()}.${ext}`);
26
+ };
27
+
28
+ export const pdfPath = (namespace: string): string => {
29
+ requireValidNamespace(namespace);
30
+ return join(tmpdir(), `pi-browser-pdf-${namespace}-${randomUUID()}.pdf`);
31
+ };
@@ -0,0 +1,9 @@
1
+ export type Result<T, E> =
2
+ | { readonly success: true; readonly data: T }
3
+ | { readonly success: false; readonly error: E };
4
+
5
+ export const ok = <T>(data: T): Result<T, never> => ({ success: true, data });
6
+ export const err = <E>(error: E): Result<never, E> => ({ success: false, error });
7
+
8
+ export const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
9
+ r.success ? ok(f(r.data)) : r;
@@ -0,0 +1,42 @@
1
+ // `sharp` is an optional dependency not installed in every workspace, so the import must stay dynamic and unresolvable to tsc.
2
+
3
+ export type SharpInstance = {
4
+ metadata(): Promise<{ width?: number; height?: number }>;
5
+ composite(layers: ReadonlyArray<{ input: Buffer; top: number; left: number }>): SharpInstance;
6
+ resize(width: number, height: number, opts?: { fit?: "inside" }): SharpInstance;
7
+ toFile(path: string): Promise<unknown>;
8
+ };
9
+
10
+ export type SharpFactory = (input: string | Buffer) => SharpInstance;
11
+
12
+ export type SharpLoad =
13
+ | { readonly kind: "ok"; readonly sharp: SharpFactory }
14
+ | { readonly kind: "missing" }
15
+ | { readonly kind: "error"; readonly message: string };
16
+
17
+ const SHARP_SPECIFIER: string = "sharp";
18
+
19
+ const MISSING_MODULE_MARKERS: ReadonlyArray<string> = [
20
+ "Cannot find module",
21
+ "MODULE_NOT_FOUND",
22
+ "ERR_MODULE_NOT_FOUND",
23
+ ];
24
+
25
+ export const loadSharp = async (): Promise<SharpLoad> => {
26
+ let mod: unknown;
27
+ try {
28
+ mod = await import(SHARP_SPECIFIER);
29
+ } catch (e) {
30
+ const msg = e instanceof Error ? e.message : String(e);
31
+ if (MISSING_MODULE_MARKERS.some((marker) => msg.includes(marker))) return { kind: "missing" };
32
+ return { kind: "error", message: msg };
33
+ }
34
+ if (mod === null || mod === undefined) return { kind: "missing" };
35
+ // The CommonJS sharp module's default export IS the factory; ESM-wrapped sharp puts it on .default.
36
+ const m = mod as { default?: unknown };
37
+ const candidate: unknown = typeof m.default === "function" ? m.default : mod;
38
+ if (typeof candidate !== "function") {
39
+ return { kind: "error", message: "sharp module did not expose a callable factory" };
40
+ }
41
+ return { kind: "ok", sharp: candidate as SharpFactory };
42
+ };
@@ -0,0 +1,185 @@
1
+ import type { TSchema, Static } from "typebox";
2
+ import type {
3
+ AgentToolResult,
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ Theme,
7
+ ToolDefinition,
8
+ ToolRenderResultOptions,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import type { Component } from "@earendil-works/pi-tui";
11
+ import type { BrowserClient } from "../client";
12
+ import type { Result } from "./result";
13
+
14
+ export type ToolOk = {
15
+ readonly text: string;
16
+ readonly details?: Readonly<Record<string, unknown>>;
17
+ };
18
+
19
+ export type ToolErrKind =
20
+ | "not_connected"
21
+ | "cdp_error"
22
+ | "timeout"
23
+ | "invalid_state"
24
+ | "io_error"
25
+ | "internal";
26
+
27
+ export type ToolErr = {
28
+ readonly kind: ToolErrKind;
29
+ readonly message: string;
30
+ readonly details?: Readonly<Record<string, unknown>>;
31
+ };
32
+
33
+ export type HandlerContext = {
34
+ readonly client: BrowserClient;
35
+ readonly signal: AbortSignal | undefined;
36
+ readonly onUpdate: (update: ToolOk) => void;
37
+ readonly extensionCtx: ExtensionContext;
38
+ };
39
+
40
+ export type ToolHandler<S extends TSchema> = (
41
+ args: Static<S>,
42
+ ctx: HandlerContext,
43
+ ) => Promise<Result<ToolOk, ToolErr>>;
44
+
45
+ export type BrowserToolDefinition<S extends TSchema> = {
46
+ readonly name: string;
47
+ readonly label: string;
48
+ readonly description: string;
49
+ readonly promptSnippet: string;
50
+ readonly promptGuidelines: ReadonlyArray<string>;
51
+ readonly parameters: S;
52
+ readonly handler: ToolHandler<S>;
53
+ readonly renderCall?: (args: Static<S>, theme: Theme) => Component;
54
+ readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
55
+ readonly concurrency: "serialized" | "parallel";
56
+ readonly ensureAlive?: boolean;
57
+ };
58
+
59
+ // renderCall accepts `never` so any concrete args type satisfies it via function-parameter contravariance.
60
+ export type AnyBrowserToolDefinition = Omit<BrowserToolDefinition<TSchema>, "renderCall" | "renderResult"> & {
61
+ readonly renderCall?: (args: never, theme: Theme) => Component;
62
+ readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
63
+ };
64
+
65
+ export const defineBrowserTool = <S extends TSchema>(
66
+ def: BrowserToolDefinition<S>,
67
+ ): BrowserToolDefinition<S> => def;
68
+
69
+ type OkDetails = { readonly ok: true } & Readonly<Record<string, unknown>>;
70
+ type ErrDetails = { readonly ok: false; readonly kind: ToolErrKind; readonly message: string } & Readonly<Record<string, unknown>>;
71
+
72
+ type ErrorFlagged<D> = AgentToolResult<D> & { readonly isError?: boolean };
73
+
74
+ const toToolResult = (
75
+ r: Result<ToolOk, ToolErr>,
76
+ toolName: string,
77
+ ): ErrorFlagged<OkDetails | ErrDetails> => {
78
+ if (r.success) {
79
+ const details: OkDetails = { ok: true, ...(r.data.details ?? {}) };
80
+ return {
81
+ content: [{ type: "text", text: r.data.text }],
82
+ details,
83
+ };
84
+ }
85
+ const details: ErrDetails = {
86
+ ok: false,
87
+ kind: r.error.kind,
88
+ message: r.error.message,
89
+ ...(r.error.details ?? {}),
90
+ };
91
+ return {
92
+ isError: true,
93
+ content: [{ type: "text", text: `${toolName} failed (${r.error.kind}): ${r.error.message}` }],
94
+ details,
95
+ };
96
+ };
97
+
98
+ export const registerBrowserTool = (
99
+ pi: ExtensionAPI,
100
+ client: BrowserClient,
101
+ def: AnyBrowserToolDefinition,
102
+ ): void => {
103
+ type S = TSchema;
104
+ const defRenderCall = def.renderCall;
105
+ const defRenderResult = def.renderResult;
106
+ const td: ToolDefinition<S> = {
107
+ name: def.name,
108
+ label: def.label,
109
+ description: def.description,
110
+ promptSnippet: def.promptSnippet,
111
+ promptGuidelines: [...def.promptGuidelines],
112
+ parameters: def.parameters,
113
+ ...(defRenderCall
114
+ ? {
115
+ renderCall: (args: Static<S>, theme: Theme, _ctx: Parameters<NonNullable<ToolDefinition<S>["renderCall"]>>[2]) =>
116
+ // Sanctioned cast: at runtime args is always the concrete Static<S> value the tool was defined with.
117
+ (defRenderCall as (args: unknown, theme: Theme) => Component)(args, theme),
118
+ }
119
+ : {}),
120
+ ...(defRenderResult
121
+ ? {
122
+ renderResult: (
123
+ result: AgentToolResult<unknown>,
124
+ options: ToolRenderResultOptions,
125
+ theme: Theme,
126
+ _ctx: Parameters<NonNullable<ToolDefinition<S>["renderResult"]>>[3],
127
+ ) => defRenderResult(result, options.expanded ?? false, theme),
128
+ }
129
+ : {}),
130
+ async execute(_toolCallId, args, signal, onUpdate, extensionCtx) {
131
+ if (def.ensureAlive !== false) {
132
+ const alive = await client.ensureAlive();
133
+ if (!alive.success) {
134
+ return toToolResult(
135
+ { success: false, error: { kind: "not_connected", message: alive.error.message } },
136
+ def.name,
137
+ );
138
+ }
139
+ // A fresh CDP attachment starts on Steel's default target. Never let a
140
+ // browser tool inspect or mutate that unowned target.
141
+ if (!client.current() || !client.owns(client.current()!.targetId)) {
142
+ const tab = await client.newTab("about:blank");
143
+ if (!tab.success) {
144
+ return toToolResult(
145
+ { success: false, error: { kind: "not_connected", message: tab.error.message } },
146
+ def.name,
147
+ );
148
+ }
149
+ }
150
+ }
151
+ let release: (() => void) | undefined;
152
+ if (def.concurrency !== "parallel") {
153
+ const acquire = await client.mutationMutex().acquire();
154
+ release = acquire;
155
+ if (signal?.aborted) {
156
+ release();
157
+ return toToolResult(
158
+ { success: false, error: { kind: "invalid_state", message: "Tool execution aborted before entering serialized lane" } },
159
+ def.name,
160
+ );
161
+ }
162
+ }
163
+ try {
164
+ const result = await def.handler(args, {
165
+ client,
166
+ signal,
167
+ onUpdate: (u) => {
168
+ if (onUpdate) {
169
+ const update: AgentToolResult<OkDetails> = {
170
+ content: [{ type: "text", text: u.text }],
171
+ details: { ok: true, ...(u.details ?? {}) },
172
+ };
173
+ onUpdate(update);
174
+ }
175
+ },
176
+ extensionCtx,
177
+ });
178
+ return toToolResult(result, def.name);
179
+ } finally {
180
+ release?.();
181
+ }
182
+ },
183
+ };
184
+ pi.registerTool(td);
185
+ };
@@ -0,0 +1,41 @@
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 "@earendil-works/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
+ };
36
+
37
+ export const formatBytes = (n: number): string => {
38
+ if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
39
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
40
+ return `${n} B`;
41
+ };