wtagent 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
package/README.md CHANGED
@@ -13,16 +13,37 @@ WTAgent 把 GPT 网页聊天连接到本地工具,提供类似 Codex 的 CLI A
13
13
  网页 GPT 负责思考,WTAgent 在本机:
14
14
 
15
15
  - 读取和修改本地文件
16
- - 调用本地 Shell,运行构建、测试和开发服务
16
+ - 调用结构化本地命令,运行构建、测试和开发服务
17
17
 
18
18
  不需要 OpenAI API Key,也不要求 ChatGPT Pro。WTAgent 使用独立的 Chrome Profile 保存你的网页登录状态,并使用该账号在网页上实际可用的模型和额度;如果账号拥有 Pro,它会成为额外优势。
19
19
 
20
+ 支持矩阵:
21
+
22
+ - macOS
23
+ - Linux
24
+ - Windows 10 1809+ / Windows 11 x64
25
+ - Windows ARM64 目前为预览支持
26
+
27
+ 边界:
28
+
29
+ - 需要 Chrome 或 Chromium;首版不支持 Edge
30
+ - Git 和 `rg` 是可选项,不是运行前提
31
+ - WSL 不在首版支持范围内;请直接在原生 PowerShell / CMD / Windows Terminal 中运行
32
+
20
33
  ### 快速开始
21
34
 
22
35
  需要 Node.js 20.17+ 和 Chrome/Chromium。
23
36
 
24
37
  ```bash
25
- npm install -g wtagent@alpha
38
+ npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
39
+ wtagent login
40
+ ```
41
+
42
+ Windows PowerShell:
43
+
44
+ ```powershell
45
+ npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
46
+ wtagent doctor
26
47
  wtagent login
27
48
  ```
28
49
 
@@ -40,10 +61,16 @@ wtagent "检查这个项目并修复测试"
40
61
  wtagent -C ./my-project "创建一个网站"
41
62
  ```
42
63
 
43
- 之后直接在终端中继续对话。WTAgent 会把任务交给 GPT Web,并在本地执行文件和 Shell 操作。使用 `↑` / `↓` 浏览本次 CLI 会话的历史输入。
64
+ 之后直接在终端中继续对话。WTAgent 会把任务交给 GPT Web,并在本地执行文件操作和结构化本地命令。使用 `↑` / `↓` 浏览本次 CLI 会话的历史输入。
44
65
 
45
66
  按 `Ctrl+C` 或 `Ctrl+D` 可退出并关闭专用 Chrome。若上一次异常退出留下了 Chrome,WTAgent 会在验证其 CDP 身份后复用并接管它。
46
67
 
68
+ 常见说明:
69
+
70
+ - `wtagent doctor` 会检查 Node、Chrome、目录权限和运行环境
71
+ - 首次登录只会写入 WTAgent 的专用 Chrome Profile,不会接管你的日常浏览器 Profile
72
+ - 如果系统没有 Git 或 `rg`,WTAgent 仍应保留核心文件读写与搜索能力
73
+
47
74
  ## English
48
75
 
49
76
  WTAgent connects GPT Web chat to local tools, providing a Codex-like CLI agent experience. The first adapter currently supports ChatGPT Web.
@@ -51,16 +78,37 @@ WTAgent connects GPT Web chat to local tools, providing a Codex-like CLI agent e
51
78
  GPT Web handles reasoning. WTAgent runs locally to:
52
79
 
53
80
  - Read and edit local files
54
- - Run shell commands, builds, tests, and development servers
81
+ - Run structured local commands, builds, tests, and development servers
55
82
 
56
83
  No OpenAI API key or ChatGPT Pro subscription is required. WTAgent stores your web login in a dedicated Chrome profile and uses the models and quota actually available to that account. Pro is an optional bonus when the account has it.
57
84
 
85
+ Support matrix:
86
+
87
+ - macOS
88
+ - Linux
89
+ - Windows 10 1809+ / Windows 11 x64
90
+ - Windows ARM64 is currently preview only
91
+
92
+ Boundaries:
93
+
94
+ - Requires Chrome or Chromium; Edge is out of scope for the first Windows release
95
+ - Git and `rg` are optional accelerators, not runtime requirements
96
+ - WSL is not supported in v1; run WTAgent from native PowerShell, CMD, or Windows Terminal
97
+
58
98
  ### Quick start
59
99
 
60
100
  Requires Node.js 20.17+ and Chrome/Chromium.
61
101
 
62
102
  ```bash
63
- npm install -g wtagent@alpha
103
+ npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
104
+ wtagent login
105
+ ```
106
+
107
+ Windows PowerShell:
108
+
109
+ ```powershell
110
+ npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
111
+ wtagent doctor
64
112
  wtagent login
65
113
  ```
66
114
 
@@ -78,10 +126,16 @@ wtagent "inspect this project and fix the tests"
78
126
  wtagent -C ./my-project "build a website"
79
127
  ```
80
128
 
81
- Continue chatting in the terminal. WTAgent sends tasks to GPT Web and executes file and shell operations locally. Use `↑` / `↓` to browse input history from the current CLI session.
129
+ Continue chatting in the terminal. WTAgent sends tasks to GPT Web and executes file operations and structured local commands. Use `↑` / `↓` to browse input history from the current CLI session.
82
130
 
83
131
  Press `Ctrl+C` or `Ctrl+D` to exit and close the dedicated Chrome. If an abnormal exit leaves Chrome running, WTAgent verifies and adopts that CDP instance on the next start.
84
132
 
133
+ Notes:
134
+
135
+ - `wtagent doctor` checks Node, Chrome, writable data paths, and runtime support
136
+ - WTAgent always uses its own Chrome profile and does not reuse your daily browsing profile
137
+ - Core file and search capabilities must continue to work even when Git or `rg` is missing
138
+
85
139
  ## License
86
140
 
87
141
  [MIT](./LICENSE)
@@ -11,11 +11,20 @@
11
11
  - 双方通过普通聊天文本中的自定义 XML 交换工具调用和结果。
12
12
  - Runtime 循环执行“网页回复 → 解析工具 → 本地执行 → 回填结果”,直到任务完成。
13
13
 
14
+ 原生 Windows 支持约束:
15
+
16
+ - 发布目标是 Windows 10 1809+ / Windows 11 x64;ARM64 先作为预览
17
+ - 支持从 PowerShell、CMD 或 Windows Terminal 启动
18
+ - 仅支持 Chrome / Chromium,不把 Edge 计入首版兼容矩阵
19
+ - Git、`rg`、Codex、Claude Code 都不是运行依赖
20
+ - WSL 不在首版范围内,需要从原生 Windows 终端直接运行
21
+ - Windows 上仍保持同一个结构化执行协议:`program + argv + cwd`
22
+
14
23
  建议的首版技术组合:
15
24
 
16
25
  | 领域 | 选择 |
17
26
  | --- | --- |
18
- | 运行时 | Node.js 22+,JavaScript ESM |
27
+ | 运行时 | Node.js 20.17+,JavaScript ESM |
19
28
  | CLI | `commander` + `@inquirer/prompts`,终端渲染可选 `ink` |
20
29
  | 浏览器控制 | `playwright-core`,使用用户已安装的 Chrome,非 headless |
21
30
  | XML | `saxes` 或 `fast-xml-parser`;工具参数按注册 Schema 二次校验 |
@@ -50,6 +59,12 @@
50
59
  - V1 支持 macOS、Windows、Linux。
51
60
  - V1 是 CLI,不做桌面 GUI 和远程控制台。
52
61
 
62
+ 其中 Windows 的产品边界需要额外强调:
63
+
64
+ - `.cmd` / `.bat` 兼容属于运行时适配层职责,不能暴露为新的 shell-string 工具
65
+ - `wtagent doctor` 需要明确区分“必需失败”“可选缺失”“能力降级”和“不支持的 WSL”
66
+ - 打包发布必须经过原生 Windows 全局安装 smoke、路径带空格/中文、以及 Chrome 专用 Profile 验证
67
+
53
68
  ### 2.3 一个必须正视的技术事实
54
69
 
55
70
  普通 ChatGPT 网页聊天没有真正的 system message、tool schema 或 tool result channel。所谓“system prompt”“工具调用”“工具结果”都是由 CLI 作为普通用户文本发送。因此:
@@ -519,11 +534,18 @@ V1 不需要把 `cd` 暴露给模型;每个命令都有显式 `cwd`,且必
519
534
 
520
535
  - `cwd` 解析后必须位于项目根目录。
521
536
  - stdout/stderr 分开捕获并流式展示。
522
- - 达到上限后保留头尾并标记截断。
537
+ - stdout 与 stderr 回填给网页模型的合计上限为 4 KiB;达到上限后按 UTF-8 字节安全地保留约 1 KiB 头部和 3 KiB 尾部,并标记原始与省略字节数。
538
+ - 工具说明要求模型优先使用 `fs.search`、分页 `fs.read`、窄路径、子命令和测试过滤参数缩小输出;不假设系统已安装 Git、`rg` 或 Unix 文本工具。`terminal.exec` 不提供管道、重定向或其他 Shell 运算符。
539
+ - 单次调用流式写入本地 `tool-output.jsonl` 的原始命令日志最多保留 4 MiB,超过后停止记录剩余日志,但不因此终止命令。
523
540
  - 超时先优雅终止,再强制终止进程树。
524
541
  - Windows 必须处理子进程树终止。
525
542
  - 返回 exit code、signal、duration 和截断信息。
526
543
 
544
+ 文件读取与网页传输另有两层硬限制:
545
+
546
+ - `fs.read` 单次最多读取 16 KiB,通过返回的 `nextOffset` 继续读取;分段边界不得切断 UTF-8 字符。
547
+ - 最终发送到浏览器的单条工具结果,包括 XML、续跑信息和 `<system_reminder>`,不得超过 24 KiB。Runtime 在字段级截断后生成完整 XML,Browser Adapter 在写入 composer 前再次按 UTF-8 字节数校验;禁止直接截断已经序列化的 XML。
548
+
527
549
  ### 7.4 长运行进程
528
550
 
529
551
  网站场景离不开 dev server,不能让 `terminal.exec` 永久阻塞。
@@ -593,7 +615,7 @@ sessions/<session-id>/
593
615
  tool-output.jsonl
594
616
  ```
595
617
 
596
- `session.json` 保存项目根目录、ChatGPT 会话 URL、当前 run phase、最近 turn、等待回填的工具结果和副作用恢复日志。它没有不可继续的 `completed task` 状态;`done=true` 只结束当前 run,Session 回到 `idle`。
618
+ `session.json` 保存项目根目录、ChatGPT 会话 URL、最近确认的 assistant message ID、最近确认的实际模式、当前 run phase、最近 turn、等待回填的工具结果和副作用恢复日志。它没有不可继续的 `completed task` 状态;`done=true` 只结束当前 run,Session 回到 `idle`。
597
619
 
598
620
  `rollout-*.jsonl` 从创建时起直接使用 **Codex rollout 风格**:首行 `session_meta`,后续每行 `{timestamp, type: "response_item", payload}`,payload 采用 OpenAI Responses 形状(`message` / `function_call` / `function_call_output`)。
599
621
 
@@ -628,6 +650,8 @@ sessionId + assistantMessageIdentity + normalizedToolCall
628
650
 
629
651
  - Chrome 仍在:根据专用 Profile、PID、CDP 端口和健康检查验证身份,复用浏览器并创建新的 Page。
630
652
  - Chrome 崩溃:用相同 Profile 重启并打开会话 URL。
653
+ - 同一 CLI 进程中的 follow-up 保持在当前会话 Page,不重复导航;跨进程恢复需要等待本地记录的最近 assistant message ID 出现在 DOM 后才能发送。
654
+ - 每次发送记录已有消息 ID 和新 user turn,只接受位于该 user turn 之后的新 assistant turn;无法建立可靠消息身份时超时并保存诊断,禁止退化为基于数量或文本变化猜测。
631
655
  - 同一 Profile 同时只允许一个 WTAgent CLI Session;启动和接管过程使用 Profile 级互斥锁。
632
656
  - 登录失效或出现验证:进入 `AUTH_REQUIRED`/`PAUSED`,让用户接管。
633
657
  - 会话页面丢失:从本地记录打开原会话;无法恢复时创建新的本地 Session 和新的网页对话,不向旧 rollout 继续追加。
@@ -640,6 +664,8 @@ sessionId + assistantMessageIdentity + normalizedToolCall
640
664
  2. 当前可用工具及参数 Schema。
641
665
  3. 用户任务、项目根目录语义和执行边界。
642
666
 
667
+ 同一网页会话中的普通 follow-up 只发送新的用户输入,并在末尾追加短 `<system_reminder>`;不得重复发送完整 `<agent_protocol>`、工具目录或初始任务。只有没有新用户输入的中断恢复流程可以发送完整 resume scaffold。
668
+
643
669
  关键规则:
644
670
 
645
671
  - 一轮最多一个工具调用。
@@ -816,7 +842,7 @@ macOS、Windows、Linux 都运行:
816
842
  | ChatGPT DOM 变化 | Provider Adapter 隔离、集中 Locator、Fixture 和诊断截图 |
817
843
  | 网页模型不遵守 XML | 简单协议、单工具/轮、确定性解析、有限重发 |
818
844
  | 重复执行工具 | assistant 指纹、call ledger、两阶段事件记录 |
819
- | 大输出塞满聊天 | 本地保存、截断回填、按需读取 |
845
+ | 大输出塞满聊天 | 文件按 16 KiB 分段读取;命令结果回填 4 KiB;浏览器工具消息硬限制 24 KiB;本地命令日志限制 4 MiB |
820
846
  | dev server 阻塞 | 独立 Process Manager |
821
847
  | 跨平台 Shell 差异 | `program + argv + cwd`,复杂 Shell 单独审批 |
822
848
  | 路径逃逸 | realpath、符号链接检查、项目根策略 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtagent",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "Turn your own web AI session into a local tool-using agent.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,12 +25,14 @@
25
25
  "files": [
26
26
  "src",
27
27
  "README.md",
28
- "docs/technical-design.md"
28
+ "docs/technical-design.md",
29
+ "LICENSE"
29
30
  ],
30
31
  "scripts": {
31
32
  "test": "node --test",
32
33
  "test:unit": "node --test test/protocol.test.js test/policy.test.js test/tools.test.js test/prompt-builder.test.js test/session-export.test.js test/platform.test.js test/cli.test.js test/at-files.test.js",
33
34
  "test:integration": "node --test test/runtime.test.js",
35
+ "test:windows": "node --test test/windows-command-launcher.test.js test/windows-diagnostics.test.js test/windows-search-path.test.js test/atomic-write.test.js test/process-utils.test.js test/cdp-state.test.js test/windows-fixtures.test.js test/windows-package-smoke.test.js",
34
36
  "check": "node scripts/check-syntax.js",
35
37
  "doctor": "node src/cli/main.js doctor",
36
38
  "login": "node src/cli/main.js login",
@@ -7,6 +7,7 @@ import {
7
7
  discoverReusableCdpState,
8
8
  fetchCdpVersion,
9
9
  processMatchesCdpState,
10
+ reapStaleProfileChrome,
10
11
  removeCdpState,
11
12
  saveCdpState,
12
13
  waitForProcessExit,
@@ -45,6 +46,33 @@ async function settleWithin(promise, timeoutMs) {
45
46
  }
46
47
  }
47
48
 
49
+ // Races a promise against a timeout. On timeout the returned promise rejects
50
+ // with a TIMEOUT-tagged error. Used to bound connectOverCDP + the first CDP
51
+ // round-trip: a Chrome whose profile is locked by a stale instance answers the
52
+ // WS handshake but never finishes protocol init, so an unbounded connect hangs
53
+ // for the full Playwright default (30s) before failing.
54
+ class CdpTimeoutError extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = "CdpTimeoutError";
58
+ this.code = "CDP_CONNECT_TIMEOUT";
59
+ }
60
+ }
61
+
62
+ async function withTimeout(promise, timeoutMs, message) {
63
+ let timer;
64
+ try {
65
+ return await Promise.race([
66
+ promise,
67
+ new Promise((_, reject) => {
68
+ timer = setTimeout(() => reject(new CdpTimeoutError(message)), timeoutMs);
69
+ }),
70
+ ]);
71
+ } finally {
72
+ clearTimeout(timer);
73
+ }
74
+ }
75
+
48
76
  async function waitForCdp({
49
77
  endpoint,
50
78
  child,
@@ -116,12 +144,14 @@ export async function launchAndConnectCdpChrome({
116
144
  fetchVersion = fetchCdpVersion,
117
145
  killTree = killProcessTree,
118
146
  matchesState = processMatchesCdpState,
147
+ reapStale = reapStaleProfileChrome,
119
148
  removeState = removeCdpState,
120
149
  reserveCdpPort = reservePort,
121
150
  saveState = saveCdpState,
122
151
  spawnChrome = spawn,
123
152
  waitForExit = waitForProcessExit,
124
153
  waitForReady = waitForCdp,
154
+ connectTimeoutMs = 20_000,
125
155
  } = {}) {
126
156
  const releaseProfileLock = await acquireProfileLock(profileDir);
127
157
  let child = null;
@@ -140,6 +170,11 @@ export async function launchAndConnectCdpChrome({
140
170
  reused = true;
141
171
  } else {
142
172
  await removeState(profileDir);
173
+ // A prior instance may have died leaving renderer children (and Chrome's
174
+ // SingletonLock) still holding this profile. Reap those stale holders
175
+ // before launching, or the new Chrome hangs during profile init and
176
+ // connectOverCDP times out.
177
+ await reapStale(profileDir, { fetchVersion, killTree }).catch(() => null);
143
178
  const port = await reserveCdpPort();
144
179
  const endpoint = `http://127.0.0.1:${port}`;
145
180
  child = spawnChrome(
@@ -192,8 +227,41 @@ export async function launchAndConnectCdpChrome({
192
227
  }
193
228
  }
194
229
 
195
- browser = await connectOverCDP(state.endpoint);
196
- const context = browser.contexts()[0];
230
+ // Bound the connect + first CDP round-trip. If Chrome's profile is held by
231
+ // a stale instance, the WS connects but protocol init never completes;
232
+ // without this guard Playwright hangs ~30s and leaves a dirty CDP state.
233
+ let context;
234
+ try {
235
+ browser = await withTimeout(
236
+ connectOverCDP(state.endpoint),
237
+ connectTimeoutMs,
238
+ `Timed out connecting to Chrome CDP at ${state.endpoint} after ${connectTimeoutMs}ms.`,
239
+ );
240
+ // contexts() forces a real protocol round-trip, so it hangs too when the
241
+ // browser main thread is stuck — keep it inside the timeout budget.
242
+ const contexts = await withTimeout(
243
+ Promise.resolve().then(() => browser.contexts()),
244
+ connectTimeoutMs,
245
+ `Timed out reading Chrome browser context at ${state.endpoint}.`,
246
+ );
247
+ context = contexts[0];
248
+ } catch (error) {
249
+ if (error instanceof CdpTimeoutError) {
250
+ // The verified-but-unusable instance we launched is a dead end. Kill it
251
+ // (only if we own it) and drop its CDP state so the next run starts
252
+ // clean instead of trying to reuse a hung endpoint.
253
+ await browser?.close().catch(() => null);
254
+ if (!reused && child?.pid) {
255
+ await killTree(child.pid).catch(() => null);
256
+ }
257
+ await removeState(profileDir, state).catch(() => null);
258
+ throw new Error(
259
+ `${error.message} The Chrome profile may be held by another instance. `
260
+ + "Close other windows using this profile, or run `wtagent logout` to reset it, then retry.",
261
+ );
262
+ }
263
+ throw error;
264
+ }
197
265
  if (!context) {
198
266
  throw new Error("Chrome CDP connection did not expose a browser context.");
199
267
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { promisify } from "node:util";
6
+ import { replaceFileAtomic } from "../shared/atomic-write.js";
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
9
  const CDP_STATE_FILE = ".wtagent-cdp.json";
@@ -37,7 +38,7 @@ async function writeJsonAtomic(filePath, value) {
37
38
  `${JSON.stringify(value, null, 2)}\n`,
38
39
  { mode: 0o600 },
39
40
  );
40
- await fs.rename(temporary, filePath);
41
+ await replaceFileAtomic(temporary, filePath);
41
42
  } finally {
42
43
  await fs.rm(temporary, { force: true }).catch(() => {});
43
44
  }
@@ -114,7 +115,7 @@ function normalizeCandidate(candidate, profileDir) {
114
115
  }
115
116
  if (
116
117
  candidate.profileDir
117
- && path.resolve(candidate.profileDir) !== profileDir
118
+ && !profileDirsMatch(candidate.profileDir, profileDir)
118
119
  ) {
119
120
  return null;
120
121
  }
@@ -128,6 +129,21 @@ function normalizeCandidate(candidate, profileDir) {
128
129
  };
129
130
  }
130
131
 
132
+ function normalizeProfilePath(value, platform = process.platform) {
133
+ const input = String(value ?? "");
134
+ const resolved = platform === "win32"
135
+ ? path.win32.resolve(input)
136
+ : path.resolve(input);
137
+ if (platform !== "win32") {
138
+ return resolved;
139
+ }
140
+ return path.win32.normalize(resolved).replaceAll("/", "\\").toLowerCase();
141
+ }
142
+
143
+ function profileDirsMatch(left, right, platform = process.platform) {
144
+ return normalizeProfilePath(left, platform) === normalizeProfilePath(right, platform);
145
+ }
146
+
131
147
  async function probeCandidate(candidate, profileDir, {
132
148
  isAlive = isProcessAlive,
133
149
  fetchVersion = fetchCdpVersion,
@@ -202,7 +218,7 @@ async function readProcessTable() {
202
218
  "-Command",
203
219
  "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
204
220
  ],
205
- { maxBuffer: 10 * 1024 * 1024 },
221
+ { maxBuffer: 10 * 1024 * 1024, windowsHide: true },
206
222
  );
207
223
  const parsed = JSON.parse(stdout || "[]");
208
224
  return (Array.isArray(parsed) ? parsed : [parsed]).map((entry) => ({
@@ -219,24 +235,25 @@ async function readProcessTable() {
219
235
  return stdout.split("\n").flatMap((line) => {
220
236
  const match = line.match(/^\s*(\d+)\s+(.+)$/);
221
237
  return match
222
- ? [{ pid: Number(match[1]), command: match[2] }]
238
+ ? [{ pid: Number(match[1]), command: match[2].trim() }]
223
239
  : [];
224
240
  });
225
241
  }
226
242
 
227
- function commandUsesProfile(command, profileDir) {
228
- return (
229
- command.includes(`--user-data-dir=${profileDir}`)
230
- || command.includes(`--user-data-dir="${profileDir}"`)
231
- || command.includes(`--user-data-dir='${profileDir}'`)
232
- );
243
+ function commandUsesProfile(command, profileDir, platform = process.platform) {
244
+ const inline = command.match(/--user-data-dir=(?:"([^"]+)"|'([^']+)'|(\S+))/i);
245
+ const separated = command.match(/--user-data-dir\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i);
246
+ const raw = inline?.[1] ?? inline?.[2] ?? inline?.[3]
247
+ ?? separated?.[1] ?? separated?.[2] ?? separated?.[3]
248
+ ?? null;
249
+ return raw ? profileDirsMatch(raw, profileDir, platform) : false;
233
250
  }
234
251
 
235
- function candidateFromProcess(entry, profileDir) {
252
+ function candidateFromProcess(entry, profileDir, platform = process.platform) {
236
253
  if (
237
254
  !entry.command
238
255
  || entry.command.includes("--type=")
239
- || !commandUsesProfile(entry.command, profileDir)
256
+ || !commandUsesProfile(entry.command, profileDir, platform)
240
257
  ) {
241
258
  return null;
242
259
  }
@@ -246,6 +263,16 @@ function candidateFromProcess(entry, profileDir) {
246
263
  : null;
247
264
  }
248
265
 
266
+ function profileHolderFromProcess(entry, profileDir, platform = process.platform) {
267
+ if (!entry.command || !commandUsesProfile(entry.command, profileDir, platform)) {
268
+ return null;
269
+ }
270
+ const pid = Number(entry.pid);
271
+ return Number.isSafeInteger(pid) && pid > 0
272
+ ? { pid, command: entry.command }
273
+ : null;
274
+ }
275
+
249
276
  async function singletonOwnerPid(profileDir) {
250
277
  if (process.platform === "win32") {
251
278
  return null;
@@ -259,13 +286,17 @@ async function singletonOwnerPid(profileDir) {
259
286
  }
260
287
  }
261
288
 
262
- export async function processMatchesCdpState(state) {
263
- const profileDir = path.resolve(state.profileDir);
289
+ export async function processMatchesCdpState(state, {
290
+ listProcesses = readProcessTable,
291
+ platform = process.platform,
292
+ } = {}) {
293
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
294
+ const profileDir = pathApi.resolve(state.profileDir);
264
295
  const port = Number(state.port);
265
296
  try {
266
- const processes = await readProcessTable();
297
+ const processes = await listProcesses();
267
298
  return processes.some((entry) => {
268
- const candidate = candidateFromProcess(entry, profileDir);
299
+ const candidate = candidateFromProcess(entry, profileDir, platform);
269
300
  return (
270
301
  candidate?.pid === Number(state.pid)
271
302
  && candidate.port === port
@@ -276,30 +307,61 @@ export async function processMatchesCdpState(state) {
276
307
  }
277
308
  }
278
309
 
310
+ function processTableContainsState(
311
+ processes,
312
+ state,
313
+ profileDir,
314
+ platform = process.platform,
315
+ ) {
316
+ const port = Number(state.port);
317
+ const pid = Number(state.pid);
318
+ return processes.some((entry) => {
319
+ const candidate = candidateFromProcess(entry, profileDir, platform);
320
+ return candidate?.pid === pid && candidate.port === port;
321
+ });
322
+ }
323
+
279
324
  export async function discoverReusableCdpState(profileDir, {
280
325
  isAlive = isProcessAlive,
281
326
  fetchVersion = fetchCdpVersion,
282
327
  listProcesses = readProcessTable,
328
+ platform = process.platform,
283
329
  } = {}) {
284
330
  const resolvedProfile = path.resolve(profileDir);
331
+ const requireVerifiedProcessTable = platform === "win32";
285
332
  const saved = await readCdpState(resolvedProfile);
333
+ let processes;
334
+ try {
335
+ processes = await listProcesses();
336
+ } catch {
337
+ if (requireVerifiedProcessTable) {
338
+ return null;
339
+ }
340
+ processes = null;
341
+ }
342
+
286
343
  const savedHealthy = await probeCandidate(saved, resolvedProfile, {
287
344
  isAlive,
288
345
  fetchVersion,
289
346
  });
290
- if (savedHealthy) {
347
+ if (
348
+ savedHealthy
349
+ && (!requireVerifiedProcessTable || processTableContainsState(
350
+ processes ?? [],
351
+ savedHealthy,
352
+ resolvedProfile,
353
+ platform,
354
+ ))
355
+ ) {
291
356
  return await saveCdpState(resolvedProfile, savedHealthy);
292
357
  }
293
358
 
294
- let processes;
295
- try {
296
- processes = await listProcesses();
297
- } catch {
359
+ if (!processes) {
298
360
  return null;
299
361
  }
300
362
 
301
363
  const rawCandidates = processes
302
- .map((entry) => candidateFromProcess(entry, resolvedProfile))
364
+ .map((entry) => candidateFromProcess(entry, resolvedProfile, platform))
303
365
  .filter(Boolean);
304
366
  const uniqueCandidates = [...new Map(
305
367
  rawCandidates.map((candidate) => [
@@ -335,6 +397,150 @@ export async function discoverReusableCdpState(profileDir, {
335
397
  );
336
398
  }
337
399
 
400
+ // Kills Chrome processes bound to this profile whose CDP endpoint is dead, and
401
+ // clears Chrome's singleton guard files. A half-dead prior instance (main
402
+ // process gone or unresponsive, but renderer children still holding the
403
+ // profile) makes a freshly launched Chrome hang during profile initialization:
404
+ // the new CDP port answers HTTP/WS, but the browser main thread never becomes
405
+ // usable, so connectOverCDP times out. Reaping those stale holders first
406
+ // prevents the hang. Only ever touches processes that use THIS profile dir, and
407
+ // never a live/healthy CDP instance. Best-effort.
408
+ export async function reapStaleProfileChrome(profileDir, {
409
+ isAlive = isProcessAlive,
410
+ fetchVersion = fetchCdpVersion,
411
+ listProcesses = readProcessTable,
412
+ killTree = null,
413
+ platform = process.platform,
414
+ } = {}) {
415
+ const resolvedProfile = path.resolve(profileDir);
416
+ let processes;
417
+ try {
418
+ processes = await listProcesses();
419
+ } catch {
420
+ return { killed: [] };
421
+ }
422
+
423
+ const holders = processes
424
+ .map((entry) => profileHolderFromProcess(entry, resolvedProfile, platform))
425
+ .filter(Boolean);
426
+ const candidates = processes
427
+ .map((entry) => candidateFromProcess(entry, resolvedProfile, platform))
428
+ .filter(Boolean);
429
+
430
+ // A healthy main browser owns every helper/renderer using this profile.
431
+ // Never reap individual children from a verified live instance.
432
+ let hasHealthyCdpOwner = false;
433
+ for (const candidate of candidates) {
434
+ try {
435
+ await fetchVersion(`http://127.0.0.1:${candidate.port}`);
436
+ hasHealthyCdpOwner = true;
437
+ break;
438
+ } catch {
439
+ // Continue until one verified owner is found.
440
+ }
441
+ }
442
+ if (hasHealthyCdpOwner) {
443
+ return { killed: [] };
444
+ }
445
+
446
+ const killed = [];
447
+ for (const holder of holders) {
448
+ if (isAlive(holder.pid) && typeof killTree === "function") {
449
+ await killTree(holder.pid).catch(() => {});
450
+ }
451
+ killed.push(holder.pid);
452
+ }
453
+
454
+ // Chrome's singleton profile guard is a symlink; a crashed instance can leave
455
+ // it dangling and block the next launch. Clear it when we reaped holders or
456
+ // when it points at a dead pid.
457
+ const ownerPid = await singletonOwnerPid(resolvedProfile);
458
+ if (killed.length > 0 || (ownerPid && !isAlive(ownerPid))) {
459
+ for (const name of ["SingletonLock", "SingletonCookie", "SingletonSocket"]) {
460
+ await fs.rm(path.join(resolvedProfile, name), { force: true })
461
+ .catch(() => {});
462
+ }
463
+ }
464
+
465
+ return { killed };
466
+ }
467
+
468
+ export async function inspectCdpProfileState(profileDir, {
469
+ isAlive = isProcessAlive,
470
+ fetchVersion = fetchCdpVersion,
471
+ listProcesses = readProcessTable,
472
+ platform = process.platform,
473
+ } = {}) {
474
+ const resolvedProfile = path.resolve(profileDir);
475
+ const diagnostics = [];
476
+
477
+ const lock = await readJson(lockPath(resolvedProfile));
478
+ if (lock?.pid) {
479
+ const alive = isAlive(Number(lock.pid));
480
+ diagnostics.push(
481
+ alive
482
+ ? `profile lock held by live pid=${lock.pid}`
483
+ : `stale profile lock references dead pid=${lock.pid}`,
484
+ );
485
+ } else {
486
+ diagnostics.push("no WTAgent profile lock");
487
+ }
488
+
489
+ const saved = await readJson(statePath(resolvedProfile));
490
+ if (!saved) {
491
+ diagnostics.push("no saved CDP state");
492
+ return {
493
+ status: "pass",
494
+ detail: diagnostics.join("; "),
495
+ };
496
+ }
497
+
498
+ const savedHealthy = await probeCandidate(saved, resolvedProfile, {
499
+ isAlive,
500
+ fetchVersion,
501
+ });
502
+ if (!savedHealthy) {
503
+ diagnostics.push(`saved CDP state for pid=${saved.pid} is stale or unhealthy`);
504
+ return {
505
+ status: "degraded",
506
+ detail: diagnostics.join("; "),
507
+ };
508
+ }
509
+
510
+ if (platform !== "win32") {
511
+ diagnostics.push(`saved CDP state is healthy for pid=${savedHealthy.pid}`);
512
+ return {
513
+ status: "pass",
514
+ detail: diagnostics.join("; "),
515
+ };
516
+ }
517
+
518
+ let processes;
519
+ try {
520
+ processes = await listProcesses();
521
+ } catch (error) {
522
+ diagnostics.push(`cannot verify saved CDP state against PowerShell CIM: ${error.message}`);
523
+ return {
524
+ status: "degraded",
525
+ detail: diagnostics.join("; "),
526
+ };
527
+ }
528
+
529
+ if (!processTableContainsState(processes, savedHealthy, resolvedProfile, platform)) {
530
+ diagnostics.push(`saved CDP state for pid=${savedHealthy.pid} could not be verified against the current process table`);
531
+ return {
532
+ status: "degraded",
533
+ detail: diagnostics.join("; "),
534
+ };
535
+ }
536
+
537
+ diagnostics.push(`saved CDP state is verified for pid=${savedHealthy.pid}`);
538
+ return {
539
+ status: "pass",
540
+ detail: diagnostics.join("; "),
541
+ };
542
+ }
543
+
338
544
  export async function acquireCdpProfileLock(profileDir, {
339
545
  ownerPid = process.pid,
340
546
  isAlive = isProcessAlive,