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,153 @@
1
+ // `window.open` with userGesture is the only CDP-reachable way into a non-default browser context: createTarget rejects a foreign browserContextId and `openerId` does not inherit the opener's context.
2
+
3
+ import { randomUUID } from "node:crypto";
4
+ import type { BrowserClient } from "../client";
5
+ import { type Result, err, ok } from "../util/result";
6
+ import { type CdpError, cdpError } from "./errors";
7
+
8
+ const SPAWN_DEADLINE_MS = 8_000;
9
+ const SPAWN_POLL_MS = 100;
10
+
11
+ export type HarnessWindow = {
12
+ readonly targetId: string;
13
+ readonly freshlyCreated: boolean;
14
+ };
15
+
16
+ type PageTarget = {
17
+ readonly targetId: string;
18
+ readonly type: string;
19
+ readonly url: string;
20
+ readonly browserContextId?: string;
21
+ };
22
+
23
+ const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
24
+ const r = await client.session().callBrowser("Target.getTargets");
25
+ if (!r.success) return r;
26
+ return ok(r.data.targetInfos.filter((t) => t.type === "page"));
27
+ };
28
+
29
+ const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
30
+ const current = client.current();
31
+ if (current?.targetId === targetId) return ok(current.sessionId);
32
+ return client.session().attach(targetId);
33
+ };
34
+
35
+ const spawnTabViaOpener = async (
36
+ client: BrowserClient,
37
+ openerTargetId: string,
38
+ ): Promise<Result<string, CdpError>> => {
39
+ const session = await sessionForTarget(client, openerTargetId);
40
+ if (!session.success) return session;
41
+
42
+ const token = `pi-${randomUUID()}`;
43
+ // userGesture: true is what gets this past the popup blocker.
44
+ const opened = await client.session().callOnTarget(
45
+ "Runtime.evaluate",
46
+ {
47
+ expression: `!!window.open('about:blank#${token}', '_blank')`,
48
+ returnByValue: true,
49
+ userGesture: true,
50
+ },
51
+ session.data,
52
+ );
53
+ if (!opened.success) return opened;
54
+ if (opened.data.result.value !== true) {
55
+ return err(cdpError(
56
+ "invalid_response",
57
+ "the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
58
+ "Runtime.evaluate",
59
+ ));
60
+ }
61
+
62
+ const deadline = Date.now() + SPAWN_DEADLINE_MS;
63
+ while (Date.now() < deadline) {
64
+ const targets = await listPageTargets(client);
65
+ if (targets.success) {
66
+ const spawned = targets.data.find((t) => t.url.includes(token));
67
+ if (spawned) return ok(spawned.targetId);
68
+ }
69
+ await new Promise((r) => setTimeout(r, SPAWN_POLL_MS));
70
+ }
71
+ return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`));
72
+ };
73
+
74
+ const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>();
75
+
76
+ export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => {
77
+ const pending = inFlight.get(client);
78
+ if (pending) {
79
+ const shared = await pending;
80
+ // `freshlyCreated` is a claim on the seed tab that only the caller which started the creation may hold, or two callers would drive the same tab.
81
+ return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared;
82
+ }
83
+ const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client));
84
+ inFlight.set(client, attempt);
85
+ return attempt;
86
+ };
87
+
88
+ const ensureHarnessWindowUncoordinated = async (
89
+ client: BrowserClient,
90
+ ): Promise<Result<HarnessWindow, CdpError>> => {
91
+ const targets = await listPageTargets(client);
92
+ if (!targets.success) return targets;
93
+ const live = new Set(targets.data.map((t) => t.targetId));
94
+
95
+ const ownership = client.ownership();
96
+ const pinnedCtx = client.profileContextId();
97
+ // With a pin set but no context resolved yet, a recorded tab's profile cannot be verified, so seed a new window rather than risk adopting one from the wrong profile.
98
+ const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
99
+ const inPinnedProfile = (targetId: string): boolean => {
100
+ if (!pinnedCtx) return true;
101
+ const info = targets.data.find((t) => t.targetId === targetId);
102
+ return info?.browserContextId === pinnedCtx;
103
+ };
104
+
105
+ if (canReuse) {
106
+ const harnessWindow = ownership.harnessWindow();
107
+ if (harnessWindow && live.has(harnessWindow) && inPinnedProfile(harnessWindow)) {
108
+ return ok({ targetId: harnessWindow, freshlyCreated: false });
109
+ }
110
+ const survivor = ownership.list().find((id) => live.has(id) && inPinnedProfile(id));
111
+ if (survivor) {
112
+ ownership.setHarnessWindow(survivor);
113
+ return ok({ targetId: survivor, freshlyCreated: false });
114
+ }
115
+ }
116
+
117
+ const seeded = client.profilePin()
118
+ ? await client.seedPinnedProfileWindow()
119
+ : await createDedicatedWindow(client);
120
+ if (!seeded.success) return seeded;
121
+ return ok({ targetId: seeded.data, freshlyCreated: true });
122
+ };
123
+
124
+ const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
125
+ const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
126
+ if (!created.success) return created;
127
+ const { targetId } = created.data;
128
+ const ownership = client.ownership();
129
+ ownership.setHarnessWindow(targetId);
130
+ ownership.add(targetId);
131
+ const win = await client.session().windowId(targetId);
132
+ if (win.success) ownership.setHarnessWindowId(win.data);
133
+ return ok(targetId);
134
+ };
135
+
136
+ export const openHarnessTab = async (
137
+ client: BrowserClient,
138
+ openerTargetId: string,
139
+ ): Promise<Result<string, CdpError>> => {
140
+ const spawned = client.profileContextId()
141
+ ? await spawnTabViaOpener(client, openerTargetId)
142
+ : await (async (): Promise<Result<string, CdpError>> => {
143
+ const created = await client.session().callBrowser("Target.createTarget", {
144
+ url: "about:blank",
145
+ openerId: openerTargetId,
146
+ });
147
+ if (!created.success) return created;
148
+ return ok(created.data.targetId);
149
+ })();
150
+ if (!spawned.success) return spawned;
151
+ client.ownership().add(spawned.data);
152
+ return ok(spawned.data);
153
+ };
@@ -0,0 +1,82 @@
1
+ import type { Result } from "../util/result";
2
+ import { isRecord } from "../util/guards";
3
+ import type { CdpError } from "./errors";
4
+
5
+ export type CdpEvent = {
6
+ readonly method: string;
7
+ readonly params: unknown;
8
+ readonly sessionId?: string;
9
+ };
10
+
11
+ export type CdpRawMessage = {
12
+ readonly id?: number;
13
+ readonly method?: string;
14
+ readonly params?: Record<string, unknown>;
15
+ readonly result?: unknown;
16
+ readonly error?: { readonly message: string; readonly code?: number };
17
+ readonly sessionId?: string;
18
+ };
19
+
20
+ export type DialogInfo = {
21
+ readonly type: "alert" | "confirm" | "prompt" | "beforeunload";
22
+ readonly message: string;
23
+ readonly defaultPrompt?: string;
24
+ };
25
+
26
+ export type TabInfo = {
27
+ readonly targetId: string;
28
+ readonly title: string;
29
+ readonly url: string;
30
+ readonly owned?: boolean;
31
+ };
32
+
33
+ export type PageInfo = {
34
+ readonly url: string;
35
+ readonly title: string;
36
+ readonly width: number;
37
+ readonly height: number;
38
+ readonly scrollX: number;
39
+ readonly scrollY: number;
40
+ readonly pageWidth: number;
41
+ readonly pageHeight: number;
42
+ };
43
+
44
+ export type DaemonStatus = {
45
+ readonly alive: boolean;
46
+ readonly sessionId: string | null;
47
+ readonly namespace: string;
48
+ readonly remoteBrowserId?: string;
49
+ };
50
+
51
+ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
52
+ if (!isRecord(v)) return false;
53
+ const id = v["id"];
54
+ if (id !== undefined && typeof id !== "number") return false;
55
+ const method = v["method"];
56
+ if (method !== undefined && typeof method !== "string") return false;
57
+ const sessionId = v["sessionId"];
58
+ if (sessionId !== undefined && typeof sessionId !== "string") return false;
59
+ const errVal = v["error"];
60
+ if (errVal !== undefined) {
61
+ if (!isRecord(errVal)) return false;
62
+ if (typeof errVal["message"] !== "string") return false;
63
+ const code = errVal["code"];
64
+ if (code !== undefined && typeof code !== "number") return false;
65
+ }
66
+ return true;
67
+ };
68
+
69
+ export const DEFAULT_TIMEOUT_MS = 15_000;
70
+
71
+ export type CdpTransport = {
72
+ connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
73
+ close(): Promise<void>;
74
+ request(
75
+ method: string,
76
+ params: Record<string, unknown>,
77
+ opts?: { sessionId?: string | null; timeoutMs?: number },
78
+ ): Promise<Result<unknown, CdpError>>;
79
+ events(): AsyncIterable<CdpEvent>;
80
+ state(): "open" | "closed" | "connecting";
81
+ onClose(cb: () => void): () => void;
82
+ };
@@ -0,0 +1,366 @@
1
+ import { Type } from "typebox";
2
+ import { Compile } from "typebox/compile";
3
+ import { parseJson } from "./schemas/parse";
4
+ import { type Result, err, ok } from "./util/result";
5
+ import { safeJs } from "./util/js-template";
6
+ import { type Mutex, createMutex } from "./util/mutex";
7
+ import { discoverEndpoint } from "./cdp/discovery";
8
+ import { type CdpError, cdpError } from "./cdp/errors";
9
+ import type { CdpTransport } from "./cdp/types";
10
+ import { type CdpSession, createCdpSession } from "./cdp/session";
11
+ import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
12
+ import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
13
+ import { seedProfileWindow } from "./profile/bind";
14
+ import type { ProfilePin } from "./profile/store";
15
+ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
16
+
17
+ export type BrowserClientOptions = {
18
+ readonly namespace: string;
19
+ readonly transport: CdpTransport;
20
+ readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
21
+ readonly initialOwnership?: {
22
+ readonly ownedTargetIds?: ReadonlyArray<string>;
23
+ readonly harnessWindowTargetId?: string;
24
+ readonly harnessWindowId?: number;
25
+ };
26
+ readonly profilePin?: ProfilePin | null;
27
+ readonly onOwnershipChange?: (snapshot: {
28
+ readonly ownedTargetIds: ReadonlyArray<string>;
29
+ readonly harnessWindowTargetId: string | undefined;
30
+ readonly harnessWindowId: number | undefined;
31
+ }) => void;
32
+ };
33
+
34
+ export type BrowserClient = {
35
+ readonly namespace: string;
36
+ ensureAlive(): Promise<Result<void, CdpError>>;
37
+ status(): DaemonStatus;
38
+ start(): Promise<Result<void, CdpError>>;
39
+ stop(): Promise<void>;
40
+ detach(): Promise<void>;
41
+ closeOwnedTabs(): Promise<void>;
42
+ evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
43
+ pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
44
+ takeDialog(): DialogInfo | null;
45
+ listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>;
46
+ switchTab(targetId: string): Promise<Result<void, CdpError>>;
47
+ newTab(url?: string): Promise<Result<string, CdpError>>;
48
+ closeTab(targetId: string): Promise<Result<void, CdpError>>;
49
+ owns(targetId: string): boolean;
50
+ ownership(): OwnershipRegistry;
51
+ current(): { readonly sessionId: string; readonly targetId: string } | null;
52
+ session(): CdpSession;
53
+ transport(): CdpTransport;
54
+ userDataDir(): string | undefined;
55
+ profilePin(): ProfilePin | null;
56
+ setProfilePin(pin: ProfilePin | null): void;
57
+ profileContextId(): string | undefined;
58
+ setProfileContextId(contextId: string | undefined): void;
59
+ seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
60
+ mutationMutex(): Mutex;
61
+ };
62
+
63
+ const HEALTH_TTL_MS = 30_000;
64
+ const PAGE_INFO_TTL_MS = 1_000;
65
+
66
+ const PageInfoPayload = Type.Object({
67
+ url: Type.String(),
68
+ title: Type.String(),
69
+ w: Type.Number(),
70
+ h: Type.Number(),
71
+ sx: Type.Number(),
72
+ sy: Type.Number(),
73
+ pw: Type.Number(),
74
+ ph: Type.Number(),
75
+ });
76
+
77
+ const pageInfoValidator = Compile(PageInfoPayload);
78
+
79
+ const parsePageInfoPayload = (raw: string): Result<PageInfo, CdpError> => {
80
+ const parsed = parseJson(raw, pageInfoValidator);
81
+ if (!parsed.success) {
82
+ return err(cdpError("invalid_response", `page info payload is invalid: ${parsed.error}`));
83
+ }
84
+ const o = parsed.data;
85
+ return ok({
86
+ url: o.url,
87
+ title: o.title,
88
+ width: o.w,
89
+ height: o.h,
90
+ scrollX: o.sx,
91
+ scrollY: o.sy,
92
+ pageWidth: o.pw,
93
+ pageHeight: o.ph,
94
+ });
95
+ };
96
+
97
+ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
98
+ const transport = opts.transport;
99
+ const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
100
+ if (opts.initialOwnership?.ownedTargetIds !== undefined) {
101
+ ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
102
+ }
103
+ if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
104
+ ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
105
+ }
106
+ if (opts.initialOwnership?.harnessWindowId !== undefined) {
107
+ ownershipInit.harnessWindowId = opts.initialOwnership.harnessWindowId;
108
+ }
109
+ const ownership = createOwnershipRegistry(ownershipInit);
110
+ if (opts.onOwnershipChange) {
111
+ const cb = opts.onOwnershipChange;
112
+ ownership.onChange(() => {
113
+ cb({
114
+ ownedTargetIds: ownership.list(),
115
+ harnessWindowTargetId: ownership.harnessWindow(),
116
+ harnessWindowId: ownership.harnessWindowId(),
117
+ });
118
+ });
119
+ }
120
+ const session = createCdpSession(transport, ownership, async () => {
121
+ const window = await ensureHarnessWindow(api);
122
+ return window.success ? ok(window.data.targetId) : window;
123
+ });
124
+ const mutationMutex = createMutex();
125
+ let lastHealth = 0;
126
+ let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
127
+ let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
128
+ let userDataDir: string | undefined;
129
+ let profilePin: ProfilePin | null = opts.profilePin ?? null;
130
+ let profileContextId: string | undefined;
131
+
132
+ // With a pin set the window must exist BEFORE anything attaches, or attachFirstPage's bare Target.createTarget lands in the focus-derived default profile.
133
+ const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
134
+ if (!profilePin) return ok(undefined);
135
+ const window = await ensureHarnessWindow(api);
136
+ if (!window.success) return window;
137
+ return ok(undefined);
138
+ };
139
+
140
+ const start = async (): Promise<Result<void, CdpError>> => {
141
+ if (transport.state() === "open" && session.current()) return ok(undefined);
142
+ let wsUrl: string;
143
+ if (remote?.cdpUrl) {
144
+ wsUrl = remote.cdpUrl;
145
+ } else {
146
+ const discovered = await discoverEndpoint();
147
+ if (!discovered.success) return discovered;
148
+ wsUrl = discovered.data.wsUrl;
149
+ userDataDir = discovered.data.userDataDir;
150
+ }
151
+ const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
152
+ if (!connected.success) return connected;
153
+
154
+ const newBrowserId: string = wsUrl.split("/").pop() ?? "unknown";
155
+ if (!remote || remote.browserId !== newBrowserId) {
156
+ profileContextId = undefined;
157
+ }
158
+ remote = { cdpUrl: wsUrl, browserId: newBrowserId };
159
+ const seeded = await seedIfPinned();
160
+ if (!seeded.success) {
161
+ await transport.close();
162
+ return seeded;
163
+ }
164
+ const attached = await session.attachFirstPage();
165
+ if (!attached.success) {
166
+ await transport.close();
167
+ return attached;
168
+ }
169
+ lastHealth = Date.now();
170
+ pageCaches.clear();
171
+ return ok(undefined);
172
+ };
173
+
174
+ const stop = async (): Promise<void> => {
175
+ await transport.close();
176
+ pageCaches.clear();
177
+ lastHealth = 0;
178
+ };
179
+
180
+ const ensureAlive = async (): Promise<Result<void, CdpError>> => {
181
+ if (transport.state() !== "open" || !session.current()) {
182
+ await stop();
183
+ return start();
184
+ }
185
+ if (Date.now() - lastHealth < HEALTH_TTL_MS) return ok(undefined);
186
+ const probe = await transport.request("Target.getTargets", {}, { sessionId: null, timeoutMs: 2_000 });
187
+ if (!probe.success) {
188
+ await stop();
189
+ return start();
190
+ }
191
+ lastHealth = Date.now();
192
+ const jsProbe = await session.call("Runtime.evaluate", {
193
+ expression: "1", returnByValue: true,
194
+ }, { timeoutMs: 2_000 });
195
+ if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
196
+ const seeded = await seedIfPinned();
197
+ if (!seeded.success) return seeded;
198
+ const reattached = await session.attachFirstPage();
199
+ if (reattached.success) {
200
+ pageCaches.clear();
201
+ return ok(undefined);
202
+ }
203
+ await stop();
204
+ return start();
205
+ }
206
+ return ok(undefined);
207
+ };
208
+
209
+ const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
210
+ // Wrap only when the trimmed source *starts with* `return` — matching the substring anywhere silently turned expressions mentioning it into `undefined`.
211
+ const trimmed = expression.trim();
212
+ const isReturnStatement = /^return[\s(]/.test(trimmed);
213
+ const wrapped = isReturnStatement ? `(function(){${expression}})()` : expression;
214
+ const r = sessionId
215
+ ? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId)
216
+ : await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true });
217
+ if (!r.success) return r;
218
+ if (r.data.exceptionDetails !== undefined) {
219
+ return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(r.data.exceptionDetails)}`, "Runtime.evaluate"));
220
+ }
221
+ return ok(r.data.result.value);
222
+ };
223
+
224
+ const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
225
+ const dirty = session.drainPageInfoInvalidations();
226
+ const currentTid = session.current()?.targetId;
227
+ const cached = currentTid ? pageCaches.get(currentTid) : undefined;
228
+ if (cached && !dirty && Date.now() - cached.at < PAGE_INFO_TTL_MS) return ok(cached.info);
229
+ const expr = safeJs`JSON.stringify((function(){var e=document.documentElement||{scrollWidth:0,scrollHeight:0};return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:e.scrollWidth,ph:e.scrollHeight};})())`;
230
+ const raw = await evaluateJs(expr);
231
+ if (!raw.success) return raw;
232
+ if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string"));
233
+ const info = parsePageInfoPayload(raw.data);
234
+ if (!info.success) return info;
235
+ if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
236
+ return ok(info.data);
237
+ };
238
+
239
+ const pageInfo = async (): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>> => {
240
+ const d = session.takeDialog();
241
+ if (d) return ok({ dialog: d });
242
+ return readPageInfo();
243
+ };
244
+
245
+ const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => {
246
+ const r = await session.callBrowser("Target.getTargets");
247
+ if (!r.success) return r;
248
+ const tabs = r.data.targetInfos
249
+ .filter((t) => t.type === "page")
250
+ .filter((t) => includeInternal || !t.url.startsWith("chrome://"))
251
+ .map((t): TabInfo => ({
252
+ targetId: t.targetId,
253
+ title: t.title,
254
+ url: t.url,
255
+ owned: ownership.has(t.targetId),
256
+ }));
257
+ const live = new Set(tabs.map((t) => t.targetId));
258
+ const owned = ownership.list();
259
+ const survivors = owned.filter((id) => live.has(id));
260
+ if (survivors.length !== owned.length) ownership.replaceAll(survivors);
261
+ const hw = ownership.harnessWindow();
262
+ if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
263
+ for (const tid of pageCaches.keys()) {
264
+ if (!live.has(tid)) pageCaches.delete(tid);
265
+ }
266
+ return ok(tabs);
267
+ };
268
+
269
+ const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
270
+ const r = await session.switchTo(targetId);
271
+ if (!r.success) return r;
272
+ await session.call("Runtime.evaluate", {
273
+ expression: safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
274
+ });
275
+ return ok(undefined);
276
+ };
277
+
278
+ const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
279
+ const tabsResult = await listTabs(true);
280
+ if (!tabsResult.success) return tabsResult;
281
+
282
+ const window = await ensureHarnessWindow(api);
283
+ if (!window.success) return window;
284
+ let tabId: string;
285
+ if (window.data.freshlyCreated) {
286
+ tabId = window.data.targetId;
287
+ } else {
288
+ const opened = await openHarnessTab(api, window.data.targetId);
289
+ if (!opened.success) return opened;
290
+ tabId = opened.data;
291
+ }
292
+
293
+ const switched = await switchTab(tabId);
294
+ if (!switched.success) return switched;
295
+ if (url && url !== "about:blank") {
296
+ const nav = await session.call("Page.navigate", { url });
297
+ if (!nav.success) return nav;
298
+ }
299
+ return ok(tabId);
300
+ };
301
+
302
+ const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
303
+ const r = await session.callBrowser("Target.closeTarget", { targetId });
304
+ if (!r.success) return r;
305
+ ownership.remove(targetId);
306
+ return ok(undefined);
307
+ };
308
+
309
+ const closeOwnedTabs = async (): Promise<void> => {
310
+ for (const id of ownership.list()) {
311
+ await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
312
+ ownership.remove(id);
313
+ }
314
+ ownership.setHarnessWindow(undefined);
315
+ ownership.setHarnessWindowId(undefined);
316
+ };
317
+
318
+ const status = (): DaemonStatus => ({
319
+ alive: transport.state() === "open" && session.current() !== null,
320
+ sessionId: session.current()?.sessionId ?? null,
321
+ namespace: opts.namespace,
322
+ ...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}),
323
+ });
324
+
325
+ const detach = async (): Promise<void> => {
326
+ const cur = session.current();
327
+ if (!cur) return;
328
+ await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
329
+ };
330
+
331
+ const api: BrowserClient = {
332
+ namespace: opts.namespace,
333
+ ensureAlive, status, start, stop, detach, closeOwnedTabs,
334
+ evaluateJs, pageInfo,
335
+ takeDialog: () => session.takeDialog(),
336
+ listTabs, switchTab, newTab, closeTab,
337
+ owns: (id: string) => ownership.has(id),
338
+ ownership: () => ownership,
339
+ current: () => session.current(),
340
+ session: () => session,
341
+ transport: () => transport,
342
+ userDataDir: () => userDataDir,
343
+ profilePin: () => profilePin,
344
+ setProfilePin: (pin) => {
345
+ const changed = pin === null
346
+ ? profilePin !== null
347
+ : profilePin === null || profilePin.userDataDir !== pin.userDataDir || profilePin.profileDir !== pin.profileDir;
348
+ profilePin = pin;
349
+ if (changed) {
350
+ profileContextId = undefined;
351
+ }
352
+ },
353
+ profileContextId: () => profileContextId,
354
+ setProfileContextId: (contextId) => {
355
+ profileContextId = contextId;
356
+ },
357
+ seedPinnedProfileWindow: async () => {
358
+ if (!profilePin) {
359
+ return err(cdpError("discovery_failed", "no browser profile is selected — run /browser-profile"));
360
+ }
361
+ return seedProfileWindow(api, profilePin);
362
+ },
363
+ mutationMutex: () => mutationMutex,
364
+ };
365
+ return api;
366
+ };