wtagent 0.1.0-alpha.2 → 0.1.0-alpha.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.
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 --registry=https://registry.npmjs.org/
39
+ wtagent login
40
+ ```
41
+
42
+ Windows PowerShell:
43
+
44
+ ```powershell
45
+ npm install -g wtagent --registry=https://registry.npmjs.org/
46
+ wtagent doctor
26
47
  wtagent login
27
48
  ```
28
49
 
@@ -33,6 +54,8 @@ cd my-project
33
54
  wtagent
34
55
  ```
35
56
 
57
+ 启动后先选择 ChatGPT 模式:`Pro` 会为新对话选择 Pro,`Current` 会保留网页当前设置且不执行模型切换。非交互调用默认使用 `Current`;需要 Pro 时可显式传入 `--mode Pro`。
58
+
36
59
  也可以直接附带任务:
37
60
 
38
61
  ```bash
@@ -40,10 +63,16 @@ wtagent "检查这个项目并修复测试"
40
63
  wtagent -C ./my-project "创建一个网站"
41
64
  ```
42
65
 
43
- 之后直接在终端中继续对话。WTAgent 会把任务交给 GPT Web,并在本地执行文件和 Shell 操作。使用 `↑` / `↓` 浏览本次 CLI 会话的历史输入。
66
+ 之后直接在终端中继续对话。WTAgent 会把任务交给 GPT Web,并在本地执行文件操作和结构化本地命令。使用 `↑` / `↓` 浏览本次 CLI 会话的历史输入。
44
67
 
45
68
  按 `Ctrl+C` 或 `Ctrl+D` 可退出并关闭专用 Chrome。若上一次异常退出留下了 Chrome,WTAgent 会在验证其 CDP 身份后复用并接管它。
46
69
 
70
+ 常见说明:
71
+
72
+ - `wtagent doctor` 会检查 Node、Chrome、目录权限和运行环境
73
+ - 首次登录只会写入 WTAgent 的专用 Chrome Profile,不会接管你的日常浏览器 Profile
74
+ - 如果系统没有 Git 或 `rg`,WTAgent 仍应保留核心文件读写与搜索能力
75
+
47
76
  ## English
48
77
 
49
78
  WTAgent connects GPT Web chat to local tools, providing a Codex-like CLI agent experience. The first adapter currently supports ChatGPT Web.
@@ -51,16 +80,37 @@ WTAgent connects GPT Web chat to local tools, providing a Codex-like CLI agent e
51
80
  GPT Web handles reasoning. WTAgent runs locally to:
52
81
 
53
82
  - Read and edit local files
54
- - Run shell commands, builds, tests, and development servers
83
+ - Run structured local commands, builds, tests, and development servers
55
84
 
56
85
  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
86
 
87
+ Support matrix:
88
+
89
+ - macOS
90
+ - Linux
91
+ - Windows 10 1809+ / Windows 11 x64
92
+ - Windows ARM64 is currently preview only
93
+
94
+ Boundaries:
95
+
96
+ - Requires Chrome or Chromium; Edge is out of scope for the first Windows release
97
+ - Git and `rg` are optional accelerators, not runtime requirements
98
+ - WSL is not supported in v1; run WTAgent from native PowerShell, CMD, or Windows Terminal
99
+
58
100
  ### Quick start
59
101
 
60
102
  Requires Node.js 20.17+ and Chrome/Chromium.
61
103
 
62
104
  ```bash
63
- npm install -g wtagent@alpha
105
+ npm install -g wtagent --registry=https://registry.npmjs.org/
106
+ wtagent login
107
+ ```
108
+
109
+ Windows PowerShell:
110
+
111
+ ```powershell
112
+ npm install -g wtagent --registry=https://registry.npmjs.org/
113
+ wtagent doctor
64
114
  wtagent login
65
115
  ```
66
116
 
@@ -71,6 +121,8 @@ cd my-project
71
121
  wtagent
72
122
  ```
73
123
 
124
+ At startup, choose a ChatGPT mode: `Pro` selects Pro for the new conversation, while `Current` keeps the current web setting without changing it. Non-interactive runs default to `Current`; pass `--mode Pro` to opt in explicitly.
125
+
74
126
  Or provide the first task directly:
75
127
 
76
128
  ```bash
@@ -78,10 +130,16 @@ wtagent "inspect this project and fix the tests"
78
130
  wtagent -C ./my-project "build a website"
79
131
  ```
80
132
 
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.
133
+ 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
134
 
83
135
  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
136
 
137
+ Notes:
138
+
139
+ - `wtagent doctor` checks Node, Chrome, writable data paths, and runtime support
140
+ - WTAgent always uses its own Chrome profile and does not reuse your daily browsing profile
141
+ - Core file and search capabilities must continue to work even when Git or `rg` is missing
142
+
85
143
  ## License
86
144
 
87
145
  [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` 永久阻塞。
@@ -820,7 +842,7 @@ macOS、Windows、Linux 都运行:
820
842
  | ChatGPT DOM 变化 | Provider Adapter 隔离、集中 Locator、Fixture 和诊断截图 |
821
843
  | 网页模型不遵守 XML | 简单协议、单工具/轮、确定性解析、有限重发 |
822
844
  | 重复执行工具 | assistant 指纹、call ledger、两阶段事件记录 |
823
- | 大输出塞满聊天 | 本地保存、截断回填、按需读取 |
845
+ | 大输出塞满聊天 | 文件按 16 KiB 分段读取;命令结果回填 4 KiB;浏览器工具消息硬限制 24 KiB;本地命令日志限制 4 MiB |
824
846
  | dev server 阻塞 | 独立 Process Manager |
825
847
  | 跨平台 Shell 差异 | `program + argv + cwd`,复杂 Shell 单独审批 |
826
848
  | 路径逃逸 | realpath、符号链接检查、项目根策略 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtagent",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
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",
@@ -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) => [
@@ -348,6 +410,7 @@ export async function reapStaleProfileChrome(profileDir, {
348
410
  fetchVersion = fetchCdpVersion,
349
411
  listProcesses = readProcessTable,
350
412
  killTree = null,
413
+ platform = process.platform,
351
414
  } = {}) {
352
415
  const resolvedProfile = path.resolve(profileDir);
353
416
  let processes;
@@ -357,28 +420,35 @@ export async function reapStaleProfileChrome(profileDir, {
357
420
  return { killed: [] };
358
421
  }
359
422
 
423
+ const holders = processes
424
+ .map((entry) => profileHolderFromProcess(entry, resolvedProfile, platform))
425
+ .filter(Boolean);
360
426
  const candidates = processes
361
- .map((entry) => candidateFromProcess(entry, resolvedProfile))
427
+ .map((entry) => candidateFromProcess(entry, resolvedProfile, platform))
362
428
  .filter(Boolean);
363
429
 
364
- const killed = [];
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;
365
433
  for (const candidate of candidates) {
366
- // "Stale" = the CDP endpoint does not answer a healthy version probe. A
367
- // healthy instance is left alone (it is a reuse candidate elsewhere).
368
- let healthy = false;
369
434
  try {
370
435
  await fetchVersion(`http://127.0.0.1:${candidate.port}`);
371
- healthy = true;
436
+ hasHealthyCdpOwner = true;
437
+ break;
372
438
  } catch {
373
- healthy = false;
374
- }
375
- if (healthy) {
376
- continue;
439
+ // Continue until one verified owner is found.
377
440
  }
378
- if (isAlive(candidate.pid) && typeof killTree === "function") {
379
- await killTree(candidate.pid).catch(() => {});
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(() => {});
380
450
  }
381
- killed.push(candidate.pid);
451
+ killed.push(holder.pid);
382
452
  }
383
453
 
384
454
  // Chrome's singleton profile guard is a symlink; a crashed instance can leave
@@ -395,6 +465,82 @@ export async function reapStaleProfileChrome(profileDir, {
395
465
  return { killed };
396
466
  }
397
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
+
398
544
  export async function acquireCdpProfileLock(profileDir, {
399
545
  ownerPid = process.pid,
400
546
  isAlive = isProcessAlive,