@rynx-ai/daemon 0.1.11-beta.45 → 0.1.11-beta.49

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.
@@ -3,13 +3,14 @@ import { execFile, spawn } from "node:child_process";
3
3
  import { accessSync, chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
4
4
  import { constants as fsConstants } from "node:fs";
5
5
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
- import { rynxHome } from "@rynx-ai/core";
6
+ import { rynxHome, diagnosticEvents, sessionDiagnosticDir, teeDiagnosticStream } from "@rynx-ai/core";
7
7
  import { RUNTIME_BROWSER_MAX_PAGES, RUNTIME_BROWSER_MAX_TITLE_CHARS, RUNTIME_BROWSER_MAX_URL_CHARS, } from "@rynx-ai/protocol/runtime-browser";
8
8
  import { RUNTIME_BROWSER_SURFACE_MAX_ENCODED_FRAME_BYTES, RUNTIME_BROWSER_SURFACE_MAX_FRAME_RATE, RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_HEIGHT, RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_PIXELS, RUNTIME_BROWSER_SURFACE_MAX_VIEWPORT_WIDTH, } from "@rynx-ai/protocol/runtime-browser-surface";
9
9
  import { SessionBrowserHostError, } from "@rynx-ai/server";
10
10
  import WebSocket from "ws";
11
11
  import { createChromeForTestingArtifactStore, } from "./chrome-for-testing-store.js";
12
12
  import { cdpKeyboardEventParams } from "./cdp-keyboard-input.js";
13
+ import { createPageCdpGateway } from "./page-cdp-gateway.js";
13
14
  const PROFILE_OWNER_FILE = ".rynx-headless-browser-profile.json";
14
15
  const PROCESS_LEASE_FILE = ".rynx-headless-browser-process.json";
15
16
  const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
@@ -124,6 +125,13 @@ class HeadlessBrowserHost {
124
125
  throw unavailable("Managed Chrome for Testing could not be launched", error);
125
126
  }
126
127
  const observed = observeProcess(child);
128
+ const logRoot = sessionDiagnosticDir("browser", input.sessionId);
129
+ const instance = String(input.browserGeneration);
130
+ const event = diagnosticEvents(join(logRoot, "browser-host.log"), { sessionId: input.sessionId, instance });
131
+ teeDiagnosticStream(child.stderr, join(logRoot, "instances", instance, "chrome.stderr.log"));
132
+ event("chrome.spawn", { pid: child.pid });
133
+ child.on("exit", (code, signal) => event("chrome.exit", { code, signal }));
134
+ child.on("error", (error) => event("chrome.error", { message: error.message }));
127
135
  const diagnostics = boundedStderr(child);
128
136
  let connection;
129
137
  let handle;
@@ -217,6 +225,7 @@ class HeadlessBrowserHandle {
217
225
  networkTargetTypeBySession = new Map();
218
226
  surfaceSources = new Map();
219
227
  loadingTargets = new Set();
228
+ automationGateways = new Map();
220
229
  activeTargetId = null;
221
230
  unavailableReason;
222
231
  closing = false;
@@ -224,8 +233,12 @@ class HeadlessBrowserHandle {
224
233
  operations = Promise.resolve();
225
234
  networkOperations = Promise.resolve();
226
235
  requestHeaders;
236
+ diagnostic;
227
237
  constructor(host) {
228
238
  this.host = host;
239
+ this.diagnostic = diagnosticEvents(join(sessionDiagnosticDir("browser", host.input.sessionId), "page-events.jsonl"), {
240
+ sessionId: host.input.sessionId, browserGeneration: host.input.browserGeneration,
241
+ });
229
242
  this.requestHeaders = cloneRequestHeadersConfig(host.input.requestHeaders ?? { enabled: false, headers: [] });
230
243
  this.events = this.eventQueue;
231
244
  this.host.connection.setEventHandler((event) => this.onCdpEvent(event));
@@ -253,7 +266,8 @@ class HeadlessBrowserHandle {
253
266
  throw new Error("Headless Browser startup Page is unavailable");
254
267
  const pageSessionId = await this.pageSession(targetId);
255
268
  await this.host.connection.command("Page.navigate", { url: requestedUrl }, pageSessionId);
256
- await this.resetPageNavigationHistory(targetId);
269
+ // Do not reset navigation history while child frames are committing.
270
+ // Chrome can discard a pending OOPIF navigation and leave it blank.
257
271
  }
258
272
  }
259
273
  async getCdpEndpoint() {
@@ -276,6 +290,53 @@ class HeadlessBrowserHandle {
276
290
  engineVersion: this.host.engineVersion,
277
291
  };
278
292
  }
293
+ async getPageAutomationEndpoint(hostPageId) {
294
+ this.ensureAvailable();
295
+ const target = this.requireTarget(hostPageId);
296
+ let gateway = this.automationGateways.get(hostPageId);
297
+ if (!gateway) {
298
+ gateway = createPageCdpGateway(this.host.endpoint, target.targetId, () => !this.closing && !this.unavailableReason && this.targets.has(hostPageId));
299
+ this.automationGateways.set(hostPageId, gateway);
300
+ void gateway.catch(() => { if (this.automationGateways.get(hostPageId) === gateway)
301
+ this.automationGateways.delete(hostPageId); });
302
+ }
303
+ return { endpoint: (await gateway).endpoint, engineVersion: this.host.engineVersion, pageTargetId: target.targetId };
304
+ }
305
+ acquireAutomationVisibility(hostPageId, canActivate = () => true) {
306
+ return this.exclusive(async () => {
307
+ this.ensureAvailable();
308
+ this.requireTarget(hostPageId);
309
+ if (!canActivate())
310
+ return { release: async () => undefined };
311
+ const previousTargetId = this.activeTargetId;
312
+ try {
313
+ await this.host.connection.command("Target.activateTarget", { targetId: hostPageId });
314
+ }
315
+ catch (error) {
316
+ throw this.mapOperationError(error);
317
+ }
318
+ let released = false;
319
+ return {
320
+ release: async (restoreFocus = true) => {
321
+ if (released)
322
+ return;
323
+ released = true;
324
+ await this.exclusive(async () => {
325
+ // A user selection or closed generation supersedes the temporary activation.
326
+ if (!restoreFocus || !canActivate() || this.closing || this.unavailableReason || !previousTargetId ||
327
+ this.activeTargetId !== previousTargetId || !this.targets.has(previousTargetId))
328
+ return;
329
+ try {
330
+ await this.host.connection.command("Target.activateTarget", { targetId: previousTargetId });
331
+ }
332
+ catch (error) {
333
+ throw this.mapOperationError(error);
334
+ }
335
+ });
336
+ },
337
+ };
338
+ });
339
+ }
279
340
  openSurface(input) {
280
341
  return this.exclusive(async () => {
281
342
  this.ensureAvailable();
@@ -361,7 +422,8 @@ class HeadlessBrowserHandle {
361
422
  if (url !== "about:blank") {
362
423
  const pageSessionId = await this.pageSession(targetId);
363
424
  await this.host.connection.command("Page.navigate", { url }, pageSessionId);
364
- await this.resetPageNavigationHistory(targetId);
425
+ // Leave Chrome's native history intact. Resetting it here can
426
+ // discard a pending cross-origin child navigation.
365
427
  }
366
428
  this.eventQueue.changed(this.browserGeneration);
367
429
  }
@@ -435,6 +497,8 @@ class HeadlessBrowserHandle {
435
497
  return this.closePromise;
436
498
  this.closing = true;
437
499
  this.closePromise = (async () => {
500
+ await Promise.allSettled([...this.automationGateways.values()].map(async (gateway) => (await gateway).close()));
501
+ this.automationGateways.clear();
438
502
  await Promise.allSettled([...this.surfaceSources.values()].map((source) => source.close()));
439
503
  let terminated = false;
440
504
  try {
@@ -483,24 +547,6 @@ class HeadlessBrowserHandle {
483
547
  }
484
548
  });
485
549
  }
486
- async resetPageNavigationHistory(targetId) {
487
- let lastError;
488
- for (let attempt = 0; attempt < PAGE_SNAPSHOT_TRANSITION_ATTEMPTS; attempt += 1) {
489
- const sessionId = await this.pageSession(targetId);
490
- try {
491
- await this.host.connection.command("Page.resetNavigationHistory", {}, sessionId);
492
- return;
493
- }
494
- catch (error) {
495
- if (!isMissingTargetError(error) && !isTransientPageTransitionError(error))
496
- throw error;
497
- lastError = error;
498
- this.dropAttachedSession(sessionId);
499
- await delay(PAGE_SNAPSHOT_TRANSITION_POLL_MS);
500
- }
501
- }
502
- throw lastError ?? new Error("Headless Browser could not reset Page navigation history");
503
- }
504
550
  pageCommand(hostPageId, method, params) {
505
551
  return this.exclusive(async () => {
506
552
  this.ensureAvailable();
@@ -624,9 +670,11 @@ class HeadlessBrowserHandle {
624
670
  for (let attempt = 0; attempt < PAGE_SNAPSHOT_TRANSITION_ATTEMPTS; attempt += 1) {
625
671
  let history;
626
672
  try {
627
- history = parseNavigationHistory(await this.host.connection.command("Page.getNavigationHistory", {}, sessionId));
673
+ history = parseNavigationHistory(await this.host.connection.command("Page.getNavigationHistory", {}, sessionId, 500));
628
674
  }
629
675
  catch (error) {
676
+ if (error instanceof CdpCommandTimeoutError)
677
+ break;
630
678
  if (!isTransientPageTransitionError(error))
631
679
  throw error;
632
680
  if (attempt < PAGE_SNAPSHOT_TRANSITION_ATTEMPTS - 1) {
@@ -634,21 +682,14 @@ class HeadlessBrowserHandle {
634
682
  }
635
683
  continue;
636
684
  }
637
- let ready = "loading";
638
- try {
639
- ready = parseReadyState(await this.host.connection.command("Runtime.evaluate", { expression: "document.readyState", returnByValue: true }, sessionId));
640
- }
641
- catch (error) {
642
- if (!isTransientPageTransitionError(error))
643
- throw error;
644
- }
645
685
  const current = history.entries[history.currentIndex];
646
686
  const currentTarget = this.targets.get(target.targetId) ?? target;
647
687
  return {
648
688
  hostPageId: target.targetId,
649
689
  url: displayUrl(current?.url ?? currentTarget.url),
650
690
  title: displayTitle(currentTarget.title),
651
- loading: this.loadingTargets.has(target.targetId) || ready !== "complete",
691
+ // Renderer JS can be paused by DevTools or beforeunload while Chrome is healthy.
692
+ loading: this.loadingTargets.has(target.targetId),
652
693
  canGoBack: history.currentIndex > 0,
653
694
  canGoForward: history.currentIndex >= 0 && history.currentIndex < history.entries.length - 1,
654
695
  };
@@ -662,6 +703,7 @@ class HeadlessBrowserHandle {
662
703
  url: displayUrl((this.targets.get(target.targetId) ?? target).url),
663
704
  title: displayTitle((this.targets.get(target.targetId) ?? target).title),
664
705
  loading: true,
706
+ observationStale: true,
665
707
  canGoBack: false,
666
708
  canGoForward: false,
667
709
  };
@@ -704,6 +746,23 @@ class HeadlessBrowserHandle {
704
746
  onCdpEvent(event) {
705
747
  if (this.closing || this.unavailableReason)
706
748
  return;
749
+ const params = event.params;
750
+ const identity = { cdpSessionId: event.sessionId, targetId: event.sessionId ? this.targetBySession.get(event.sessionId) : params.targetId };
751
+ if (["Runtime.exceptionThrown", "Network.loadingFailed", "Target.targetCrashed", "Inspector.detached"].includes(event.method) ||
752
+ event.method === "Runtime.consoleAPICalled" && ["error", "warning", "assert"].includes(String(params.type))) {
753
+ this.diagnostic(event.method, { ...identity, details: params });
754
+ }
755
+ else if (event.method === "Network.requestWillBeSent") {
756
+ const request = params.request;
757
+ this.diagnostic(event.method, { ...identity, requestId: params.requestId, type: params.type, url: request?.url, method: request?.method });
758
+ }
759
+ else if (event.method === "Network.responseReceived") {
760
+ const response = params.response;
761
+ if (typeof response?.status === "number" && response.status >= 400)
762
+ this.diagnostic(event.method, {
763
+ ...identity, requestId: params.requestId, url: response.url, status: response.status, statusText: response.statusText,
764
+ });
765
+ }
707
766
  if (event.method === "Fetch.requestPaused" && event.sessionId) {
708
767
  void this.continuePausedRequest(event.sessionId, event.params).catch((error) => {
709
768
  if (isMissingFetchRequestError(error))
@@ -855,6 +914,10 @@ class HeadlessBrowserHandle {
855
914
  return target;
856
915
  }
857
916
  dropTarget(targetId) {
917
+ const gateway = this.automationGateways.get(targetId);
918
+ this.automationGateways.delete(targetId);
919
+ if (gateway)
920
+ void gateway.then((value) => value.close()).catch(() => undefined);
858
921
  const surface = this.surfaceSources.get(targetId);
859
922
  if (surface) {
860
923
  // Stop routing renderer events immediately; close remains best-effort
@@ -923,6 +986,11 @@ class HeadlessBrowserHandle {
923
986
  await this.host.connection.command("Runtime.runIfWaitingForDebugger", {}, sessionId);
924
987
  }
925
988
  await this.host.connection.command("Network.enable", {}, sessionId);
989
+ // Enable exception/console observation after paused targets are resumed.
990
+ // Diagnostics failure must not reject an otherwise usable Page.
991
+ await this.host.connection.command("Runtime.enable", {}, sessionId).catch((error) => {
992
+ this.diagnostic("observer.enable_failed", { cdpSessionId: sessionId, error: String(error) });
993
+ });
926
994
  }
927
995
  catch (error) {
928
996
  if (isMissingTargetError(error) || isTransientPageTransitionError(error)) {
@@ -1031,6 +1099,9 @@ class HeadlessBrowserHandle {
1031
1099
  if (this.closing || this.unavailableReason)
1032
1100
  return;
1033
1101
  this.unavailableReason = reason;
1102
+ for (const gateway of this.automationGateways.values())
1103
+ void gateway.then((value) => value.close()).catch(() => undefined);
1104
+ this.automationGateways.clear();
1034
1105
  for (const source of this.surfaceSources.values())
1035
1106
  source.closeAfterSourceLoss();
1036
1107
  this.host.connection.close();
@@ -1045,7 +1116,10 @@ class HeadlessBrowserHandle {
1045
1116
  this.markUnavailable("The native CDP observer disconnected");
1046
1117
  return unavailable("Headless Browser is unavailable", error);
1047
1118
  }
1048
- return unavailable("Headless Browser rejected the CDP operation", error);
1119
+ if (error instanceof CdpCommandTimeoutError) {
1120
+ return new SessionBrowserHostError("command_timeout", "Browser command timed out; an accepted action may still complete", { cause: error });
1121
+ }
1122
+ return new SessionBrowserHostError("operation_failed", "Headless Browser rejected the CDP operation", { cause: error });
1049
1123
  }
1050
1124
  exclusive(operation) {
1051
1125
  const result = this.operations.then(operation, operation);
@@ -1165,7 +1239,10 @@ class HeadlessBrowserSurfaceSource {
1165
1239
  this.ensureCurrent();
1166
1240
  const receivedAt = Date.now();
1167
1241
  this.lastScreencastFrameAt = receivedAt;
1168
- if (receivedAt - this.lastAcceptedFrameAt < this.minimumFrameIntervalMs) {
1242
+ // Throttle immediate deliveries, not replacements behind a slow viewer.
1243
+ // Coalesced CDP messages must still leave the latest paint in the bounded
1244
+ // queue, otherwise its final frame can be permanently dropped.
1245
+ if (receivedAt - this.lastAcceptedFrameAt < this.minimumFrameIntervalMs && !this.frameQueue.hasCurrentFrame()) {
1169
1246
  void acknowledge().catch(() => undefined);
1170
1247
  return;
1171
1248
  }
@@ -1444,6 +1521,7 @@ class LatestSurfaceFrameQueue {
1444
1521
  currentDelivered = false;
1445
1522
  waiter;
1446
1523
  closed = false;
1524
+ hasCurrentFrame() { return this.current !== undefined; }
1447
1525
  push(frame) {
1448
1526
  const entry = this.wrap(frame);
1449
1527
  if (this.closed) {
@@ -1795,7 +1873,7 @@ class CdpConnection {
1795
1873
  setDisconnectHandler(handler) {
1796
1874
  this.disconnectHandler = handler;
1797
1875
  }
1798
- command(method, params = {}, sessionId) {
1876
+ command(method, params = {}, sessionId, timeoutMs = this.commandTimeoutMs) {
1799
1877
  if (!this.connected)
1800
1878
  return Promise.reject(new CdpDisconnectedError("Native CDP is disconnected"));
1801
1879
  const id = this.nextId++;
@@ -1803,7 +1881,7 @@ class CdpConnection {
1803
1881
  const timer = setTimeout(() => {
1804
1882
  this.pending.delete(id);
1805
1883
  rejectCommand(new CdpCommandTimeoutError(`Native CDP command ${method} timed out`));
1806
- }, this.commandTimeoutMs);
1884
+ }, Math.min(timeoutMs, this.commandTimeoutMs));
1807
1885
  this.pending.set(id, { method, resolve: resolveCommand, reject: rejectCommand, timer });
1808
1886
  try {
1809
1887
  this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
@@ -2347,7 +2425,7 @@ function observeProcess(child) {
2347
2425
  }
2348
2426
  function boundedStderr(child) {
2349
2427
  let value = "";
2350
- child.stderr?.setEncoding("utf8");
2428
+ // Keep the underlying stream binary for the raw diagnostic tee.
2351
2429
  child.stderr?.on("data", (chunk) => {
2352
2430
  value = `${value}${chunk}`.slice(-16_000);
2353
2431
  });
@@ -2419,12 +2497,6 @@ function parseNavigationHistory(value) {
2419
2497
  }
2420
2498
  return { currentIndex, entries };
2421
2499
  }
2422
- function parseReadyState(value) {
2423
- if (!isRecord(value) || !isRecord(value.result) || typeof value.result.value !== "string") {
2424
- return "loading";
2425
- }
2426
- return value.result.value;
2427
- }
2428
2500
  function displayUrl(value) {
2429
2501
  if (value === "about:blank")
2430
2502
  return value;
@@ -0,0 +1,9 @@
1
+ import { type AgentSessionRecord, type MachineSessionRecord } from "@rynx-ai/core";
2
+ import type { PluginLogFileRoot } from "@rynx-ai/plugin-sdk";
3
+ /** Node-wide files are intentionally separate from Session ownership. Plugins
4
+ * are installed local code; their remote-facing adapter must authorize node
5
+ * diagnostics, not merely task visibility, before calling this method. */
6
+ export declare function nodeLogFiles(): Promise<PluginLogFileRoot[]>;
7
+ /** Roots only: the off-process collector walks files. One Session per RPC
8
+ * bounds control messages independently of file count and file sizes. */
9
+ export declare function sessionLogFiles(session: MachineSessionRecord, binding: AgentSessionRecord | null): Promise<PluginLogFileRoot[]>;
@@ -0,0 +1,96 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { createInterface } from "node:readline";
4
+ import { tmpdir } from "node:os";
5
+ import { access, readdir, readFile } from "node:fs/promises";
6
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
7
+ import { rynxHome, sessionDiagnosticDir } from "@rynx-ai/core";
8
+ import { dbPath } from "./db.js";
9
+ const family = (file) => [file, ...["-wal", "-shm", "-journal"].map((suffix) => file + suffix)];
10
+ const root = (source, file, scope, optional = false) => {
11
+ const rel = relative(rynxHome(), file).split(sep).join("/");
12
+ return { source, path: file, scope, optional, archivePath: !rel.startsWith("../") && !isAbsolute(rel)
13
+ ? `files/rynx/${rel}` : `files/external-logs/${createHash("sha256").update(dirname(file)).digest("hex")}/${basename(file)}` };
14
+ };
15
+ /** Node-wide files are intentionally separate from Session ownership. Plugins
16
+ * are installed local code; their remote-facing adapter must authorize node
17
+ * diagnostics, not merely task visibility, before calling this method. */
18
+ export async function nodeLogFiles() {
19
+ const logs = join(rynxHome(), "logs");
20
+ const files = (await readdir(logs).catch(() => []))
21
+ .filter((name) => /^(?:(?:out|err)|daemon\.(?:out|err))\.log(?:[.-].+)?$/.test(name));
22
+ return [
23
+ ...files.map((name) => root("daemon", join(logs, name), "host")),
24
+ root("browser", join(rynxHome(), "logs", "browser", "host"), "host", true),
25
+ ...family(dbPath()).map((file, i) => root("rynx-db", file, "host", i > 0)),
26
+ ];
27
+ }
28
+ /** Roots only: the off-process collector walks files. One Session per RPC
29
+ * bounds control messages independently of file count and file sizes. */
30
+ export async function sessionLogFiles(session, binding) {
31
+ const extended = binding;
32
+ const state = (id) => join(rynxHome(), "runtime", "sessions", createHash("sha256").update(id).digest("hex").slice(0, 32));
33
+ const owner = extended?.runtimeHomeOwnerSessionId || session.id;
34
+ let home = state(owner);
35
+ const provider = session.execution.provider;
36
+ const legacy = join(tmpdir(), `rynx-${process.getuid?.() ?? "nouid"}`);
37
+ const exists = (file) => access(file).then(() => true, () => false);
38
+ const nativeName = provider === "codex" ? "codex-home" : "trae-home";
39
+ if (!(await exists(join(home, nativeName)))) {
40
+ const old = join(legacy, provider === "codex" ? "codex-native" : "traex-native", createHash("sha256").update(owner).digest("hex").slice(0, 32));
41
+ if (await exists(join(old, nativeName)))
42
+ home = old;
43
+ }
44
+ const result = [
45
+ root("runtime", sessionDiagnosticDir("runtime", session.id), "session", true),
46
+ root("browser", sessionDiagnosticDir("browser", session.id), "session", true),
47
+ root("browser", join(rynxHome(), "logs", "browser", "host"), "host", true),
48
+ ];
49
+ if (owner !== session.id)
50
+ result.push(root("runtime", sessionDiagnosticDir("runtime", owner), "runtime_home", true));
51
+ if (provider === "codex" || provider === "traex") {
52
+ const native = join(home, provider === "codex" ? "codex-home" : "trae-home");
53
+ const cli = provider === "codex" ? native : join(native, "cli");
54
+ result.push(root("runtime", join(native, "sessions"), "runtime_home"));
55
+ result.push(root("runtime", join(cli, "log"), "runtime_home", true));
56
+ for (const file of family(join(cli, "logs_2.sqlite")))
57
+ result.push(root("runtime-db", file, "runtime_home", true));
58
+ }
59
+ else if (provider === "claude") {
60
+ const bridgeOwner = extended?.bridgeOwnerSessionId || owner;
61
+ let bridge = join(state(bridgeOwner), "claude-bridge");
62
+ if (!(await exists(bridge))) {
63
+ const old = join(legacy, "claude-native", createHash("sha256").update(bridgeOwner).digest("hex").slice(0, 32));
64
+ if (await exists(old))
65
+ bridge = old;
66
+ }
67
+ for (const name of ["hooks.jsonl", "state.json", "message_deltas.jsonl", "interactions.jsonl", "interaction-acks.jsonl", "interaction-results", "history"]) {
68
+ result.push(root("runtime", join(bridge, name), "session", true));
69
+ }
70
+ const transcripts = new Set();
71
+ const note = (value) => { if (typeof value === "string" && isAbsolute(value))
72
+ transcripts.add(value); };
73
+ const bridges = [bridge, ...(await readdir(join(bridge, "history")).catch(() => [])).map((id) => join(bridge, "history", id))];
74
+ for (const directory of bridges) {
75
+ try {
76
+ note(JSON.parse(await readFile(join(directory, "state.json"), "utf8")).transcriptPath);
77
+ }
78
+ catch { /* older/never-started */ }
79
+ try {
80
+ const lines = createInterface({ input: createReadStream(join(directory, "hooks.jsonl")), crlfDelay: Infinity });
81
+ for await (const line of lines) {
82
+ try {
83
+ note(JSON.parse(line).payload?.transcript_path);
84
+ }
85
+ catch { /* partial live line */ }
86
+ }
87
+ }
88
+ catch { /* original files and collection warnings still report unreadable sources */ }
89
+ }
90
+ for (const transcript of transcripts) {
91
+ result.push(root("runtime", transcript, "session"));
92
+ result.push(root("runtime", join(dirname(transcript), basename(transcript, ".jsonl"), "subagents"), "session", true));
93
+ }
94
+ }
95
+ return result;
96
+ }
@@ -0,0 +1,6 @@
1
+ /** A helper epoch owns one transport connection. An upstream CLI must not
2
+ * silently restart and execute a write against this epoch's borrowed Page. */
3
+ export declare function createPageAgentBrowserConnection(endpoint: string, isCurrent: () => boolean): Promise<{
4
+ endpoint: string;
5
+ close(): Promise<void>;
6
+ }>;
@@ -0,0 +1,71 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import WebSocket, { WebSocketServer } from "ws";
4
+ /** A helper epoch owns one transport connection. An upstream CLI must not
5
+ * silently restart and execute a write against this epoch's borrowed Page. */
6
+ export async function createPageAgentBrowserConnection(endpoint, isCurrent) {
7
+ const noncePath = "/devtools/browser/" + randomBytes(18).toString("hex");
8
+ const limit = 8 * 1024 * 1024;
9
+ let used = false, closed = false, authority = "";
10
+ let client, upstream;
11
+ const server = createServer((_request, response) => { response.writeHead(404).end(); });
12
+ const sockets = new WebSocketServer({ noServer: true, maxPayload: limit });
13
+ const disconnect = () => { client?.terminate(); upstream?.terminate(); };
14
+ server.on("upgrade", (request, socket, head) => {
15
+ if (closed || used || !isCurrent() || request.url !== noncePath || request.headers.origin !== undefined || request.headers.host !== authority) {
16
+ socket.destroy();
17
+ return;
18
+ }
19
+ used = true;
20
+ sockets.handleUpgrade(request, socket, head, (downstream) => {
21
+ client = downstream;
22
+ const native = new WebSocket(endpoint, { maxPayload: limit, handshakeTimeout: 5_000 });
23
+ upstream = native;
24
+ const early = [];
25
+ let buffered = 0;
26
+ downstream.on("close", disconnect);
27
+ downstream.on("error", disconnect);
28
+ native.on("close", disconnect);
29
+ native.on("error", disconnect);
30
+ downstream.on("message", (bytes, binary) => {
31
+ if (binary || !isCurrent() || native.bufferedAmount > limit) {
32
+ disconnect();
33
+ return;
34
+ }
35
+ const data = Buffer.from(bytes.toString());
36
+ if (native.readyState === WebSocket.OPEN)
37
+ native.send(data, { binary: false });
38
+ else if (native.readyState === WebSocket.CONNECTING && early.length < 64 && buffered + data.length <= limit) {
39
+ early.push(data);
40
+ buffered += data.length;
41
+ }
42
+ else
43
+ disconnect();
44
+ });
45
+ native.on("open", () => { for (const bytes of early)
46
+ native.send(bytes, { binary: false }); early.length = 0; buffered = 0; });
47
+ native.on("message", (bytes, binary) => {
48
+ if (binary || !isCurrent() || downstream.readyState !== WebSocket.OPEN || downstream.bufferedAmount > limit) {
49
+ disconnect();
50
+ return;
51
+ }
52
+ downstream.send(bytes, { binary: false });
53
+ });
54
+ });
55
+ });
56
+ await new Promise((resolve, reject) => {
57
+ server.once("error", reject);
58
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
59
+ });
60
+ const address = server.address();
61
+ if (!address || typeof address === "string")
62
+ throw new Error("Browser helper capability did not bind");
63
+ authority = "127.0.0.1:" + address.port;
64
+ return { endpoint: "ws://" + authority + noncePath, async close() {
65
+ if (closed)
66
+ return;
67
+ closed = true;
68
+ disconnect();
69
+ await Promise.all([new Promise((r) => sockets.close(() => r())), new Promise((r) => server.close(() => r()))]);
70
+ } };
71
+ }
@@ -0,0 +1,14 @@
1
+ import type { SessionBrowserAutomationBinding } from "@rynx-ai/server";
2
+ export declare const PAGE_AGENT_BROWSER_VERSION = "0.36.0";
3
+ export declare class PageAutomationError extends Error {
4
+ readonly code: "invalid_request" | "human_control_active" | "driver_restarted" | "outcome_unknown" | "command_failed" | "capacity" | "not_found";
5
+ constructor(code: "invalid_request" | "human_control_active" | "driver_restarted" | "outcome_unknown" | "command_failed" | "capacity" | "not_found", message: string, options?: ErrorOptions);
6
+ }
7
+ export interface PageAutomationDriver {
8
+ readonly version?: string;
9
+ execute(args: string[], beforeInvoke?: () => void): Promise<Record<string, unknown>>;
10
+ close(): Promise<void>;
11
+ }
12
+ /** Resolve only the pinned dependency. Never use a PATH/global helper or run its Node wrapper. */
13
+ export declare function pageAgentBrowserBinary(): string;
14
+ export declare function createPageAgentBrowserDriver(root: string, binding: SessionBrowserAutomationBinding): Promise<PageAutomationDriver>;