wtagent 0.1.0-alpha.0 → 0.1.0-alpha.1

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 循环执行“网页回复 → 解析工具 → 本地执行 → 回填结果”,直到任务完成。
@@ -626,8 +626,9 @@ 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
+ - 同一 Profile 同时只允许一个 WTAgent CLI Session;启动和接管过程使用 Profile 级互斥锁。
631
632
  - 登录失效或出现验证:进入 `AUTH_REQUIRED`/`PAUSED`,让用户接管。
632
633
  - 会话页面丢失:从本地记录打开原会话;无法恢复时创建新的本地 Session 和新的网页对话,不向旧 rollout 继续追加。
633
634
 
@@ -686,8 +687,8 @@ Session 界面展示:
686
687
 
687
688
  用户可随时:
688
689
 
689
- - `Ctrl+C` 第一次请求安全暂停;
690
- - 再次 `Ctrl+C` 强制终止当前 run;
690
+ - `Ctrl+C` 或 `Ctrl+D` 退出 CLI,并关闭专用 Chrome;
691
+ - 若关闭未完成,保留经过验证的 CDP 状态,下一次启动复用后再次执行关闭;
691
692
  - 输入补充指令;
692
693
  - 打开浏览器人工接管;
693
694
  - 恢复自动化。
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.1",
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,15 @@ 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
+ removeCdpState,
11
+ saveCdpState,
12
+ waitForProcessExit,
13
+ } from "./cdp-state.js";
5
14
 
6
15
  async function reservePort() {
7
16
  return await new Promise((resolve, reject) => {
@@ -22,6 +31,20 @@ async function reservePort() {
22
31
  });
23
32
  }
24
33
 
34
+ async function settleWithin(promise, timeoutMs) {
35
+ let timer;
36
+ try {
37
+ await Promise.race([
38
+ Promise.resolve(promise).catch(() => null),
39
+ new Promise((resolve) => {
40
+ timer = setTimeout(resolve, timeoutMs);
41
+ }),
42
+ ]);
43
+ } finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
47
+
25
48
  async function waitForCdp({
26
49
  endpoint,
27
50
  child,
@@ -42,7 +65,7 @@ async function waitForCdp({
42
65
  if (response.ok) {
43
66
  const version = await response.json();
44
67
  if (version.webSocketDebuggerUrl) {
45
- return;
68
+ return version;
46
69
  }
47
70
  }
48
71
  } catch (error) {
@@ -57,21 +80,6 @@ async function waitForCdp({
57
80
  );
58
81
  }
59
82
 
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
83
  // Sets the OS window state (e.g. "minimized" / "normal") of the window hosting
76
84
  // `page` via the CDP Browser domain. On macOS the Chromium launch flags for
77
85
  // minimizing (--start-minimized) and off-screen positioning are ignored or
@@ -101,41 +109,100 @@ export async function launchAndConnectCdpChrome({
101
109
  profileDir,
102
110
  url = "about:blank",
103
111
  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
- );
112
+ }, {
113
+ acquireProfileLock = acquireCdpProfileLock,
114
+ connectOverCDP = (endpoint) => chromium.connectOverCDP(endpoint),
115
+ discoverReusable = discoverReusableCdpState,
116
+ fetchVersion = fetchCdpVersion,
117
+ killTree = killProcessTree,
118
+ matchesState = processMatchesCdpState,
119
+ removeState = removeCdpState,
120
+ reserveCdpPort = reservePort,
121
+ saveState = saveCdpState,
122
+ spawnChrome = spawn,
123
+ waitForExit = waitForProcessExit,
124
+ waitForReady = waitForCdp,
125
+ } = {}) {
126
+ const releaseProfileLock = await acquireProfileLock(profileDir);
127
+ let child = null;
128
+ let state = null;
129
+ let browser = null;
130
+ let closePromise = null;
131
+ let reused = false;
124
132
 
125
- await new Promise((resolve, reject) => {
126
- child.once("spawn", resolve);
127
- child.once("error", reject);
128
- });
133
+ async function findReusable() {
134
+ return await discoverReusable(profileDir, { fetchVersion });
135
+ }
129
136
 
130
137
  try {
131
- await waitForCdp({ endpoint, child });
132
- const browser = await chromium.connectOverCDP(endpoint);
138
+ state = await findReusable();
139
+ if (state) {
140
+ reused = true;
141
+ } else {
142
+ await removeState(profileDir);
143
+ const port = await reserveCdpPort();
144
+ const endpoint = `http://127.0.0.1:${port}`;
145
+ child = spawnChrome(
146
+ executablePath,
147
+ [
148
+ `--remote-debugging-port=${port}`,
149
+ "--remote-debugging-address=127.0.0.1",
150
+ `--user-data-dir=${profileDir}`,
151
+ "--profile-directory=Default",
152
+ "--no-first-run",
153
+ "--no-default-browser-check",
154
+ url,
155
+ ],
156
+ {
157
+ detached: process.platform !== "win32",
158
+ stdio: "ignore",
159
+ windowsHide: false,
160
+ },
161
+ );
162
+
163
+ await new Promise((resolve, reject) => {
164
+ child.once("spawn", resolve);
165
+ child.once("error", reject);
166
+ });
167
+
168
+ try {
169
+ const version = await waitForReady({ endpoint, child });
170
+ state = await saveState(profileDir, {
171
+ pid: child.pid,
172
+ port,
173
+ endpoint,
174
+ profileDir,
175
+ browser: version.Browser ?? null,
176
+ webSocketDebuggerUrl: version.webSocketDebuggerUrl,
177
+ });
178
+ } catch (error) {
179
+ // Chrome may forward the URL to an existing process using the same
180
+ // profile and then exit successfully. Re-scan after that handoff and
181
+ // adopt the verified live CDP instance instead of reporting a false
182
+ // launch failure.
183
+ if (child.exitCode === 0 && child.signalCode == null) {
184
+ state = await findReusable();
185
+ if (state) {
186
+ reused = true;
187
+ }
188
+ }
189
+ if (!state) {
190
+ throw error;
191
+ }
192
+ }
193
+ }
194
+
195
+ browser = await connectOverCDP(state.endpoint);
133
196
  const context = browser.contexts()[0];
134
197
  if (!context) {
135
198
  throw new Error("Chrome CDP connection did not expose a browser context.");
136
199
  }
137
200
 
138
- const page = context.pages()[0] ?? await context.newPage();
201
+ // A reused browser may still contain the previous conversation. Keep it
202
+ // intact and create a fresh target for this CLI session.
203
+ const page = reused
204
+ ? await context.newPage()
205
+ : context.pages()[0] ?? await context.newPage();
139
206
  if (minimized) {
140
207
  await setWindowState(context, page, "minimized");
141
208
  }
@@ -144,27 +211,75 @@ export async function launchAndConnectCdpChrome({
144
211
  browser,
145
212
  context,
146
213
  child,
147
- endpoint,
214
+ endpoint: state.endpoint,
215
+ page,
216
+ pid: state.pid,
217
+ reused,
148
218
  // Minimize / restore the visible window on demand. The runtime restores
149
219
  // the window when it needs the user (manual login, CAPTCHA) and
150
220
  // re-minimizes afterward. Uses the live current page each time so it
151
221
  // targets the window the user is actually looking at.
152
222
  async minimize() {
153
- return await setWindowState(context, context.pages()[0] ?? page, "minimized");
223
+ return await setWindowState(context, page, "minimized");
154
224
  },
155
225
  async restore() {
156
- return await setWindowState(context, context.pages()[0] ?? page, "normal");
226
+ return await setWindowState(context, page, "normal");
157
227
  },
158
228
  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);
229
+ if (closePromise) {
230
+ return await closePromise;
163
231
  }
232
+ closePromise = (async () => {
233
+ let exited = false;
234
+ try {
235
+ // browser.close() on a connectOverCDP browser only disconnects the
236
+ // Playwright transport. Browser.close asks Chrome itself to exit.
237
+ const session = await browser.newBrowserCDPSession()
238
+ .catch(() => null);
239
+ if (session) {
240
+ await settleWithin(session.send("Browser.close"), 1_500);
241
+ await settleWithin(session.detach(), 500);
242
+ }
243
+ exited = await waitForExit(state.pid, 3_000);
244
+ await settleWithin(browser.close(), 1_500);
245
+
246
+ if (!exited) {
247
+ const childStillOwnsPid = child?.pid === state.pid
248
+ && child.exitCode == null
249
+ && child.signalCode == null;
250
+ const safeToKill = childStillOwnsPid
251
+ || await matchesState(state);
252
+ if (safeToKill) {
253
+ await killTree(state.pid);
254
+ exited = await waitForExit(state.pid, 3_000);
255
+ }
256
+ }
257
+
258
+ if (exited) {
259
+ await removeState(profileDir, state);
260
+ return;
261
+ }
262
+ throw new Error(
263
+ `Chrome pid=${state.pid} did not exit; its verified CDP state `
264
+ + "was kept so the next WTAgent run can reuse it.",
265
+ );
266
+ } finally {
267
+ await releaseProfileLock();
268
+ }
269
+ })();
270
+ return await closePromise;
164
271
  },
165
272
  };
166
273
  } catch (error) {
167
- await killProcessTree(child.pid);
274
+ await browser?.close().catch(() => null);
275
+ if (
276
+ child
277
+ && child.exitCode == null
278
+ && child.signalCode == null
279
+ ) {
280
+ await killTree(child.pid);
281
+ }
282
+ await releaseProfileLock();
168
283
  throw error;
169
284
  }
170
285
  }
@@ -0,0 +1,399 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const CDP_STATE_FILE = ".wtagent-cdp.json";
9
+ const PROFILE_LOCK_FILE = ".wtagent-session.lock";
10
+ const STATE_VERSION = 1;
11
+ const LOCK_INITIALIZATION_GRACE_MS = 5_000;
12
+
13
+ function statePath(profileDir) {
14
+ return path.join(profileDir, CDP_STATE_FILE);
15
+ }
16
+
17
+ function lockPath(profileDir) {
18
+ return path.join(profileDir, PROFILE_LOCK_FILE);
19
+ }
20
+
21
+ async function readJson(filePath) {
22
+ try {
23
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
24
+ } catch (error) {
25
+ if (error.code === "ENOENT" || error instanceof SyntaxError) {
26
+ return null;
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ async function writeJsonAtomic(filePath, value) {
33
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
34
+ try {
35
+ await fs.writeFile(
36
+ temporary,
37
+ `${JSON.stringify(value, null, 2)}\n`,
38
+ { mode: 0o600 },
39
+ );
40
+ await fs.rename(temporary, filePath);
41
+ } finally {
42
+ await fs.rm(temporary, { force: true }).catch(() => {});
43
+ }
44
+ }
45
+
46
+ async function readInitializedLock(filePath) {
47
+ for (let attempt = 0; attempt < 10; attempt += 1) {
48
+ const value = await readJson(filePath);
49
+ if (
50
+ Number.isSafeInteger(Number(value?.pid))
51
+ && Number(value.pid) > 0
52
+ && typeof value.token === "string"
53
+ && value.token
54
+ ) {
55
+ return value;
56
+ }
57
+ await new Promise((resolve) => setTimeout(resolve, 25));
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function isProcessAlive(pid) {
63
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
64
+ return false;
65
+ }
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch (error) {
70
+ return error.code === "EPERM";
71
+ }
72
+ }
73
+
74
+ export async function waitForProcessExit(
75
+ pid,
76
+ timeoutMs,
77
+ { isAlive = isProcessAlive } = {},
78
+ ) {
79
+ const deadline = Date.now() + timeoutMs;
80
+ while (Date.now() < deadline) {
81
+ if (!isAlive(pid)) {
82
+ return true;
83
+ }
84
+ await new Promise((resolve) => setTimeout(resolve, 100));
85
+ }
86
+ return !isAlive(pid);
87
+ }
88
+
89
+ export async function fetchCdpVersion(endpoint, timeoutMs = 1_500) {
90
+ const response = await fetch(`${endpoint}/json/version`, {
91
+ signal: AbortSignal.timeout(timeoutMs),
92
+ });
93
+ if (!response.ok) {
94
+ throw new Error(`CDP health check returned HTTP ${response.status}.`);
95
+ }
96
+ const version = await response.json();
97
+ if (!version.webSocketDebuggerUrl) {
98
+ throw new Error("CDP health check did not return a WebSocket endpoint.");
99
+ }
100
+ return version;
101
+ }
102
+
103
+ function normalizeCandidate(candidate, profileDir) {
104
+ const pid = Number(candidate?.pid);
105
+ const port = Number(candidate?.port);
106
+ if (
107
+ !Number.isSafeInteger(pid)
108
+ || pid <= 0
109
+ || !Number.isSafeInteger(port)
110
+ || port <= 0
111
+ || port > 65_535
112
+ ) {
113
+ return null;
114
+ }
115
+ if (
116
+ candidate.profileDir
117
+ && path.resolve(candidate.profileDir) !== profileDir
118
+ ) {
119
+ return null;
120
+ }
121
+ return {
122
+ stateVersion: STATE_VERSION,
123
+ pid,
124
+ port,
125
+ endpoint: `http://127.0.0.1:${port}`,
126
+ profileDir,
127
+ startedAt: candidate.startedAt ?? null,
128
+ };
129
+ }
130
+
131
+ async function probeCandidate(candidate, profileDir, {
132
+ isAlive = isProcessAlive,
133
+ fetchVersion = fetchCdpVersion,
134
+ } = {}) {
135
+ const normalized = normalizeCandidate(candidate, profileDir);
136
+ if (!normalized || !isAlive(normalized.pid)) {
137
+ return null;
138
+ }
139
+ try {
140
+ const version = await fetchVersion(normalized.endpoint);
141
+ if (
142
+ candidate.webSocketDebuggerUrl
143
+ && candidate.webSocketDebuggerUrl !== version.webSocketDebuggerUrl
144
+ ) {
145
+ return null;
146
+ }
147
+ return {
148
+ ...normalized,
149
+ browser: version.Browser ?? null,
150
+ webSocketDebuggerUrl: version.webSocketDebuggerUrl,
151
+ };
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ export async function readCdpState(profileDir) {
158
+ return await readJson(statePath(path.resolve(profileDir)));
159
+ }
160
+
161
+ export async function saveCdpState(profileDir, state) {
162
+ const resolvedProfile = path.resolve(profileDir);
163
+ const normalized = normalizeCandidate(state, resolvedProfile);
164
+ if (!normalized) {
165
+ throw new Error("Refusing to save invalid WTAgent CDP state.");
166
+ }
167
+ const value = {
168
+ ...normalized,
169
+ browser: state.browser ?? null,
170
+ webSocketDebuggerUrl: state.webSocketDebuggerUrl ?? null,
171
+ startedAt: state.startedAt ?? new Date().toISOString(),
172
+ };
173
+ await writeJsonAtomic(statePath(resolvedProfile), value);
174
+ return value;
175
+ }
176
+
177
+ export async function removeCdpState(profileDir, expected = null) {
178
+ const filePath = statePath(path.resolve(profileDir));
179
+ if (expected) {
180
+ const current = await readJson(filePath);
181
+ if (
182
+ current
183
+ && (
184
+ Number(current.pid) !== Number(expected.pid)
185
+ || Number(current.port) !== Number(expected.port)
186
+ )
187
+ ) {
188
+ return false;
189
+ }
190
+ }
191
+ await fs.rm(filePath, { force: true });
192
+ return true;
193
+ }
194
+
195
+ async function readProcessTable() {
196
+ if (process.platform === "win32") {
197
+ const { stdout } = await execFileAsync(
198
+ "powershell.exe",
199
+ [
200
+ "-NoProfile",
201
+ "-NonInteractive",
202
+ "-Command",
203
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
204
+ ],
205
+ { maxBuffer: 10 * 1024 * 1024 },
206
+ );
207
+ const parsed = JSON.parse(stdout || "[]");
208
+ return (Array.isArray(parsed) ? parsed : [parsed]).map((entry) => ({
209
+ pid: Number(entry.ProcessId),
210
+ command: String(entry.CommandLine ?? ""),
211
+ }));
212
+ }
213
+
214
+ const { stdout } = await execFileAsync(
215
+ "ps",
216
+ ["-axo", "pid=,command="],
217
+ { maxBuffer: 10 * 1024 * 1024 },
218
+ );
219
+ return stdout.split("\n").flatMap((line) => {
220
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
221
+ return match
222
+ ? [{ pid: Number(match[1]), command: match[2] }]
223
+ : [];
224
+ });
225
+ }
226
+
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
+ );
233
+ }
234
+
235
+ function candidateFromProcess(entry, profileDir) {
236
+ if (
237
+ !entry.command
238
+ || entry.command.includes("--type=")
239
+ || !commandUsesProfile(entry.command, profileDir)
240
+ ) {
241
+ return null;
242
+ }
243
+ const port = entry.command.match(/--remote-debugging-port=(\d+)/)?.[1];
244
+ return port
245
+ ? { pid: entry.pid, port: Number(port), profileDir }
246
+ : null;
247
+ }
248
+
249
+ async function singletonOwnerPid(profileDir) {
250
+ if (process.platform === "win32") {
251
+ return null;
252
+ }
253
+ try {
254
+ const target = await fs.readlink(path.join(profileDir, "SingletonLock"));
255
+ const pid = Number(target.match(/-(\d+)$/)?.[1]);
256
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ export async function processMatchesCdpState(state) {
263
+ const profileDir = path.resolve(state.profileDir);
264
+ const port = Number(state.port);
265
+ try {
266
+ const processes = await readProcessTable();
267
+ return processes.some((entry) => {
268
+ const candidate = candidateFromProcess(entry, profileDir);
269
+ return (
270
+ candidate?.pid === Number(state.pid)
271
+ && candidate.port === port
272
+ );
273
+ });
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ export async function discoverReusableCdpState(profileDir, {
280
+ isAlive = isProcessAlive,
281
+ fetchVersion = fetchCdpVersion,
282
+ listProcesses = readProcessTable,
283
+ } = {}) {
284
+ const resolvedProfile = path.resolve(profileDir);
285
+ const saved = await readCdpState(resolvedProfile);
286
+ const savedHealthy = await probeCandidate(saved, resolvedProfile, {
287
+ isAlive,
288
+ fetchVersion,
289
+ });
290
+ if (savedHealthy) {
291
+ return await saveCdpState(resolvedProfile, savedHealthy);
292
+ }
293
+
294
+ let processes;
295
+ try {
296
+ processes = await listProcesses();
297
+ } catch {
298
+ return null;
299
+ }
300
+
301
+ const rawCandidates = processes
302
+ .map((entry) => candidateFromProcess(entry, resolvedProfile))
303
+ .filter(Boolean);
304
+ const uniqueCandidates = [...new Map(
305
+ rawCandidates.map((candidate) => [
306
+ `${candidate.pid}:${candidate.port}`,
307
+ candidate,
308
+ ]),
309
+ ).values()];
310
+ const healthy = (await Promise.all(
311
+ uniqueCandidates.map((candidate) =>
312
+ probeCandidate(candidate, resolvedProfile, {
313
+ isAlive,
314
+ fetchVersion,
315
+ })
316
+ ),
317
+ )).filter(Boolean);
318
+
319
+ if (healthy.length === 0) {
320
+ return null;
321
+ }
322
+ if (healthy.length === 1) {
323
+ return await saveCdpState(resolvedProfile, healthy[0]);
324
+ }
325
+
326
+ const ownerPid = await singletonOwnerPid(resolvedProfile);
327
+ const singleton = healthy.find((candidate) => candidate.pid === ownerPid);
328
+ if (singleton) {
329
+ return await saveCdpState(resolvedProfile, singleton);
330
+ }
331
+
332
+ throw new Error(
333
+ `Multiple live CDP Chrome instances use ${resolvedProfile}; `
334
+ + "close the extra instances before starting WTAgent.",
335
+ );
336
+ }
337
+
338
+ export async function acquireCdpProfileLock(profileDir, {
339
+ ownerPid = process.pid,
340
+ isAlive = isProcessAlive,
341
+ } = {}) {
342
+ const resolvedProfile = path.resolve(profileDir);
343
+ const filePath = lockPath(resolvedProfile);
344
+ const token = randomUUID();
345
+
346
+ for (let attempt = 0; attempt < 3; attempt += 1) {
347
+ try {
348
+ const handle = await fs.open(filePath, "wx", 0o600);
349
+ try {
350
+ await handle.writeFile(
351
+ `${JSON.stringify({
352
+ pid: ownerPid,
353
+ token,
354
+ createdAt: new Date().toISOString(),
355
+ })}\n`,
356
+ );
357
+ } finally {
358
+ await handle.close();
359
+ }
360
+
361
+ let released = false;
362
+ return async () => {
363
+ if (released) {
364
+ return;
365
+ }
366
+ released = true;
367
+ const current = await readJson(filePath);
368
+ if (current?.token === token) {
369
+ await fs.rm(filePath, { force: true });
370
+ }
371
+ };
372
+ } catch (error) {
373
+ if (error.code !== "EEXIST") {
374
+ throw error;
375
+ }
376
+ const existing = await readInitializedLock(filePath);
377
+ if (existing && isAlive(Number(existing.pid))) {
378
+ throw new Error(
379
+ `Another WTAgent session (pid=${existing.pid}) is already using `
380
+ + `${resolvedProfile}.`,
381
+ );
382
+ }
383
+ if (!existing) {
384
+ const stat = await fs.stat(filePath).catch(() => null);
385
+ if (
386
+ stat
387
+ && Date.now() - stat.mtimeMs < LOCK_INITIALIZATION_GRACE_MS
388
+ ) {
389
+ throw new Error(
390
+ `The WTAgent profile lock at ${filePath} is still being initialized.`,
391
+ );
392
+ }
393
+ }
394
+ await fs.rm(filePath, { force: true });
395
+ }
396
+ }
397
+
398
+ throw new Error(`Could not acquire the WTAgent profile lock at ${filePath}.`);
399
+ }
@@ -78,7 +78,7 @@ export class ChatGPTWebAdapter {
78
78
  minimized: this.minimized,
79
79
  });
80
80
  this.context = this.cdpChrome.context;
81
- this.page = this.context.pages()[0] ?? await this.context.newPage();
81
+ this.page = this.cdpChrome.page;
82
82
  this.page.setDefaultTimeout(15_000);
83
83
  this.page.setDefaultNavigationTimeout(60_000);
84
84
  await this.page.goto(this.baseUrl, { waitUntil: "domcontentloaded" });
@@ -456,9 +456,20 @@ export class ChatGPTWebAdapter {
456
456
  }
457
457
 
458
458
  const stopVisible = await this.#isStopButtonVisible();
459
+ // If the reply looks like protocol XML, never accept it until BOTH the
460
+ // opening and closing tags are present. During streaming the text can
461
+ // briefly go quiet (or the stop button flip off) after "<agent_response"
462
+ // is painted but before "</agent_response>" arrives; accepting there
463
+ // hands the parser a truncated envelope. Non-protocol chatter (no
464
+ // "<agent_response") is unaffected and still completes on the stable
465
+ // window below.
466
+ const looksLikeProtocol = lastText.includes("<agent_response");
467
+ const envelopeReady = !looksLikeProtocol
468
+ || hasCompleteAgentEnvelope(lastText);
459
469
  if (
460
470
  lastText.trim()
461
471
  && stableSince > 0
472
+ && envelopeReady
462
473
  && (
463
474
  (
464
475
  !stopVisible
package/src/cli/main.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Command } from "commander";
5
- import { confirm, input } from "@inquirer/prompts";
5
+ import { confirm } from "@inquirer/prompts";
6
6
  import { ChatGPTWebAdapter } from "../browser/chatgpt-web-adapter.js";
7
7
  import { launchNativeLoginBrowser } from "../browser/native-login.js";
8
8
  import {
@@ -21,6 +21,12 @@ import { ProcessManager } from "../tools/process-manager.js";
21
21
  import { resolveLimits } from "../shared/limits.js";
22
22
  import { EXPORTERS } from "../session/session-export.js";
23
23
  import { extractAtMentions } from "./at-files.js";
24
+ import {
25
+ classifyChatInput,
26
+ promptForText,
27
+ readChatMessage,
28
+ ShellChatInput,
29
+ } from "./prompt-input.js";
24
30
  import { createRenderer } from "./render-events.js";
25
31
 
26
32
  function resolveRuntimePaths(options) {
@@ -55,10 +61,13 @@ async function runLogin(options) {
55
61
  });
56
62
 
57
63
  try {
58
- await input({
64
+ const answer = await promptForText({
59
65
  message:
60
66
  "After the signed-in ChatGPT home is visible, press Enter here to save and verify",
61
67
  });
68
+ if (answer == null) {
69
+ return;
70
+ }
62
71
  console.log("Closing native Chrome and saving the dedicated profile...");
63
72
  await new Promise((resolve) => setTimeout(resolve, 1_000));
64
73
  } finally {
@@ -242,7 +251,9 @@ class ConversationRunner {
242
251
  this.closed = true;
243
252
  this.renderer.finish();
244
253
  await this.processManager.stopAll().catch(() => {});
245
- await this.adapter.close().catch(() => {});
254
+ await this.adapter.close().catch((error) => {
255
+ console.error(`Warning: ${error.message}`);
256
+ });
246
257
  }
247
258
  }
248
259
 
@@ -252,6 +263,7 @@ async function executeSession({
252
263
  resume = false,
253
264
  instruction = null,
254
265
  files = [],
266
+ chatInput = null,
255
267
  }) {
256
268
  const paths = resolveRuntimePaths(options);
257
269
  await ensureDirectory(paths.sessionsDir);
@@ -259,7 +271,7 @@ async function executeSession({
259
271
 
260
272
  const onInterrupt = async () => {
261
273
  if (runner.interrupted) {
262
- process.exit(130);
274
+ return;
263
275
  }
264
276
  runner.interrupted = true;
265
277
  runner.renderer.stopSpinner();
@@ -270,6 +282,11 @@ async function executeSession({
270
282
  process.on("SIGINT", onInterrupt);
271
283
 
272
284
  const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
285
+ const activeChatInput = chatInput
286
+ ?? (interactive ? new ShellChatInput() : null);
287
+ if (instruction) {
288
+ activeChatInput?.remember(instruction);
289
+ }
273
290
 
274
291
  try {
275
292
  runner.renderer.hint(`Session ID: ${session.sessionId}`);
@@ -303,7 +320,7 @@ async function executeSession({
303
320
  }
304
321
  }
305
322
 
306
- const next = await promptForNextMessage(runner);
323
+ const next = await promptForNextMessage(runner, activeChatInput);
307
324
  if (next == null) {
308
325
  break;
309
326
  }
@@ -315,34 +332,37 @@ async function executeSession({
315
332
  return null;
316
333
  } finally {
317
334
  process.removeListener("SIGINT", onInterrupt);
335
+ activeChatInput?.close();
318
336
  await runner.close();
319
337
  console.log(`Session saved at: ${session.directory}`);
320
338
  }
321
339
  }
322
340
 
323
- // Reads the user's next message from the interactive prompt. Returns null when
324
- // the user wants to end the conversation (blank line, exit/quit, or EOF), or
341
+ // Reads the user's next message from the interactive prompt. Empty input simply
342
+ // re-prompts. Returns null for an explicit exit command, Ctrl+C, Ctrl+D, or EOF;
343
+ // otherwise returns
325
344
  // { text, files } where files are resolved @file attachments.
326
- async function promptForNextMessage(runner) {
345
+ async function promptForNextMessage(runner, chatInput) {
327
346
  for (;;) {
328
- let answer;
329
- try {
330
- answer = await input({
347
+ const answer = chatInput
348
+ ? await chatInput.read()
349
+ : await promptForText({
331
350
  message: "you ›",
332
351
  theme: { prefix: "" },
333
352
  });
334
- } catch {
353
+ if (answer == null) {
335
354
  // Ctrl+C / Ctrl+D inside the prompt ends the conversation cleanly.
336
355
  runner.renderer.println("");
337
356
  return null;
338
357
  }
339
- const text = String(answer ?? "").trim();
340
- if (!text) {
341
- return null;
358
+ const classified = classifyChatInput(answer);
359
+ if (classified.kind === "empty") {
360
+ continue;
342
361
  }
343
- if (["exit", "quit", ":q", "/exit", "/quit"].includes(text.toLowerCase())) {
362
+ if (classified.kind === "exit") {
344
363
  return null;
345
364
  }
365
+ const { text } = classified;
346
366
  const files = await resolveMessageAttachments(runner, text);
347
367
  return { text, files };
348
368
  }
@@ -370,6 +390,7 @@ async function runAgent(taskParts, options) {
370
390
  const projectRoot = path.resolve(options.project ?? process.cwd());
371
391
  await assertDirectory(projectRoot);
372
392
  const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
393
+ const chatInput = interactive ? new ShellChatInput() : null;
373
394
 
374
395
  // In interactive mode an initial task is optional: the user can just start
375
396
  // typing at the prompt. In one-shot mode a task is required.
@@ -378,13 +399,21 @@ async function runAgent(taskParts, options) {
378
399
  if (interactive) {
379
400
  printChatBanner(projectRoot, options.mode);
380
401
  }
381
- task = (await input({
382
- message: interactive ? "you ›" : "Task",
383
- theme: interactive ? { prefix: "" } : undefined,
384
- validate: (value) => value.trim() ? true : "Please type a message.",
385
- })).trim();
402
+ const initialMessage = interactive
403
+ ? await readChatMessage(() => chatInput.read())
404
+ : await promptForText({
405
+ message: "Task",
406
+ validate: (value) => value.trim() ? true : "Please type a message.",
407
+ });
408
+ if (initialMessage == null) {
409
+ chatInput?.close();
410
+ console.log("");
411
+ return null;
412
+ }
413
+ task = initialMessage.trim();
386
414
  } else if (interactive) {
387
415
  printChatBanner(projectRoot, options.mode);
416
+ chatInput.remember(task);
388
417
  }
389
418
 
390
419
  const paths = resolveRuntimePaths(options);
@@ -413,7 +442,7 @@ async function runAgent(taskParts, options) {
413
442
  }
414
443
  }
415
444
 
416
- return await executeSession({ session, options, files });
445
+ return await executeSession({ session, options, files, chatInput });
417
446
  }
418
447
 
419
448
  function printChatBanner(projectRoot, mode) {
@@ -421,8 +450,8 @@ function printChatBanner(projectRoot, mode) {
421
450
  const DIM = "\x1b[2m";
422
451
  const RESET = "\x1b[0m";
423
452
  console.log("");
424
- console.log(`${CYAN}WTAgent${RESET} ${DIM}· ChatGPT ${mode ?? "Pro"} · ${projectRoot}${RESET}`);
425
- console.log(`${DIM}Type a message and press Enter. Blank line, "exit", or Ctrl+C to quit.${RESET}`);
453
+ console.log(`${CYAN}WTAgent${RESET} ${DIM}· GPT Web · ${projectRoot}${RESET}`);
454
+ console.log(`${DIM}Enter sends · ↑/↓ history · "exit", Ctrl+C, or Ctrl+D quits${RESET}`);
426
455
  console.log("");
427
456
  }
428
457
 
@@ -546,7 +575,7 @@ async function runExport(sessionId, options) {
546
575
  const program = new Command()
547
576
  .name("wtagent")
548
577
  .description("Turn your web AI session into a local tool-using agent.")
549
- .version("0.1.0-alpha.0")
578
+ .version("0.1.0-alpha.1")
550
579
  .option("--home <path>", "Application data directory")
551
580
  .option("--profile-dir <path>", "Dedicated Chrome profile directory")
552
581
  .option("--chrome-path <path>", "Chrome/Chromium executable")
@@ -0,0 +1,152 @@
1
+ import { input } from "@inquirer/prompts";
2
+ import { createInterface } from "node:readline/promises";
3
+
4
+ const CLEAN_EXIT_ERRORS = new Set([
5
+ "AbortPromptError",
6
+ "ExitPromptError",
7
+ ]);
8
+ const EXIT_COMMANDS = new Set([
9
+ "exit",
10
+ "quit",
11
+ ":q",
12
+ "/exit",
13
+ "/quit",
14
+ ]);
15
+
16
+ export function classifyChatInput(value) {
17
+ const text = String(value ?? "").trim();
18
+ if (!text) {
19
+ return { kind: "empty", text: "" };
20
+ }
21
+ if (EXIT_COMMANDS.has(text.toLowerCase())) {
22
+ return { kind: "exit", text };
23
+ }
24
+ return { kind: "message", text };
25
+ }
26
+
27
+ export async function readChatMessage(read) {
28
+ for (;;) {
29
+ const answer = await read();
30
+ if (answer == null) {
31
+ return null;
32
+ }
33
+ const classified = classifyChatInput(answer);
34
+ if (classified.kind === "empty") {
35
+ continue;
36
+ }
37
+ if (classified.kind === "exit") {
38
+ return null;
39
+ }
40
+ return classified.text;
41
+ }
42
+ }
43
+
44
+ export class ShellChatInput {
45
+ constructor({
46
+ inputStream = process.stdin,
47
+ outputStream = process.stdout,
48
+ historySize = 100,
49
+ } = {}) {
50
+ this.inputStream = inputStream;
51
+ this.outputStream = outputStream;
52
+ this.historySize = historySize;
53
+ this.history = [];
54
+ }
55
+
56
+ remember(value) {
57
+ const text = String(value ?? "").trim();
58
+ if (!text) {
59
+ return;
60
+ }
61
+ this.history = [
62
+ text,
63
+ ...this.history.filter((entry) => entry !== text),
64
+ ].slice(0, this.historySize);
65
+ }
66
+
67
+ async read(prompt = " you › ") {
68
+ if (this.inputStream.readableEnded) {
69
+ return null;
70
+ }
71
+
72
+ const readline = createInterface({
73
+ input: this.inputStream,
74
+ output: this.outputStream,
75
+ terminal: true,
76
+ historySize: this.historySize,
77
+ removeHistoryDuplicates: true,
78
+ });
79
+ readline.history = [...this.history];
80
+
81
+ const controller = new AbortController();
82
+ let closed = false;
83
+ const onClose = () => {
84
+ closed = true;
85
+ controller.abort();
86
+ };
87
+ const onInterrupt = () => readline.close();
88
+ readline.once("close", onClose);
89
+ readline.on("SIGINT", onInterrupt);
90
+
91
+ try {
92
+ const answer = await readline.question(prompt, {
93
+ signal: controller.signal,
94
+ });
95
+ this.history = readline.history.slice(0, this.historySize);
96
+ return answer;
97
+ } catch (error) {
98
+ if (closed || error?.name === "AbortError") {
99
+ return null;
100
+ }
101
+ throw error;
102
+ } finally {
103
+ readline.removeListener("close", onClose);
104
+ readline.removeListener("SIGINT", onInterrupt);
105
+ readline.close();
106
+ }
107
+ }
108
+
109
+ close() {
110
+ // Each readline instance is scoped to one question and is closed in read().
111
+ // This method keeps ownership explicit for callers and future extensions.
112
+ }
113
+ }
114
+
115
+ export async function promptForText(config, {
116
+ prompt = input,
117
+ inputStream = process.stdin,
118
+ outputStream = process.stdout,
119
+ } = {}) {
120
+ if (inputStream.readableEnded) {
121
+ return null;
122
+ }
123
+
124
+ const controller = new AbortController();
125
+ let reachedEof = false;
126
+ const onEof = () => {
127
+ reachedEof = true;
128
+ controller.abort();
129
+ };
130
+
131
+ inputStream.once("end", onEof);
132
+ inputStream.once("close", onEof);
133
+
134
+ try {
135
+ return await prompt(config, {
136
+ input: inputStream,
137
+ output: outputStream,
138
+ signal: controller.signal,
139
+ });
140
+ } catch (error) {
141
+ if (
142
+ reachedEof
143
+ || CLEAN_EXIT_ERRORS.has(error?.name)
144
+ ) {
145
+ return null;
146
+ }
147
+ throw error;
148
+ } finally {
149
+ inputStream.removeListener("end", onEof);
150
+ inputStream.removeListener("close", onEof);
151
+ }
152
+ }