wtagent 0.1.0-alpha.2 → 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` 永久阻塞。
@@ -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.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",
@@ -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,
@@ -229,9 +229,9 @@ export class ChatGPTWebAdapter {
229
229
  return result;
230
230
  }
231
231
 
232
- // DOM port for the language-independent selector in mode-selection.js. Every
233
- // option is identified by a stable attribute slug (data-testid / data-* / id)
234
- // rather than its localized label, and its disabled state is read from ARIA.
232
+ // DOM port for mode-selection.js. Stable attribute slugs are preferred;
233
+ // exact visible labels are a fallback for current menus that omit those
234
+ // attributes. Disabled state is still read from DOM state, never from text.
235
235
  #modeSelectionPort() {
236
236
  const page = this.page;
237
237
  return {
@@ -287,9 +287,9 @@ export class ChatGPTWebAdapter {
287
287
  );
288
288
  }
289
289
 
290
- // Enumerates the open menu's options into the language-independent shape
291
- // { index, slug, label, disabled }. `slug` is the first stable attribute
292
- // found; `disabled` is read from ARIA / data-state, never from text.
290
+ // Enumerates the open menu's options into { index, slug, label, disabled }.
291
+ // `slug` is the first stable attribute found and may be empty in current
292
+ // ChatGPT menus; `disabled` is read from ARIA / data-state, never from text.
293
293
  async #readModeOptions() {
294
294
  const locator = this.#modeOptionLocators();
295
295
  const count = await locator.count().catch(() => 0);
@@ -332,8 +332,18 @@ export class ChatGPTWebAdapter {
332
332
  return this.lastAssistantMessageId;
333
333
  }
334
334
 
335
- async sendMessage(text, { files = [] } = {}) {
335
+ async sendMessage(text, { files = [], maxBytes = null } = {}) {
336
336
  this.#requirePage();
337
+ const messageBytes = Buffer.byteLength(String(text ?? ""), "utf8");
338
+ if (maxBytes != null && messageBytes > maxBytes) {
339
+ throw new BrowserAdapterError(
340
+ `Outbound message is ${messageBytes} bytes; the limit is ${maxBytes} bytes.`,
341
+ {
342
+ code: "OUTBOUND_MESSAGE_TOO_LARGE",
343
+ details: { messageBytes, maxBytes },
344
+ },
345
+ );
346
+ }
337
347
  const urlBeforeSend = this.page.url();
338
348
  const composer = await this.#waitForComposer(30_000);
339
349
  if (!composer) {
@@ -754,7 +764,7 @@ export class ChatGPTWebAdapter {
754
764
  return fullText;
755
765
  }
756
766
 
757
- async #findModelSwitcher(timeoutMs = 15_000) {
767
+ async #findModelSwitcher(timeoutMs = 45_000) {
758
768
  const deadline = Date.now() + timeoutMs;
759
769
 
760
770
  while (Date.now() < deadline) {
@@ -1,9 +1,9 @@
1
1
  // Language-independent model/mode selection logic for the ChatGPT web picker.
2
2
  //
3
- // The picker's visible labels are localized (e.g. "Pro", and reasoning tiers
4
- // like "Extra high"), so we must NOT match options by their display text.
5
- // Instead every option is identified by a stable attribute "slug" derived from
6
- // data-testid / data-* / id, which does not change with the UI language.
3
+ // Prefer language-independent option slugs derived from data-testid / data-* /
4
+ // id. Current ChatGPT menus sometimes omit every such attribute, including on
5
+ // the Pro item, so an exact normalized label match is kept as a conservative
6
+ // fallback. We never use fuzzy label matching.
7
7
  //
8
8
  // This module is pure and DOM-free so it can be unit-tested without a browser.
9
9
  // The adapter supplies a small "port" of async DOM operations to
@@ -38,6 +38,12 @@ export function slugMatchesToken(slug, token) {
38
38
  return false;
39
39
  }
40
40
 
41
+ function labelMatchesToken(label, token) {
42
+ return String(label ?? "")
43
+ .split(/\r?\n/)
44
+ .some((line) => normalizeToken(line) === token);
45
+ }
46
+
41
47
  // Given the enumerated menu options and the requested mode, decide what to do.
42
48
  // options: [{ index, slug, label, disabled }] in DOM order.
43
49
  // Returns { status, targetIndex, selectedLabel, reason }.
@@ -49,7 +55,14 @@ export function slugMatchesToken(slug, token) {
49
55
  // option is not selectable either.
50
56
  export function chooseModeOption(options, requested) {
51
57
  const token = normalizeToken(requested);
52
- const proIndex = options.findIndex((option) => slugMatchesToken(option.slug, token));
58
+ let proIndex = options.findIndex(
59
+ (option) => slugMatchesToken(option.slug, token),
60
+ );
61
+ if (proIndex === -1) {
62
+ proIndex = options.findIndex(
63
+ (option) => labelMatchesToken(option.label, token),
64
+ );
65
+ }
53
66
 
54
67
  if (proIndex === -1) {
55
68
  return {
@@ -49,7 +49,9 @@ export async function extractAtMentions(text, projectRoot) {
49
49
 
50
50
  // Absolute paths are used as-is; relative paths resolve against the project
51
51
  // root. `~` is expanded to the user's home directory for convenience.
52
- const expanded = expandHome(mention.requested);
52
+ // Backslashes are normalized to the platform separator so Windows-style
53
+ // paths (src\\main.js) work on POSIX hosts too.
54
+ const expanded = expandHome(mention.requested).replaceAll("\\", path.sep);
53
55
  const absPath = path.isAbsolute(expanded)
54
56
  ? path.resolve(expanded)
55
57
  : path.resolve(projectRoot, expanded);