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 +14 -10
- package/docs/technical-design.md +6 -5
- package/package.json +1 -1
- package/src/browser/cdp-browser.js +166 -51
- package/src/browser/cdp-state.js +399 -0
- package/src/browser/chatgpt-web-adapter.js +12 -1
- package/src/cli/main.js +54 -25
- package/src/cli/prompt-input.js +152 -0
package/README.md
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# WTAgent
|
|
2
2
|
|
|
3
|
-
Turn
|
|
3
|
+
Turn GPT Web into a local CLI agent.
|
|
4
4
|
|
|
5
|
-
把
|
|
5
|
+
把 GPT 网页聊天变成一个本地 CLI Agent。
|
|
6
6
|
|
|
7
7
|
[中文](#中文) · [English](#english)
|
|
8
8
|
|
|
9
9
|
## 中文
|
|
10
10
|
|
|
11
|
-
WTAgent
|
|
11
|
+
WTAgent 把 GPT 网页聊天连接到本地工具,提供类似 Codex 的 CLI Agent 体验。当前首个适配器支持 ChatGPT Web。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
网页 GPT 负责思考,WTAgent 在本机:
|
|
14
14
|
|
|
15
15
|
- 读取和修改本地文件
|
|
16
16
|
- 调用本地 Shell,运行构建、测试和开发服务
|
|
17
17
|
|
|
18
|
-
不需要 OpenAI API Key。WTAgent 使用独立的 Chrome Profile
|
|
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 会把任务交给
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
package/docs/technical-design.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# WTAgent:
|
|
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
|
|
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
|
-
-
|
|
690
|
+
- `Ctrl+C` 或 `Ctrl+D` 退出 CLI,并关闭专用 Chrome;
|
|
691
|
+
- 若关闭未完成,保留经过验证的 CDP 状态,下一次启动复用后再次执行关闭;
|
|
691
692
|
- 输入补充指令;
|
|
692
693
|
- 打开浏览器人工接管;
|
|
693
694
|
- 恢复自动化。
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
});
|
|
133
|
+
async function findReusable() {
|
|
134
|
+
return await discoverReusable(profileDir, { fetchVersion });
|
|
135
|
+
}
|
|
129
136
|
|
|
130
137
|
try {
|
|
131
|
-
await
|
|
132
|
-
|
|
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
|
-
|
|
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,
|
|
223
|
+
return await setWindowState(context, page, "minimized");
|
|
154
224
|
},
|
|
155
225
|
async restore() {
|
|
156
|
-
return await setWindowState(context,
|
|
226
|
+
return await setWindowState(context, page, "normal");
|
|
157
227
|
},
|
|
158
228
|
async close() {
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
324
|
-
//
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
347
|
+
const answer = chatInput
|
|
348
|
+
? await chatInput.read()
|
|
349
|
+
: await promptForText({
|
|
331
350
|
message: "you ›",
|
|
332
351
|
theme: { prefix: "" },
|
|
333
352
|
});
|
|
334
|
-
|
|
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
|
|
340
|
-
if (
|
|
341
|
-
|
|
358
|
+
const classified = classifyChatInput(answer);
|
|
359
|
+
if (classified.kind === "empty") {
|
|
360
|
+
continue;
|
|
342
361
|
}
|
|
343
|
-
if (
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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}·
|
|
425
|
-
console.log(`${DIM}
|
|
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.
|
|
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
|
+
}
|