wtagent 0.1.0 → 0.2.0

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.
@@ -108,6 +108,55 @@ async function waitForCdp({
108
108
  );
109
109
  }
110
110
 
111
+ // A headful Chrome process can stay alive in background mode after its final
112
+ // window is closed. Its browser CDP endpoint remains healthy, but recent
113
+ // Playwright versions cannot initialize the default context when Chrome has no
114
+ // page target (`Browser.setDownloadBehavior`: context management unsupported).
115
+ // Recreate one blank tab through Chrome's own local debugging endpoint before
116
+ // connecting. Existing tabs and conversations are left untouched.
117
+ export async function ensureCdpPageTarget(
118
+ endpoint,
119
+ { fetchImpl = fetch, timeoutMs = 1_500 } = {},
120
+ ) {
121
+ let targets;
122
+ try {
123
+ const response = await fetchImpl(`${endpoint}/json/list`, {
124
+ signal: AbortSignal.timeout(timeoutMs),
125
+ });
126
+ if (!response.ok) {
127
+ return false;
128
+ }
129
+ targets = await response.json();
130
+ } catch {
131
+ // This preflight is compatibility hardening. If the optional discovery
132
+ // endpoint is unavailable, preserve the normal Playwright connect path and
133
+ // let it report the authoritative CDP error.
134
+ return false;
135
+ }
136
+
137
+ if (!Array.isArray(targets) || targets.some((target) => target?.type === "page")) {
138
+ return false;
139
+ }
140
+
141
+ const response = await fetchImpl(
142
+ `${endpoint}/json/new?${encodeURIComponent("about:blank")}`,
143
+ { method: "PUT", signal: AbortSignal.timeout(timeoutMs) },
144
+ );
145
+ if (!response.ok) {
146
+ throw new Error(
147
+ `Chrome CDP at ${endpoint} has no page target and could not create one `
148
+ + `(HTTP ${response.status}).`,
149
+ );
150
+ }
151
+ const target = await response.json();
152
+ if (target?.type !== "page" || !target?.webSocketDebuggerUrl) {
153
+ throw new Error(
154
+ `Chrome CDP at ${endpoint} returned an invalid new-page target.`,
155
+ );
156
+ }
157
+ return true;
158
+ }
159
+
111
160
  // Sets the OS window state (e.g. "minimized" / "normal") of the window hosting
112
161
  // `page` via the CDP Browser domain. On macOS the Chromium launch flags for
113
162
  // minimizing (--start-minimized) and off-screen positioning are ignored or
@@ -153,6 +202,7 @@ export async function launchAndConnectCdpChrome({
153
202
  acquireProfileLock = acquireCdpProfileLock,
154
203
  connectOverCDP = (endpoint) => chromium.connectOverCDP(endpoint),
155
204
  discoverReusable = discoverReusableCdpState,
205
+ ensurePageTarget = ensureCdpPageTarget,
156
206
  fetchVersion = fetchCdpVersion,
157
207
  killTree = killProcessTree,
158
208
  matchesState = processMatchesCdpState,
@@ -244,6 +294,9 @@ export async function launchAndConnectCdpChrome({
244
294
  // without this guard Playwright hangs ~30s and leaves a dirty CDP state.
245
295
  let context;
246
296
  try {
297
+ if (reused) {
298
+ await ensurePageTarget(state.endpoint);
299
+ }
247
300
  browser = await withTimeout(
248
301
  connectOverCDP(state.endpoint),
249
302
  connectTimeoutMs,
@@ -318,9 +371,18 @@ export async function launchAndConnectCdpChrome({
318
371
  // Drops only the Playwright transport. Unlike close(), never asks Chrome
319
372
  // to exit and never kills the process: used to recover from a dead CDP
320
373
  // connection (e.g. after the Mac slept) while Chrome itself is alive.
374
+ // The profile lock stays held because the same CLI session will reconnect.
321
375
  async disconnect() {
322
376
  await settleWithin(browser.close(), 1_500);
323
377
  },
378
+ // Leaves Chrome running (and its saved CDP state intact) but drops the
379
+ // Playwright transport and the profile lock so a later WTAgent process
380
+ // can reuse the window. Used after a failed run that wants the page
381
+ // left open for inspection without blocking the next launch.
382
+ async detach() {
383
+ await settleWithin(browser.close(), 1_500);
384
+ await releaseProfileLock();
385
+ },
324
386
  async close() {
325
387
  if (closePromise) {
326
388
  return await closePromise;
@@ -541,9 +541,38 @@ export async function inspectCdpProfileState(profileDir, {
541
541
  };
542
542
  }
543
543
 
544
+ // The instant a process was started, in epoch milliseconds. Used to tell a
545
+ // live lock-holder pid from a recycled one: a process that started after the
546
+ // lock file was written cannot own it. Returns null when it cannot be
547
+ // determined (unknown process, platform tool unavailable).
548
+ async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
549
+ if (process.platform === "win32") {
550
+ const { stdout } = await execFileImpl(
551
+ "powershell.exe",
552
+ [
553
+ "-NoProfile",
554
+ "-NonInteractive",
555
+ "-Command",
556
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate`,
557
+ ],
558
+ { windowsHide: true },
559
+ );
560
+ const parsed = Date.parse(String(stdout).trim());
561
+ return Number.isFinite(parsed) ? parsed : null;
562
+ }
563
+
564
+ const { stdout } = await execFileImpl(
565
+ "ps",
566
+ ["-p", String(pid), "-o", "etimes="],
567
+ );
568
+ const seconds = Number.parseInt(String(stdout), 10);
569
+ return Number.isFinite(seconds) ? Date.now() - seconds * 1_000 : null;
570
+ }
571
+
544
572
  export async function acquireCdpProfileLock(profileDir, {
545
573
  ownerPid = process.pid,
546
574
  isAlive = isProcessAlive,
575
+ processStartTime = processStartTimeMs,
547
576
  } = {}) {
548
577
  const resolvedProfile = path.resolve(profileDir);
549
578
  const filePath = lockPath(resolvedProfile);
@@ -581,10 +610,29 @@ export async function acquireCdpProfileLock(profileDir, {
581
610
  }
582
611
  const existing = await readInitializedLock(filePath);
583
612
  if (existing && isAlive(Number(existing.pid))) {
584
- throw new Error(
585
- `Another WTAgent session (pid=${existing.pid}) is already using `
586
- + `${resolvedProfile}.`,
587
- );
613
+ // A live pid usually IS the real holder, but a crashed session's pid
614
+ // can be recycled by an unrelated process. A process that started
615
+ // after the lock was written cannot own it, so the lock is stale and
616
+ // may be removed. When the start time is unknown, assume the holder is
617
+ // real rather than deleting a lock we cannot verify is stale.
618
+ const startedAt = await processStartTime(Number(existing.pid))
619
+ .catch(() => null);
620
+ const lockCreatedAt = Date.parse(existing.createdAt ?? "");
621
+ const recycled = Number.isFinite(lockCreatedAt)
622
+ && startedAt != null
623
+ && startedAt > lockCreatedAt + 2_000;
624
+ if (!recycled) {
625
+ const error = new Error(
626
+ `Another WTAgent session (pid=${existing.pid}) is already using `
627
+ + `${resolvedProfile}.`,
628
+ );
629
+ error.code = "PROFILE_LOCKED";
630
+ error.details = {
631
+ pid: Number(existing.pid),
632
+ profileDir: resolvedProfile,
633
+ };
634
+ throw error;
635
+ }
588
636
  }
589
637
  if (!existing) {
590
638
  const stat = await fs.stat(filePath).catch(() => null);