wtagent 0.1.0-alpha.0 → 0.1.0-alpha.2

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
@@ -1,21 +1,21 @@
1
1
  # WTAgent
2
2
 
3
- Turn ChatGPT Web into a local CLI agent.
3
+ Turn GPT Web into a local CLI agent.
4
4
 
5
- 把 ChatGPT 网页版变成一个本地 CLI Agent。
5
+ 把 GPT 网页聊天变成一个本地 CLI Agent。
6
6
 
7
7
  [中文](#中文) · [English](#english)
8
8
 
9
9
  ## 中文
10
10
 
11
- WTAgent 使用你自己的 ChatGPT Pro 网页会话,提供类似 Codex 的本地 CLI Agent 体验。
11
+ WTAgent 把 GPT 网页聊天连接到本地工具,提供类似 Codex 的 CLI Agent 体验。当前首个适配器支持 ChatGPT Web。
12
12
 
13
- ChatGPT Web 负责思考,WTAgent 在本机:
13
+ 网页 GPT 负责思考,WTAgent 在本机:
14
14
 
15
15
  - 读取和修改本地文件
16
16
  - 调用本地 Shell,运行构建、测试和开发服务
17
17
 
18
- 不需要 OpenAI API Key。WTAgent 使用独立的 Chrome Profile 保存 ChatGPT 登录状态。
18
+ 不需要 OpenAI API Key,也不要求 ChatGPT Pro。WTAgent 使用独立的 Chrome Profile 保存你的网页登录状态,并使用该账号在网页上实际可用的模型和额度;如果账号拥有 Pro,它会成为额外优势。
19
19
 
20
20
  ### 快速开始
21
21
 
@@ -40,18 +40,20 @@ wtagent "检查这个项目并修复测试"
40
40
  wtagent -C ./my-project "创建一个网站"
41
41
  ```
42
42
 
43
- 之后直接在终端中继续对话。WTAgent 会把任务交给 ChatGPT Web,并在本地执行文件和 Shell 操作。
43
+ 之后直接在终端中继续对话。WTAgent 会把任务交给 GPT Web,并在本地执行文件和 Shell 操作。使用 `↑` / `↓` 浏览本次 CLI 会话的历史输入。
44
+
45
+ 按 `Ctrl+C` 或 `Ctrl+D` 可退出并关闭专用 Chrome。若上一次异常退出留下了 Chrome,WTAgent 会在验证其 CDP 身份后复用并接管它。
44
46
 
45
47
  ## English
46
48
 
47
- WTAgent turns your own ChatGPT Pro web session into a Codex-like local CLI agent.
49
+ WTAgent connects GPT Web chat to local tools, providing a Codex-like CLI agent experience. The first adapter currently supports ChatGPT Web.
48
50
 
49
- ChatGPT Web handles reasoning. WTAgent runs locally to:
51
+ GPT Web handles reasoning. WTAgent runs locally to:
50
52
 
51
53
  - Read and edit local files
52
54
  - Run shell commands, builds, tests, and development servers
53
55
 
54
- No OpenAI API key is required. WTAgent stores the ChatGPT session in a dedicated Chrome profile.
56
+ 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.
55
57
 
56
58
  ### Quick start
57
59
 
@@ -76,7 +78,9 @@ wtagent "inspect this project and fix the tests"
76
78
  wtagent -C ./my-project "build a website"
77
79
  ```
78
80
 
79
- Continue chatting in the terminal. WTAgent sends tasks to ChatGPT Web and executes file and shell operations locally.
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.
82
+
83
+ 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.
80
84
 
81
85
  ## License
82
86
 
@@ -1,4 +1,4 @@
1
- # WTAgent:ChatGPT Web 本地工具 Agent 技术方案
1
+ # WTAgent:GPT Web 本地工具 Agent 技术方案
2
2
 
3
3
  ## 1. 结论
4
4
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  - 用户在终端中选择项目目录并输入开发任务。
8
8
  - CLI 启动一个独立、可见、持久化 Profile 的 Chrome。
9
- - 用户首次在该 Chrome 中手动登录自己的 ChatGPT Pro。
9
+ - 用户首次在该 Chrome 中手动登录自己的 ChatGPT Web 账号;Pro 不是前提,只是账号可用时的额外模型与额度。
10
10
  - ChatGPT Web 只负责推理;本地 Runtime 负责工具、权限、状态和恢复。
11
11
  - 双方通过普通聊天文本中的自定义 XML 交换工具调用和结果。
12
12
  - Runtime 循环执行“网页回复 → 解析工具 → 本地执行 → 回填结果”,直到任务完成。
@@ -593,7 +593,7 @@ sessions/<session-id>/
593
593
  tool-output.jsonl
594
594
  ```
595
595
 
596
- `session.json` 保存项目根目录、ChatGPT 会话 URL、当前 run phase、最近 turn、等待回填的工具结果和副作用恢复日志。它没有不可继续的 `completed task` 状态;`done=true` 只结束当前 run,Session 回到 `idle`。
596
+ `session.json` 保存项目根目录、ChatGPT 会话 URL、最近确认的 assistant message ID、最近确认的实际模式、当前 run phase、最近 turn、等待回填的工具结果和副作用恢复日志。它没有不可继续的 `completed task` 状态;`done=true` 只结束当前 run,Session 回到 `idle`。
597
597
 
598
598
  `rollout-*.jsonl` 从创建时起直接使用 **Codex rollout 风格**:首行 `session_meta`,后续每行 `{timestamp, type: "response_item", payload}`,payload 采用 OpenAI Responses 形状(`message` / `function_call` / `function_call_output`)。
599
599
 
@@ -626,8 +626,11 @@ sessionId + assistantMessageIdentity + normalizedToolCall
626
626
 
627
627
  ### 9.3 浏览器恢复
628
628
 
629
- - Chrome 仍在:重新连接 Page 并校验会话 URL。
629
+ - Chrome 仍在:根据专用 Profile、PID、CDP 端口和健康检查验证身份,复用浏览器并创建新的 Page。
630
630
  - Chrome 崩溃:用相同 Profile 重启并打开会话 URL。
631
+ - 同一 CLI 进程中的 follow-up 保持在当前会话 Page,不重复导航;跨进程恢复需要等待本地记录的最近 assistant message ID 出现在 DOM 后才能发送。
632
+ - 每次发送记录已有消息 ID 和新 user turn,只接受位于该 user turn 之后的新 assistant turn;无法建立可靠消息身份时超时并保存诊断,禁止退化为基于数量或文本变化猜测。
633
+ - 同一 Profile 同时只允许一个 WTAgent CLI Session;启动和接管过程使用 Profile 级互斥锁。
631
634
  - 登录失效或出现验证:进入 `AUTH_REQUIRED`/`PAUSED`,让用户接管。
632
635
  - 会话页面丢失:从本地记录打开原会话;无法恢复时创建新的本地 Session 和新的网页对话,不向旧 rollout 继续追加。
633
636
 
@@ -639,6 +642,8 @@ sessionId + assistantMessageIdentity + normalizedToolCall
639
642
  2. 当前可用工具及参数 Schema。
640
643
  3. 用户任务、项目根目录语义和执行边界。
641
644
 
645
+ 同一网页会话中的普通 follow-up 只发送新的用户输入,并在末尾追加短 `<system_reminder>`;不得重复发送完整 `<agent_protocol>`、工具目录或初始任务。只有没有新用户输入的中断恢复流程可以发送完整 resume scaffold。
646
+
642
647
  关键规则:
643
648
 
644
649
  - 一轮最多一个工具调用。
@@ -686,8 +691,8 @@ Session 界面展示:
686
691
 
687
692
  用户可随时:
688
693
 
689
- - `Ctrl+C` 第一次请求安全暂停;
690
- - 再次 `Ctrl+C` 强制终止当前 run;
694
+ - `Ctrl+C` 或 `Ctrl+D` 退出 CLI,并关闭专用 Chrome;
695
+ - 若关闭未完成,保留经过验证的 CDP 状态,下一次启动复用后再次执行关闭;
691
696
  - 输入补充指令;
692
697
  - 打开浏览器人工接管;
693
698
  - 恢复自动化。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtagent",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Turn your own web AI session into a local tool-using agent.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,6 +2,16 @@ import net from "node:net";
2
2
  import { spawn } from "node:child_process";
3
3
  import { chromium } from "playwright-core";
4
4
  import { killProcessTree } from "../tools/process-utils.js";
5
+ import {
6
+ acquireCdpProfileLock,
7
+ discoverReusableCdpState,
8
+ fetchCdpVersion,
9
+ processMatchesCdpState,
10
+ reapStaleProfileChrome,
11
+ removeCdpState,
12
+ saveCdpState,
13
+ waitForProcessExit,
14
+ } from "./cdp-state.js";
5
15
 
6
16
  async function reservePort() {
7
17
  return await new Promise((resolve, reject) => {
@@ -22,6 +32,47 @@ async function reservePort() {
22
32
  });
23
33
  }
24
34
 
35
+ async function settleWithin(promise, timeoutMs) {
36
+ let timer;
37
+ try {
38
+ await Promise.race([
39
+ Promise.resolve(promise).catch(() => null),
40
+ new Promise((resolve) => {
41
+ timer = setTimeout(resolve, timeoutMs);
42
+ }),
43
+ ]);
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
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
+
25
76
  async function waitForCdp({
26
77
  endpoint,
27
78
  child,
@@ -42,7 +93,7 @@ async function waitForCdp({
42
93
  if (response.ok) {
43
94
  const version = await response.json();
44
95
  if (version.webSocketDebuggerUrl) {
45
- return;
96
+ return version;
46
97
  }
47
98
  }
48
99
  } catch (error) {
@@ -57,21 +108,6 @@ async function waitForCdp({
57
108
  );
58
109
  }
59
110
 
60
- async function waitForExit(child, timeoutMs) {
61
- if (child.exitCode != null || child.signalCode != null) {
62
- return true;
63
- }
64
-
65
- return await new Promise((resolve) => {
66
- const timer = setTimeout(() => resolve(false), timeoutMs);
67
- timer.unref?.();
68
- child.once("close", () => {
69
- clearTimeout(timer);
70
- resolve(true);
71
- });
72
- });
73
- }
74
-
75
111
  // Sets the OS window state (e.g. "minimized" / "normal") of the window hosting
76
112
  // `page` via the CDP Browser domain. On macOS the Chromium launch flags for
77
113
  // minimizing (--start-minimized) and off-screen positioning are ignored or
@@ -101,41 +137,140 @@ export async function launchAndConnectCdpChrome({
101
137
  profileDir,
102
138
  url = "about:blank",
103
139
  minimized = false,
104
- }) {
105
- const port = await reservePort();
106
- const endpoint = `http://127.0.0.1:${port}`;
107
- const child = spawn(
108
- executablePath,
109
- [
110
- `--remote-debugging-port=${port}`,
111
- "--remote-debugging-address=127.0.0.1",
112
- `--user-data-dir=${profileDir}`,
113
- "--profile-directory=Default",
114
- "--no-first-run",
115
- "--no-default-browser-check",
116
- url,
117
- ],
118
- {
119
- detached: process.platform !== "win32",
120
- stdio: "ignore",
121
- windowsHide: false,
122
- },
123
- );
140
+ }, {
141
+ acquireProfileLock = acquireCdpProfileLock,
142
+ connectOverCDP = (endpoint) => chromium.connectOverCDP(endpoint),
143
+ discoverReusable = discoverReusableCdpState,
144
+ fetchVersion = fetchCdpVersion,
145
+ killTree = killProcessTree,
146
+ matchesState = processMatchesCdpState,
147
+ reapStale = reapStaleProfileChrome,
148
+ removeState = removeCdpState,
149
+ reserveCdpPort = reservePort,
150
+ saveState = saveCdpState,
151
+ spawnChrome = spawn,
152
+ waitForExit = waitForProcessExit,
153
+ waitForReady = waitForCdp,
154
+ connectTimeoutMs = 20_000,
155
+ } = {}) {
156
+ const releaseProfileLock = await acquireProfileLock(profileDir);
157
+ let child = null;
158
+ let state = null;
159
+ let browser = null;
160
+ let closePromise = null;
161
+ let reused = false;
124
162
 
125
- await new Promise((resolve, reject) => {
126
- child.once("spawn", resolve);
127
- child.once("error", reject);
128
- });
163
+ async function findReusable() {
164
+ return await discoverReusable(profileDir, { fetchVersion });
165
+ }
129
166
 
130
167
  try {
131
- await waitForCdp({ endpoint, child });
132
- const browser = await chromium.connectOverCDP(endpoint);
133
- const context = browser.contexts()[0];
168
+ state = await findReusable();
169
+ if (state) {
170
+ reused = true;
171
+ } else {
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);
178
+ const port = await reserveCdpPort();
179
+ const endpoint = `http://127.0.0.1:${port}`;
180
+ child = spawnChrome(
181
+ executablePath,
182
+ [
183
+ `--remote-debugging-port=${port}`,
184
+ "--remote-debugging-address=127.0.0.1",
185
+ `--user-data-dir=${profileDir}`,
186
+ "--profile-directory=Default",
187
+ "--no-first-run",
188
+ "--no-default-browser-check",
189
+ url,
190
+ ],
191
+ {
192
+ detached: process.platform !== "win32",
193
+ stdio: "ignore",
194
+ windowsHide: false,
195
+ },
196
+ );
197
+
198
+ await new Promise((resolve, reject) => {
199
+ child.once("spawn", resolve);
200
+ child.once("error", reject);
201
+ });
202
+
203
+ try {
204
+ const version = await waitForReady({ endpoint, child });
205
+ state = await saveState(profileDir, {
206
+ pid: child.pid,
207
+ port,
208
+ endpoint,
209
+ profileDir,
210
+ browser: version.Browser ?? null,
211
+ webSocketDebuggerUrl: version.webSocketDebuggerUrl,
212
+ });
213
+ } catch (error) {
214
+ // Chrome may forward the URL to an existing process using the same
215
+ // profile and then exit successfully. Re-scan after that handoff and
216
+ // adopt the verified live CDP instance instead of reporting a false
217
+ // launch failure.
218
+ if (child.exitCode === 0 && child.signalCode == null) {
219
+ state = await findReusable();
220
+ if (state) {
221
+ reused = true;
222
+ }
223
+ }
224
+ if (!state) {
225
+ throw error;
226
+ }
227
+ }
228
+ }
229
+
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
+ }
134
265
  if (!context) {
135
266
  throw new Error("Chrome CDP connection did not expose a browser context.");
136
267
  }
137
268
 
138
- const page = context.pages()[0] ?? await context.newPage();
269
+ // A reused browser may still contain the previous conversation. Keep it
270
+ // intact and create a fresh target for this CLI session.
271
+ const page = reused
272
+ ? await context.newPage()
273
+ : context.pages()[0] ?? await context.newPage();
139
274
  if (minimized) {
140
275
  await setWindowState(context, page, "minimized");
141
276
  }
@@ -144,27 +279,75 @@ export async function launchAndConnectCdpChrome({
144
279
  browser,
145
280
  context,
146
281
  child,
147
- endpoint,
282
+ endpoint: state.endpoint,
283
+ page,
284
+ pid: state.pid,
285
+ reused,
148
286
  // Minimize / restore the visible window on demand. The runtime restores
149
287
  // the window when it needs the user (manual login, CAPTCHA) and
150
288
  // re-minimizes afterward. Uses the live current page each time so it
151
289
  // targets the window the user is actually looking at.
152
290
  async minimize() {
153
- return await setWindowState(context, context.pages()[0] ?? page, "minimized");
291
+ return await setWindowState(context, page, "minimized");
154
292
  },
155
293
  async restore() {
156
- return await setWindowState(context, context.pages()[0] ?? page, "normal");
294
+ return await setWindowState(context, page, "normal");
157
295
  },
158
296
  async close() {
159
- await browser.close().catch(() => null);
160
- if (!await waitForExit(child, 2_000)) {
161
- await killProcessTree(child.pid);
162
- await waitForExit(child, 2_000);
297
+ if (closePromise) {
298
+ return await closePromise;
163
299
  }
300
+ closePromise = (async () => {
301
+ let exited = false;
302
+ try {
303
+ // browser.close() on a connectOverCDP browser only disconnects the
304
+ // Playwright transport. Browser.close asks Chrome itself to exit.
305
+ const session = await browser.newBrowserCDPSession()
306
+ .catch(() => null);
307
+ if (session) {
308
+ await settleWithin(session.send("Browser.close"), 1_500);
309
+ await settleWithin(session.detach(), 500);
310
+ }
311
+ exited = await waitForExit(state.pid, 3_000);
312
+ await settleWithin(browser.close(), 1_500);
313
+
314
+ if (!exited) {
315
+ const childStillOwnsPid = child?.pid === state.pid
316
+ && child.exitCode == null
317
+ && child.signalCode == null;
318
+ const safeToKill = childStillOwnsPid
319
+ || await matchesState(state);
320
+ if (safeToKill) {
321
+ await killTree(state.pid);
322
+ exited = await waitForExit(state.pid, 3_000);
323
+ }
324
+ }
325
+
326
+ if (exited) {
327
+ await removeState(profileDir, state);
328
+ return;
329
+ }
330
+ throw new Error(
331
+ `Chrome pid=${state.pid} did not exit; its verified CDP state `
332
+ + "was kept so the next WTAgent run can reuse it.",
333
+ );
334
+ } finally {
335
+ await releaseProfileLock();
336
+ }
337
+ })();
338
+ return await closePromise;
164
339
  },
165
340
  };
166
341
  } catch (error) {
167
- await killProcessTree(child.pid);
342
+ await browser?.close().catch(() => null);
343
+ if (
344
+ child
345
+ && child.exitCode == null
346
+ && child.signalCode == null
347
+ ) {
348
+ await killTree(child.pid);
349
+ }
350
+ await releaseProfileLock();
168
351
  throw error;
169
352
  }
170
353
  }