wtagent 0.1.0-alpha.3 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,14 +35,14 @@ WTAgent 把 GPT 网页聊天连接到本地工具,提供类似 Codex 的 CLI A
35
35
  需要 Node.js 20.17+ 和 Chrome/Chromium。
36
36
 
37
37
  ```bash
38
- npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
38
+ npm install -g wtagent --registry=https://registry.npmjs.org/
39
39
  wtagent login
40
40
  ```
41
41
 
42
42
  Windows PowerShell:
43
43
 
44
44
  ```powershell
45
- npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
45
+ npm install -g wtagent --registry=https://registry.npmjs.org/
46
46
  wtagent doctor
47
47
  wtagent login
48
48
  ```
@@ -54,6 +54,8 @@ cd my-project
54
54
  wtagent
55
55
  ```
56
56
 
57
+ 启动后先选择 ChatGPT 模式:`Pro` 会为新对话选择 Pro,`Current` 会保留网页当前设置且不执行模型切换。非交互调用默认使用 `Current`;需要 Pro 时可显式传入 `--mode Pro`。
58
+
57
59
  也可以直接附带任务:
58
60
 
59
61
  ```bash
@@ -100,14 +102,14 @@ Boundaries:
100
102
  Requires Node.js 20.17+ and Chrome/Chromium.
101
103
 
102
104
  ```bash
103
- npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
105
+ npm install -g wtagent --registry=https://registry.npmjs.org/
104
106
  wtagent login
105
107
  ```
106
108
 
107
109
  Windows PowerShell:
108
110
 
109
111
  ```powershell
110
- npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
112
+ npm install -g wtagent --registry=https://registry.npmjs.org/
111
113
  wtagent doctor
112
114
  wtagent login
113
115
  ```
@@ -119,6 +121,8 @@ cd my-project
119
121
  wtagent
120
122
  ```
121
123
 
124
+ At startup, choose a ChatGPT mode: `Pro` selects Pro for the new conversation, while `Current` keeps the current web setting without changing it. Non-interactive runs default to `Current`; pass `--mode Pro` to opt in explicitly.
125
+
122
126
  Or provide the first task directly:
123
127
 
124
128
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtagent",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Turn your own web AI session into a local tool-using agent.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5,6 +5,8 @@ import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
5
5
  import { ensureDirectory } from "../platform/paths.js";
6
6
  import { BrowserAdapterError } from "../shared/errors.js";
7
7
  import {
8
+ chooseModeOption,
9
+ labelMatchesToken,
8
10
  runModeSelection,
9
11
  slugMatchesToken,
10
12
  normalizeToken,
@@ -236,6 +238,15 @@ export class ChatGPTWebAdapter {
236
238
  const page = this.page;
237
239
  return {
238
240
  alreadyOnMode: async (requested) => {
241
+ const token = normalizeToken(requested);
242
+ const switcher = await this.#findModelSwitcher();
243
+ const switcherLabel = switcher
244
+ ? await switcher.innerText().catch(() => "")
245
+ : "";
246
+ if (labelMatchesToken(switcherLabel, token)) {
247
+ return true;
248
+ }
249
+
239
250
  const messages = this.#assistantMessages();
240
251
  if (await messages.count() === 0) {
241
252
  return false;
@@ -243,17 +254,24 @@ export class ChatGPTWebAdapter {
243
254
  const slug = await messages.last()
244
255
  .getAttribute("data-message-model-slug")
245
256
  .catch(() => null);
246
- return Boolean(slug) && slugMatchesToken(slug, normalizeToken(requested));
257
+ return Boolean(slug) && slugMatchesToken(slug, token);
247
258
  },
248
259
  hasSwitcher: async () => Boolean(await this.#findModelSwitcher()),
249
- openMenu: async () => {
260
+ openMenu: async (requested) => {
250
261
  const switcher = await this.#findModelSwitcher();
251
262
  if (switcher) {
252
- await switcher.click().catch(() => null);
263
+ const opened = await switcher.click({ timeout: 5_000 })
264
+ .then(() => true)
265
+ .catch(() => false);
266
+ if (!opened) {
267
+ await switcher.click({ force: true, timeout: 5_000 })
268
+ .catch(() => null);
269
+ }
253
270
  // Wait for the Radix menu portal to attach before reading options.
254
- await page.locator('[role="menu"] [role="menuitem"], [role="menu"] [role="menuitemradio"]')
271
+ await page.locator('[role="menu"]:visible [role="menuitem"]:visible, [role="menu"]:visible [role="menuitemradio"]:visible')
255
272
  .first().waitFor({ state: "visible", timeout: 5_000 })
256
273
  .catch(() => null);
274
+ await this.#revealNestedModeOption(requested);
257
275
  }
258
276
  },
259
277
  readOptions: async () => this.#readModeOptions(),
@@ -273,6 +291,32 @@ export class ChatGPTWebAdapter {
273
291
  .catch(() => false);
274
292
  return ok;
275
293
  },
294
+ waitSelected: async (requested) => {
295
+ const token = normalizeToken(requested);
296
+ const deadline = Date.now() + 5_000;
297
+ while (Date.now() < deadline) {
298
+ const switcher = await this.#findModelSwitcher(500);
299
+ const label = switcher
300
+ ? await switcher.innerText().catch(() => "")
301
+ : "";
302
+ if (labelMatchesToken(label, token)) {
303
+ return true;
304
+ }
305
+
306
+ const selectedOption = (await this.#readModeOptions()).find(
307
+ (option) => option.selected
308
+ && (
309
+ slugMatchesToken(option.slug, token)
310
+ || labelMatchesToken(option.label, token)
311
+ ),
312
+ );
313
+ if (selectedOption) {
314
+ return true;
315
+ }
316
+ await page.waitForTimeout(100);
317
+ }
318
+ return false;
319
+ },
276
320
  closeMenu: async () => {
277
321
  await page.keyboard.press("Escape").catch(() => null);
278
322
  await page.waitForTimeout(150);
@@ -283,8 +327,36 @@ export class ChatGPTWebAdapter {
283
327
 
284
328
  #modeOptionLocators() {
285
329
  return this.page.locator(
286
- '[role="menu"] [role="menuitemradio"], [role="menu"] [role="menuitem"]',
330
+ '[role="menu"]:visible [role="menuitemradio"]:visible, '
331
+ + '[role="menu"]:visible [role="menuitem"]:visible',
332
+ );
333
+ }
334
+
335
+ async #revealNestedModeOption(requested) {
336
+ const present = (options) => {
337
+ const choice = chooseModeOption(options, requested);
338
+ return choice.status !== "unavailable";
339
+ };
340
+
341
+ let options = await this.#readModeOptions();
342
+ if (present(options)) {
343
+ return;
344
+ }
345
+
346
+ const submenuTriggers = this.page.locator(
347
+ '[role="menu"]:visible [role="menuitem"][aria-haspopup="menu"]:visible',
287
348
  );
349
+ const count = await submenuTriggers.count().catch(() => 0);
350
+ for (let index = 0; index < count; index += 1) {
351
+ const trigger = submenuTriggers.nth(index);
352
+ await trigger.dispatchEvent("pointermove", { pointerType: "mouse" })
353
+ .catch(() => null);
354
+ await this.page.waitForTimeout(250);
355
+ options = await this.#readModeOptions();
356
+ if (present(options)) {
357
+ return;
358
+ }
359
+ }
288
360
  }
289
361
 
290
362
  // Enumerates the open menu's options into { index, slug, label, disabled }.
@@ -296,13 +368,14 @@ export class ChatGPTWebAdapter {
296
368
  const options = [];
297
369
  for (let index = 0; index < count; index += 1) {
298
370
  const item = locator.nth(index);
299
- const [testId, dataValue, dataTestValue, id, ariaDisabled, dataDisabled, dataState, label] =
371
+ const [testId, dataValue, dataTestValue, id, ariaDisabled, ariaChecked, dataDisabled, dataState, label] =
300
372
  await Promise.all([
301
373
  item.getAttribute("data-testid").catch(() => null),
302
374
  item.getAttribute("data-value").catch(() => null),
303
375
  item.getAttribute("data-test-value").catch(() => null),
304
376
  item.getAttribute("id").catch(() => null),
305
377
  item.getAttribute("aria-disabled").catch(() => null),
378
+ item.getAttribute("aria-checked").catch(() => null),
306
379
  item.getAttribute("data-disabled").catch(() => null),
307
380
  item.getAttribute("data-state").catch(() => null),
308
381
  item.innerText().catch(() => ""),
@@ -313,11 +386,13 @@ export class ChatGPTWebAdapter {
313
386
  || dataDisabled === ""
314
387
  || dataState === "disabled"
315
388
  || !await item.isEnabled().catch(() => true);
389
+ const selected = ariaChecked === "true" || dataState === "checked";
316
390
  options.push({
317
391
  index,
318
392
  slug,
319
393
  label: (label ?? "").trim(),
320
394
  disabled,
395
+ selected,
321
396
  });
322
397
  }
323
398
  return options;
@@ -38,16 +38,17 @@ export function slugMatchesToken(slug, token) {
38
38
  return false;
39
39
  }
40
40
 
41
- function labelMatchesToken(label, token) {
41
+ export function labelMatchesToken(label, token) {
42
42
  return String(label ?? "")
43
43
  .split(/\r?\n/)
44
44
  .some((line) => normalizeToken(line) === token);
45
45
  }
46
46
 
47
47
  // Given the enumerated menu options and the requested mode, decide what to do.
48
- // options: [{ index, slug, label, disabled }] in DOM order.
48
+ // options: [{ index, slug, label, disabled, selected }] in DOM order.
49
49
  // Returns { status, targetIndex, selectedLabel, reason }.
50
50
  // status "select" -> requested mode is available; click it.
51
+ // status "already" -> requested mode is already selected.
51
52
  // status "fallback" -> requested mode is limited/disabled; click
52
53
  // the option immediately before it.
53
54
  // status "unavailable" -> requested mode is not present in the menu.
@@ -74,6 +75,14 @@ export function chooseModeOption(options, requested) {
74
75
  }
75
76
 
76
77
  const pro = options[proIndex];
78
+ if (pro.selected) {
79
+ return {
80
+ status: "already",
81
+ targetIndex: null,
82
+ selectedLabel: pro.label || requested,
83
+ reason: `Already using ${requested}.`,
84
+ };
85
+ }
77
86
  if (!pro.disabled) {
78
87
  return {
79
88
  status: "select",
@@ -112,9 +121,10 @@ export function chooseModeOption(options, requested) {
112
121
  // port: {
113
122
  // alreadyOnMode(requested) -> bool,
114
123
  // hasSwitcher() -> bool,
115
- // openMenu() -> void,
116
- // readOptions() -> [{index, slug, label, disabled}],
124
+ // openMenu(requested) -> void,
125
+ // readOptions() -> [{index, slug, label, disabled, selected}],
117
126
  // clickOption(index) -> bool (click landed),
127
+ // waitSelected(requested) -> bool (picker reflects the requested mode),
118
128
  // waitClosed() -> bool (menu closed after click),
119
129
  // closeMenu() -> void,
120
130
  // writeDiagnostics(label) -> void,
@@ -146,7 +156,7 @@ export async function runModeSelection(port, requested, { maxAttempts = 2 } = {}
146
156
 
147
157
  let lastReason = "";
148
158
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
149
- await port.openMenu();
159
+ await port.openMenu(requested);
150
160
  const options = await port.readOptions();
151
161
 
152
162
  // The Radix menu populates asynchronously; an empty read is a race, retry.
@@ -158,13 +168,25 @@ export async function runModeSelection(port, requested, { maxAttempts = 2 } = {}
158
168
 
159
169
  const choice = chooseModeOption(options, requested);
160
170
 
171
+ if (choice.status === "already") {
172
+ await port.closeMenu();
173
+ return { ...choice, requested, attempts: attempt };
174
+ }
175
+
161
176
  if (choice.status === "select" || choice.status === "fallback") {
162
177
  const clicked = await port.clickOption(choice.targetIndex);
163
- const closed = clicked && await port.waitClosed();
164
- if (closed) {
178
+ const expectedMode = choice.status === "fallback"
179
+ ? choice.selectedLabel
180
+ : requested;
181
+ const selected = clicked && (
182
+ typeof port.waitSelected === "function"
183
+ ? await port.waitSelected(expectedMode)
184
+ : await port.waitClosed()
185
+ );
186
+ if (selected) {
165
187
  return { ...choice, requested, attempts: attempt };
166
188
  }
167
- lastReason = "The option click did not register.";
189
+ lastReason = "The option click did not change the selected mode.";
168
190
  await port.closeMenu();
169
191
  continue;
170
192
  }
package/src/cli/main.js CHANGED
@@ -28,10 +28,16 @@ import { extractAtMentions } from "./at-files.js";
28
28
  import {
29
29
  classifyChatInput,
30
30
  promptForText,
31
+ promptForSelect,
31
32
  readChatMessage,
32
33
  ShellChatInput,
33
34
  } from "./prompt-input.js";
34
35
  import { createRenderer } from "./render-events.js";
36
+ import {
37
+ CHATGPT_MODE_CHOICES,
38
+ modeFromPromptChoice,
39
+ normalizeConfiguredMode,
40
+ } from "./mode-choice.js";
35
41
 
36
42
  function resolveRuntimePaths(options) {
37
43
  const appDataDir = path.resolve(options.home ?? getAppDataDir());
@@ -401,13 +407,34 @@ async function runAgent(taskParts, options) {
401
407
  const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
402
408
  const chatInput = interactive ? new ShellChatInput() : null;
403
409
 
410
+ if (interactive) {
411
+ printChatBanner(projectRoot);
412
+ }
413
+
414
+ let requestedMode;
415
+ if (options.mode != null) {
416
+ requestedMode = normalizeConfiguredMode(options.mode);
417
+ } else if (interactive) {
418
+ const modeChoice = await promptForSelect({
419
+ message: "ChatGPT mode",
420
+ choices: CHATGPT_MODE_CHOICES,
421
+ });
422
+ if (modeChoice == null) {
423
+ chatInput?.close();
424
+ console.log("");
425
+ return null;
426
+ }
427
+ requestedMode = modeFromPromptChoice(modeChoice);
428
+ } else {
429
+ // Non-interactive callers cannot answer a picker. Preserve the current web
430
+ // setting unless they explicitly opt into `--mode Pro`.
431
+ requestedMode = null;
432
+ }
433
+
404
434
  // In interactive mode an initial task is optional: the user can just start
405
435
  // typing at the prompt. In one-shot mode a task is required.
406
436
  let task = taskParts.join(" ").trim();
407
437
  if (!task) {
408
- if (interactive) {
409
- printChatBanner(projectRoot, options.mode);
410
- }
411
438
  const initialMessage = interactive
412
439
  ? await readChatMessage(() => chatInput.read())
413
440
  : await promptForText({
@@ -421,7 +448,6 @@ async function runAgent(taskParts, options) {
421
448
  }
422
449
  task = initialMessage.trim();
423
450
  } else if (interactive) {
424
- printChatBanner(projectRoot, options.mode);
425
451
  chatInput.remember(task);
426
452
  }
427
453
 
@@ -431,7 +457,7 @@ async function runAgent(taskParts, options) {
431
457
  sessionsDir: paths.sessionsDir,
432
458
  task,
433
459
  projectRoot,
434
- mode: options.mode,
460
+ mode: requestedMode,
435
461
  });
436
462
 
437
463
  // Resolve @file attachments in the opening task, if any. The task itself is
@@ -454,7 +480,7 @@ async function runAgent(taskParts, options) {
454
480
  return await executeSession({ session, options, files, chatInput });
455
481
  }
456
482
 
457
- function printChatBanner(projectRoot, mode) {
483
+ function printChatBanner(projectRoot) {
458
484
  const CYAN = "\x1b[36m";
459
485
  const DIM = "\x1b[2m";
460
486
  const RESET = "\x1b[0m";
@@ -585,12 +611,15 @@ async function runExport(sessionId, options) {
585
611
  const program = new Command()
586
612
  .name("wtagent")
587
613
  .description("Turn your web AI session into a local tool-using agent.")
588
- .version("0.1.0-alpha.3")
614
+ .version("0.1.0-alpha.4")
589
615
  .option("--home <path>", "Application data directory")
590
616
  .option("--profile-dir <path>", "Dedicated Chrome profile directory")
591
617
  .option("--chrome-path <path>", "Chrome/Chromium executable")
592
618
  .option("-C, --project <path>", "Project directory", process.cwd())
593
- .option("--mode <name>", "ChatGPT mode to select", "Pro")
619
+ .option(
620
+ "--mode <name>",
621
+ 'ChatGPT mode: "Pro" selects Pro; "Current" keeps the web setting',
622
+ )
594
623
  .option(
595
624
  "--once",
596
625
  "Run a single request and exit instead of a conversation",
@@ -0,0 +1,25 @@
1
+ export const CHATGPT_MODE_CHOICES = Object.freeze([
2
+ Object.freeze({
3
+ name: "Pro — select Pro for this conversation",
4
+ value: "pro",
5
+ }),
6
+ Object.freeze({
7
+ name: "Current — keep the current ChatGPT setting",
8
+ value: "current",
9
+ }),
10
+ ]);
11
+
12
+ export function normalizeConfiguredMode(value) {
13
+ const configured = String(value ?? "").trim();
14
+ if (/^current$/i.test(configured)) {
15
+ return null;
16
+ }
17
+ if (/^pro$/i.test(configured)) {
18
+ return "Pro";
19
+ }
20
+ throw new Error('ChatGPT mode must be either "Pro" or "Current".');
21
+ }
22
+
23
+ export function modeFromPromptChoice(choice) {
24
+ return choice === "pro" ? "Pro" : null;
25
+ }
@@ -1,4 +1,4 @@
1
- import { input } from "@inquirer/prompts";
1
+ import { input, select } from "@inquirer/prompts";
2
2
  import { createInterface } from "node:readline/promises";
3
3
 
4
4
  const CLEAN_EXIT_ERRORS = new Set([
@@ -112,11 +112,11 @@ export class ShellChatInput {
112
112
  }
113
113
  }
114
114
 
115
- export async function promptForText(config, {
116
- prompt = input,
115
+ async function promptWithCleanExit(config, {
116
+ prompt,
117
117
  inputStream = process.stdin,
118
118
  outputStream = process.stdout,
119
- } = {}) {
119
+ }) {
120
120
  if (inputStream.readableEnded) {
121
121
  return null;
122
122
  }
@@ -150,3 +150,17 @@ export async function promptForText(config, {
150
150
  inputStream.removeListener("close", onEof);
151
151
  }
152
152
  }
153
+
154
+ export async function promptForText(config, options = {}) {
155
+ return await promptWithCleanExit(config, {
156
+ ...options,
157
+ prompt: options.prompt ?? input,
158
+ });
159
+ }
160
+
161
+ export async function promptForSelect(config, options = {}) {
162
+ return await promptWithCleanExit(config, {
163
+ ...options,
164
+ prompt: options.prompt ?? select,
165
+ });
166
+ }
@@ -239,7 +239,7 @@ export class AgentRuntime {
239
239
  let activeMode = resume
240
240
  ? (this.session.state.activeMode ?? null)
241
241
  : null;
242
- if (!resume) {
242
+ if (!resume && mode) {
243
243
  const modeResult = await this.adapter.selectMode(mode);
244
244
  if (modeResult) {
245
245
  await this.emit("conversation.mode_selected", {
@@ -339,7 +339,7 @@ export class AgentRuntime {
339
339
  let protocolErrors = 0;
340
340
  const baseTurn = resume ? Number(this.session.state.turn || 0) : 0;
341
341
 
342
- for (let step = 1; step <= this.limits.maxTurns; step += 1) {
342
+ for (let step = 1; ; step += 1) {
343
343
  const turnNumber = baseTurn + step;
344
344
  await this.session.update({ turn: turnNumber, phase: "waiting_model" });
345
345
  const raw = await this.adapter.waitForTurnComplete({
@@ -699,12 +699,5 @@ export class AgentRuntime {
699
699
  });
700
700
  }
701
701
 
702
- const message = `Maximum turn limit reached (${this.limits.maxTurns}).`;
703
- await this.session.update({
704
- phase: "interrupted",
705
- lastError: message,
706
- });
707
- await this.emit("run.interrupted", { message });
708
- throw new Error(message);
709
702
  }
710
703
  }
@@ -1,5 +1,4 @@
1
1
  export const DEFAULT_LIMITS = Object.freeze({
2
- maxTurns: 36,
3
2
  maxProtocolErrors: 3,
4
3
  modelTurnTimeoutMs: 10 * 60_000,
5
4
  modelStableWindowMs: 1_500,