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,110 @@
1
+ import { isRecord } from "../util/guards";
2
+
3
+ export type WireRequest = {
4
+ readonly type: "request";
5
+ readonly id: number;
6
+ readonly method: string;
7
+ readonly params?: Record<string, unknown>;
8
+ readonly sessionId?: string;
9
+ };
10
+
11
+ export type WireResponse = {
12
+ readonly type: "response";
13
+ readonly id: number;
14
+ readonly result?: unknown;
15
+ readonly error?: {
16
+ readonly code: number;
17
+ readonly message: string;
18
+ readonly data?: string;
19
+ };
20
+ };
21
+
22
+ export type WireEvent = {
23
+ readonly type: "event";
24
+ readonly method: string;
25
+ readonly params?: Record<string, unknown>;
26
+ readonly sessionId?: string;
27
+ };
28
+
29
+ export type WireControl = {
30
+ readonly type: "control";
31
+ readonly action: "register" | "registered" | "deregister" | "shutdown";
32
+ readonly clientId?: string;
33
+ readonly reason?: string;
34
+ };
35
+
36
+ export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl;
37
+
38
+ const isWireMessage = (v: unknown): v is WireMessage => {
39
+ if (!isRecord(v)) return false;
40
+ const t = v["type"];
41
+ if (typeof t !== "string") return false;
42
+
43
+ switch (t) {
44
+ case "request": {
45
+ if (typeof v["id"] !== "number") return false;
46
+ if (typeof v["method"] !== "string") return false;
47
+ const sid = v["sessionId"];
48
+ if (sid !== undefined && typeof sid !== "string") return false;
49
+ return true;
50
+ }
51
+ case "response": {
52
+ if (typeof v["id"] !== "number") return false;
53
+ const errVal = v["error"];
54
+ if (errVal !== undefined) {
55
+ if (!isRecord(errVal)) return false;
56
+ if (typeof errVal["message"] !== "string") return false;
57
+ if (typeof errVal["code"] !== "number") return false;
58
+ }
59
+ return true;
60
+ }
61
+ case "event": {
62
+ if (typeof v["method"] !== "string") return false;
63
+ const sid = v["sessionId"];
64
+ if (sid !== undefined && typeof sid !== "string") return false;
65
+ return true;
66
+ }
67
+ case "control": {
68
+ const action = v["action"];
69
+ if (typeof action !== "string") return false;
70
+ if (!["register", "registered", "deregister", "shutdown"].includes(action)) return false;
71
+ const cid = v["clientId"];
72
+ if (
73
+ (action === "register" || action === "registered" || action === "deregister") &&
74
+ typeof cid !== "string"
75
+ ) {
76
+ return false;
77
+ }
78
+ return true;
79
+ }
80
+ default:
81
+ return false;
82
+ }
83
+ };
84
+
85
+ export const serialize = (msg: WireMessage): string => JSON.stringify(msg);
86
+
87
+ export const deserialize = (line: string): WireMessage | null => {
88
+ let parsed: unknown;
89
+ try {
90
+ parsed = JSON.parse(line);
91
+ } catch {
92
+ return null;
93
+ }
94
+ if (!isWireMessage(parsed)) return null;
95
+ return parsed;
96
+ };
97
+
98
+ // Windows requires a named pipe (`\\.\pipe\<name>`); a Unix path is not a valid `net` listen/connect target there.
99
+ export const DAEMON_SOCKET_PATH =
100
+ process.platform === "win32"
101
+ ? "\\\\.\\pipe\\pi-browser-daemon"
102
+ : "/tmp/pi-browser-daemon.sock";
103
+
104
+ export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
105
+
106
+ export const DAEMON_MAX_CLIENTS = 16;
107
+
108
+ export const CDP_CONNECT_TIMEOUT_MS = 10_000;
109
+
110
+ export const CDP_COMMAND_TIMEOUT_MS = 10_000;
@@ -0,0 +1,85 @@
1
+ import { type Result, err } from "../util/result";
2
+ import { type CdpError, cdpError } from "../cdp/errors";
3
+ import type { CdpEvent } from "../cdp/types";
4
+
5
+ export type EventQueue = {
6
+ readonly push: (e: CdpEvent) => void;
7
+ readonly end: () => void;
8
+ readonly iter: AsyncIterable<CdpEvent>;
9
+ };
10
+
11
+ export const makeEventQueue = (): EventQueue => {
12
+ const buf: CdpEvent[] = [];
13
+ const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = [];
14
+ let ended = false;
15
+ return {
16
+ push(e) {
17
+ if (ended) return;
18
+ const w = waiters.shift();
19
+ if (w) w({ value: e, done: false });
20
+ else buf.push(e);
21
+ },
22
+ end() {
23
+ ended = true;
24
+ for (const w of waiters.splice(0)) w({ value: undefined, done: true });
25
+ },
26
+ iter: {
27
+ [Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> {
28
+ return {
29
+ next: (): Promise<IteratorResult<CdpEvent, undefined>> =>
30
+ new Promise((resolve) => {
31
+ const next = buf.shift();
32
+ if (next) resolve({ value: next, done: false });
33
+ else if (ended) resolve({ value: undefined, done: true });
34
+ else waiters.push(resolve);
35
+ }),
36
+ };
37
+ },
38
+ },
39
+ };
40
+ };
41
+
42
+ export type CdpResult = Result<unknown, CdpError>;
43
+
44
+ export type Pending = {
45
+ readonly resolve: (v: Result<unknown, CdpError>) => void;
46
+ readonly timer: ReturnType<typeof setTimeout>;
47
+ readonly method: string;
48
+ };
49
+
50
+ export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => {
51
+ for (const [, p] of pending) {
52
+ clearTimeout(p.timer);
53
+ p.resolve(err(cdpError("transport_closed", reason, p.method)));
54
+ }
55
+ pending.clear();
56
+ };
57
+
58
+ export const sendWithTimeout = (
59
+ pending: Map<number, Pending>,
60
+ id: number,
61
+ method: string,
62
+ timeoutMs: number,
63
+ timeoutLabel: string,
64
+ send: () => void,
65
+ ): Promise<CdpResult> =>
66
+ new Promise((resolve) => {
67
+ const timer = setTimeout(() => {
68
+ pending.delete(id);
69
+ resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method)));
70
+ }, timeoutMs);
71
+ pending.set(id, { resolve, timer, method });
72
+ try {
73
+ send();
74
+ } catch (e) {
75
+ clearTimeout(timer);
76
+ pending.delete(id);
77
+ resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
78
+ }
79
+ });
80
+
81
+ export const makeOnClose = (closeListeners: Set<() => void>) =>
82
+ (cb: () => void): (() => void) => {
83
+ closeListeners.add(cb);
84
+ return () => closeListeners.delete(cb);
85
+ };
@@ -0,0 +1,170 @@
1
+ import { createServer, type Server, type Socket } from "node:net";
2
+ import { createInterface, type Interface } from "node:readline";
3
+ import { unlinkSync } from "node:fs";
4
+ import {
5
+ type WireMessage,
6
+ type WireControl,
7
+ deserialize,
8
+ serialize,
9
+ DAEMON_SOCKET_PATH,
10
+ DAEMON_MAX_CLIENTS,
11
+ } from "./protocol";
12
+
13
+ export type ClientSocket = {
14
+ id: string;
15
+ readonly socket: Socket;
16
+ readonly rl: Interface;
17
+ registered: boolean;
18
+ };
19
+
20
+ export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
21
+
22
+ export type ConnectionHandler = (client: ClientSocket) => void;
23
+
24
+ export type IpcServer = {
25
+ start(): Promise<void>;
26
+ stop(): Promise<void>;
27
+ onMessage(handler: MessageHandler): void;
28
+ onConnect(handler: ConnectionHandler): void;
29
+ onDisconnect(handler: ConnectionHandler): void;
30
+ send(clientId: string, msg: WireMessage): void;
31
+ broadcast(msg: WireMessage): void;
32
+ clientCount(): number;
33
+ };
34
+
35
+ export const createIpcServer = (): IpcServer => {
36
+ let server: Server | null = null;
37
+ const sockets = new Map<string, ClientSocket>();
38
+ const clients = new Map<string, ClientSocket>();
39
+ let messageHandler: MessageHandler | null = null;
40
+ let connectHandler: ConnectionHandler | null = null;
41
+ let disconnectHandler: ConnectionHandler | null = null;
42
+ let nextTempId = 1;
43
+
44
+ const start = (): Promise<void> =>
45
+ new Promise((resolve, reject) => {
46
+ try {
47
+ unlinkSync(DAEMON_SOCKET_PATH);
48
+ } catch {}
49
+
50
+ server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
51
+ if (clients.size >= DAEMON_MAX_CLIENTS) {
52
+ socket.destroy();
53
+ return;
54
+ }
55
+
56
+ const tempId = `anon-${nextTempId++}`;
57
+ const rl = createInterface({ input: socket, crlfDelay: Infinity });
58
+ let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false };
59
+
60
+ sockets.set(tempId, currentClient);
61
+
62
+ rl.on("line", (line: string) => {
63
+ const msg = deserialize(line);
64
+ if (!msg) return;
65
+
66
+ if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
67
+ const clientId = msg.clientId;
68
+ if (!clientId) return;
69
+
70
+ if (clients.has(clientId)) {
71
+ const reply: WireControl = {
72
+ type: "control",
73
+ action: "shutdown",
74
+ reason: `clientId ${clientId} is already connected`,
75
+ };
76
+ socket.write(serialize(reply) + "\n");
77
+ socket.destroy();
78
+ return;
79
+ }
80
+
81
+ // Mutate in place so the already-captured closure sees the promoted client.
82
+ sockets.delete(tempId);
83
+ currentClient.id = clientId;
84
+ currentClient.registered = true;
85
+ clients.set(clientId, currentClient);
86
+
87
+ const ack: WireControl = { type: "control", action: "registered", clientId };
88
+ socket.write(serialize(ack) + "\n");
89
+
90
+ connectHandler?.(currentClient);
91
+ return;
92
+ }
93
+
94
+ if (!currentClient.registered) return;
95
+
96
+ messageHandler?.(msg, currentClient);
97
+ });
98
+
99
+ socket.on("error", () => {});
100
+
101
+ socket.on("close", () => {
102
+ const found = clients.get(currentClient.id) ?? sockets.get(tempId);
103
+ if (found) {
104
+ clients.delete(found.id);
105
+ sockets.delete(tempId);
106
+ if (found.registered) {
107
+ disconnectHandler?.(found);
108
+ }
109
+ }
110
+ rl.close();
111
+ });
112
+ });
113
+
114
+ server.on("error", (err: NodeJS.ErrnoException) => {
115
+ reject(err);
116
+ });
117
+
118
+ server.listen(DAEMON_SOCKET_PATH, () => {
119
+ resolve();
120
+ });
121
+ });
122
+
123
+ const stop = (): Promise<void> =>
124
+ new Promise((resolve) => {
125
+ for (const [, client] of clients) {
126
+ try { client.socket.destroy(); } catch {}
127
+ }
128
+ for (const [, client] of sockets) {
129
+ try { client.socket.destroy(); } catch {}
130
+ }
131
+ clients.clear();
132
+ sockets.clear();
133
+
134
+ if (server) {
135
+ server.close(() => {
136
+ try { unlinkSync(DAEMON_SOCKET_PATH); } catch {}
137
+ resolve();
138
+ });
139
+ } else {
140
+ resolve();
141
+ }
142
+ });
143
+
144
+ const send = (clientId: string, msg: WireMessage): void => {
145
+ const client = clients.get(clientId);
146
+ if (!client) return;
147
+ try {
148
+ client.socket.write(serialize(msg) + "\n");
149
+ } catch {}
150
+ };
151
+
152
+ return {
153
+ start,
154
+ stop,
155
+ onMessage(handler) {
156
+ messageHandler = handler;
157
+ },
158
+ onConnect(handler) {
159
+ connectHandler = handler;
160
+ },
161
+ onDisconnect(handler) {
162
+ disconnectHandler = handler;
163
+ },
164
+ send,
165
+ broadcast(msg) {
166
+ for (const [clientId] of clients) send(clientId, msg);
167
+ },
168
+ clientCount: (): number => clients.size,
169
+ };
170
+ };
@@ -0,0 +1,124 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { access, unlink } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { createConnection } from "node:net";
7
+ import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
8
+
9
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
10
+
11
+ const requireFromHere = createRequire(import.meta.url);
12
+
13
+ const isWindows = process.platform === "win32";
14
+
15
+ // No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
16
+ const cleanupStaleSocket = (): void => {
17
+ if (isWindows) return;
18
+ unlink(DAEMON_SOCKET_PATH).catch(() => {});
19
+ };
20
+
21
+ export const daemonSocketMayExist = async (
22
+ platform: NodeJS.Platform = process.platform,
23
+ socketPath: string = DAEMON_SOCKET_PATH,
24
+ ): Promise<boolean> => {
25
+ if (platform === "win32") return true;
26
+ try {
27
+ await access(socketPath);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ };
33
+
34
+ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
35
+ if (!(await daemonSocketMayExist())) return false;
36
+
37
+ return new Promise((resolve) => {
38
+ let settled = false;
39
+ const done = (alive: boolean): void => {
40
+ if (settled) return;
41
+ settled = true;
42
+ resolve(alive);
43
+ };
44
+
45
+ const sock = createConnection(DAEMON_SOCKET_PATH);
46
+ const timer = setTimeout(() => {
47
+ sock.destroy();
48
+ cleanupStaleSocket();
49
+ done(false);
50
+ }, timeoutMs);
51
+
52
+ sock.on("connect", () => {
53
+ const regMsg: WireControl = { type: "control", action: "register", clientId: "_liveness_probe" };
54
+ sock.write(serialize(regMsg) + "\n");
55
+ });
56
+
57
+ sock.on("data", (chunk: Buffer) => {
58
+ const text = chunk.toString();
59
+ if (text.includes('"registered"')) {
60
+ clearTimeout(timer);
61
+ const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
62
+ try { sock.write(serialize(deregMsg) + "\n"); } catch {}
63
+ sock.destroy();
64
+ done(true);
65
+ }
66
+ });
67
+
68
+ sock.on("error", () => {
69
+ clearTimeout(timer);
70
+ sock.destroy();
71
+ cleanupStaleSocket();
72
+ done(false);
73
+ });
74
+
75
+ // Closed without a 'registered' ack: the daemon is alive but rejected us, so do NOT unlink its socket.
76
+ sock.on("close", () => {
77
+ clearTimeout(timer);
78
+ done(false);
79
+ });
80
+ });
81
+ };
82
+
83
+ // Node's own resolver, not a guessed `node_modules/.bin` path: pi installs every extension into
84
+ // one flat, hoisted tree, so tsx lands beside the package rather than inside it and a fixed
85
+ // `<pkg>/node_modules/.bin/tsx` lookup always misses. Resolving the CLI entry also means no shell,
86
+ // so the Windows `.cmd` shim and its argument quoting are gone with it.
87
+ export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => {
88
+ const daemonScript = join(moduleDir, "index.ts");
89
+ try {
90
+ return { command: process.execPath, args: [requireFromHere.resolve("tsx/cli"), daemonScript] };
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
95
+
96
+ export const spawnDaemon = (): ChildProcess | null => {
97
+ const spec = daemonSpawnCommand();
98
+ if (!spec) return null;
99
+
100
+ const child = spawn(spec.command, [...spec.args], {
101
+ detached: true,
102
+ stdio: "ignore",
103
+ env: { ...process.env },
104
+ windowsHide: true,
105
+ });
106
+
107
+ child.unref();
108
+ return child;
109
+ };
110
+
111
+ export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => {
112
+ if (await isDaemonRunning()) return true;
113
+
114
+ const child = spawnDaemon();
115
+ if (!child) return false;
116
+
117
+ const deadline = Date.now() + timeoutMs;
118
+ while (Date.now() < deadline) {
119
+ if (await isDaemonRunning()) return true;
120
+ await new Promise((r) => setTimeout(r, 200));
121
+ }
122
+
123
+ return false;
124
+ };
@@ -0,0 +1,178 @@
1
+ import { createConnection, type Socket } from "node:net";
2
+ import { createInterface } from "node:readline";
3
+ import { type Result, err, ok } from "../util/result";
4
+ import { type CdpError, cdpError, classifyRemoteError } from "../cdp/errors";
5
+ import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/types";
6
+ import type { CdpEvent } from "../cdp/types";
7
+ import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./queue";
8
+ import {
9
+ DAEMON_SOCKET_PATH,
10
+ type WireRequest,
11
+ type WireControl,
12
+ deserialize,
13
+ serialize,
14
+ } from "./protocol";
15
+
16
+ export const createDaemonTransport = (clientId: string): CdpTransport => {
17
+ let socket: Socket | null = null;
18
+ let rl: ReturnType<typeof createInterface> | null = null;
19
+ let queue = makeEventQueue();
20
+ const closeListeners = new Set<() => void>();
21
+ const pending = new Map<number, Pending>();
22
+ let registered = false;
23
+ let nextRequestId = 1;
24
+
25
+ const cleanup = (reason: string): void => {
26
+ rejectAllPending(pending, reason);
27
+ queue.end();
28
+ queue = makeEventQueue();
29
+ registered = false;
30
+
31
+ if (rl) { rl.close(); rl = null; }
32
+ if (socket) { try { socket.destroy(); } catch {} socket = null; }
33
+
34
+ for (const cb of closeListeners) cb();
35
+ };
36
+
37
+ const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => {
38
+ const timeoutMs = opts?.timeoutMs ?? 10_000;
39
+
40
+ if (socket && !socket.destroyed && registered) {
41
+ return Promise.resolve(ok(undefined));
42
+ }
43
+
44
+ return new Promise((resolve) => {
45
+ let settled = false;
46
+ const settle = (r: Result<void, CdpError>): void => {
47
+ if (settled) return;
48
+ settled = true;
49
+ resolve(r);
50
+ };
51
+
52
+ let sock: Socket;
53
+ try {
54
+ sock = createConnection(DAEMON_SOCKET_PATH);
55
+ } catch (e) {
56
+ settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
57
+ return;
58
+ }
59
+ socket = sock;
60
+
61
+ const connectTimer = setTimeout(() => {
62
+ cleanup("Connection timeout");
63
+ settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
64
+ }, timeoutMs);
65
+
66
+ sock.on("connect", () => {
67
+ clearTimeout(connectTimer);
68
+ rl = createInterface({ input: sock, crlfDelay: Infinity });
69
+
70
+ rl.on("line", (line: string) => {
71
+ const msg = deserialize(line);
72
+ if (!msg) return;
73
+
74
+ if (msg.type === "control" && msg.action === "registered" && msg.clientId === clientId) {
75
+ registered = true;
76
+ settle(ok(undefined));
77
+ return;
78
+ }
79
+
80
+ if (msg.type === "control" && msg.action === "shutdown") {
81
+ cleanup(`Daemon shutting down: ${msg.reason ?? "unknown"}`);
82
+ return;
83
+ }
84
+
85
+ if (!registered) return;
86
+
87
+ if (msg.type === "response") {
88
+ const p = pending.get(msg.id);
89
+ if (!p) return;
90
+ pending.delete(msg.id);
91
+ clearTimeout(p.timer);
92
+
93
+ if (msg.error) {
94
+ p.resolve(err(cdpError(classifyRemoteError(msg.error.message), msg.error.message, p.method)));
95
+ } else {
96
+ p.resolve(ok(msg.result));
97
+ }
98
+ return;
99
+ }
100
+
101
+ if (msg.type === "event") {
102
+ queue.push({
103
+ method: msg.method,
104
+ params: msg.params,
105
+ ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
106
+ });
107
+ return;
108
+ }
109
+ });
110
+
111
+ sock.on("error", () => {});
112
+
113
+ sock.on("close", () => {
114
+ clearTimeout(connectTimer);
115
+ cleanup("Daemon socket closed");
116
+ settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
117
+ });
118
+
119
+ const regMsg: WireControl = { type: "control", action: "register", clientId };
120
+ sock.write(serialize(regMsg) + "\n");
121
+ });
122
+
123
+ sock.on("error", (e: NodeJS.ErrnoException) => {
124
+ clearTimeout(connectTimer);
125
+ const msg = e.code === "ENOENT"
126
+ ? "Daemon not running — socket not found"
127
+ : e.message;
128
+ settle(err(cdpError("transport_closed", msg)));
129
+ });
130
+ });
131
+ };
132
+
133
+ const close = async (): Promise<void> => {
134
+ if (registered && socket && !socket.destroyed) {
135
+ const dereg: WireControl = { type: "control", action: "deregister", clientId };
136
+ try { socket.write(serialize(dereg) + "\n"); } catch {}
137
+ }
138
+ cleanup("close() called");
139
+ };
140
+
141
+ const request = (
142
+ method: string,
143
+ params: Record<string, unknown>,
144
+ opts?: { sessionId?: string | null; timeoutMs?: number },
145
+ ): Promise<Result<unknown, CdpError>> => {
146
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
147
+ const sessionId = opts?.sessionId ?? null;
148
+
149
+ const sock = socket;
150
+ if (sock === null || sock.destroyed || !registered) {
151
+ return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method)));
152
+ }
153
+
154
+ const id = nextRequestId++;
155
+ const req: WireRequest = {
156
+ type: "request",
157
+ id,
158
+ method,
159
+ params,
160
+ ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
161
+ };
162
+
163
+ return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));
164
+ };
165
+
166
+ const events = (): AsyncIterable<CdpEvent> => queue.iter;
167
+
168
+ const state = (): "open" | "closed" | "connecting" => {
169
+ if (!socket) return "closed";
170
+ if (!registered) return "connecting";
171
+ if (socket.destroyed) return "closed";
172
+ return "open";
173
+ };
174
+
175
+ const onClose = makeOnClose(closeListeners);
176
+
177
+ return { connect, close, request, events, state, onClose };
178
+ };