wtagent 0.1.0 → 0.2.0

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/src/cli/main.js CHANGED
@@ -3,16 +3,25 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Command } from "commander";
5
5
  import { confirm, select } from "@inquirer/prompts";
6
- import { ChatGPTWebAdapter } from "../browser/chatgpt-web-adapter.js";
7
6
  import { launchNativeLoginBrowser } from "../browser/native-login.js";
7
+ import {
8
+ createWebAdapter,
9
+ DEFAULT_PROVIDER,
10
+ getProvider,
11
+ getProviderProfileDir,
12
+ isProviderProfileBasename,
13
+ listActiveProviderIds,
14
+ resolveCliProviderSelection,
15
+ resolveProvider,
16
+ } from "../browser/provider-registry.js";
8
17
  import {
9
18
  ensureDirectory,
10
19
  getAppDataDir,
11
- getChromeProfileDir,
12
20
  getSessionsDir,
13
21
  getTasksDir,
14
22
  } from "../platform/paths.js";
15
23
  import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
24
+ import { isProcessAlive } from "../browser/cdp-state.js";
16
25
  import {
17
26
  assertNativeRuntimeSupported,
18
27
  collectDoctorReport,
@@ -25,9 +34,11 @@ import { createDefaultToolRegistry } from "../tools/default-tools.js";
25
34
  import { ProcessManager } from "../tools/process-manager.js";
26
35
  import { resolveLimits } from "../shared/limits.js";
27
36
  import { EXPORTERS } from "../session/session-export.js";
37
+ import { getPackageVersion } from "../shared/package-info.js";
28
38
  import { extractAtMentions } from "./at-files.js";
29
39
  import {
30
40
  classifyChatInput,
41
+ promptForConfirm,
31
42
  promptForText,
32
43
  promptForSelect,
33
44
  readChatMessage,
@@ -39,13 +50,24 @@ import {
39
50
  modeFromPromptChoice,
40
51
  normalizeConfiguredMode,
41
52
  } from "./mode-choice.js";
53
+ import { runSelfUpdate } from "./self-update.js";
54
+ import { runStartupChecks } from "./startup-notices.js";
55
+
56
+ // Resolves the app data dir, the provider's dedicated Chrome profile dir, and
57
+ // the sessions dirs. `providerId` selects which profile directory is used
58
+ // (each provider logs in independently); an explicit `--profile-dir` still
59
+ // overrides it. Defaults to the ChatGPT profile so callers that predate
60
+ // multi-provider support are unaffected.
61
+ function withResolvedProviderSelection(options) {
62
+ return { ...options, ...resolveCliProviderSelection(options) };
63
+ }
42
64
 
43
- function resolveRuntimePaths(options) {
65
+ function resolveRuntimePaths(options, providerId = DEFAULT_PROVIDER) {
44
66
  const appDataDir = path.resolve(options.home ?? getAppDataDir());
45
67
  return {
46
68
  appDataDir,
47
69
  profileDir: path.resolve(
48
- options.profileDir ?? getChromeProfileDir(appDataDir),
70
+ options.profileDir ?? getProviderProfileDir(appDataDir, providerId),
49
71
  ),
50
72
  sessionsDir: getSessionsDir(appDataDir),
51
73
  legacyTasksDir: getTasksDir(appDataDir),
@@ -61,21 +83,25 @@ async function assertDirectory(directory) {
61
83
 
62
84
  async function runLogin(options) {
63
85
  assertNativeRuntimeSupported();
64
- const { profileDir } = resolveRuntimePaths(options);
86
+ options = withResolvedProviderSelection(options);
87
+ const provider = resolveProvider(options.model ?? DEFAULT_PROVIDER);
88
+ const { profileDir } = resolveRuntimePaths(options, provider.id);
89
+ const { label, baseUrl } = provider;
65
90
  for (;;) {
66
91
  console.log(`Opening native Chrome profile: ${profileDir}`);
67
92
  console.log(
68
- "This window has no CDP flags. Finish until ChatGPT shows your signed-in home/chat history and no Log in button.",
93
+ `This window has no CDP flags. Finish until ${label} shows your signed-in home/chat history and no Log in button.`,
69
94
  );
70
95
  const browser = await launchNativeLoginBrowser({
71
96
  profileDir,
72
97
  chromePath: options.chromePath,
98
+ url: baseUrl,
73
99
  });
74
100
 
75
101
  try {
76
102
  const answer = await promptForText({
77
103
  message:
78
- "After the signed-in ChatGPT home is visible, press Enter here to save and verify",
104
+ `After the signed-in ${label} home is visible, press Enter here to save and verify`,
79
105
  });
80
106
  if (answer == null) {
81
107
  return;
@@ -86,7 +112,8 @@ async function runLogin(options) {
86
112
  await browser.close();
87
113
  }
88
114
 
89
- const verifier = new ChatGPTWebAdapter({
115
+ const verifier = createWebAdapter({
116
+ provider,
90
117
  profileDir,
91
118
  chromePath: options.chromePath,
92
119
  });
@@ -102,7 +129,7 @@ async function runLogin(options) {
102
129
  }
103
130
  }
104
131
  if (authenticated) {
105
- console.log("ChatGPT login verified through a fresh CDP connection.");
132
+ console.log(`${label} login verified through a fresh CDP connection.`);
106
133
  return;
107
134
  }
108
135
  } finally {
@@ -110,17 +137,22 @@ async function runLogin(options) {
110
137
  }
111
138
 
112
139
  console.log(
113
- "ChatGPT is still in guest mode. Reopening native Chrome; complete the final ChatGPT sign-in/continue step.",
140
+ `${label} is still in guest mode. Reopening native Chrome; complete the final ${label} sign-in/continue step.`,
114
141
  );
115
142
  }
116
143
  }
117
144
 
118
145
  // Resets local login by deleting the dedicated Chrome profile. Login state for
119
- // this app lives entirely in that profile (chatgpt.com cookies + localStorage),
120
- // so removing it returns wtagent to a clean guest state — useful for testing the
146
+ // this app lives entirely in that profile (provider cookies + localStorage), so
147
+ // removing it returns wtagent to a clean guest state — useful for testing the
121
148
  // full login → run flow. It never touches the real account server-side.
122
149
  async function runLogout(options) {
123
- const { profileDir } = resolveRuntimePaths(options);
150
+ // `logout` targets one provider's profile; unknown ids are rejected but a
151
+ // "planned" provider is still allowed (its profile may exist from a prior
152
+ // login attempt), so use getProvider rather than resolveProvider.
153
+ options = withResolvedProviderSelection(options);
154
+ const provider = getProvider(options.model ?? DEFAULT_PROVIDER);
155
+ const { profileDir } = resolveRuntimePaths(options, provider.id);
124
156
  const exists = await fs.stat(profileDir)
125
157
  .then((stat) => stat.isDirectory())
126
158
  .catch(() => false);
@@ -129,13 +161,13 @@ async function runLogout(options) {
129
161
  return;
130
162
  }
131
163
 
132
- // Guard: only ever delete something that is actually the dedicated profile.
164
+ // Guard: only ever delete something that is actually a dedicated profile.
133
165
  // A profile Chrome has used contains a "Default" profile directory; otherwise
134
- // require the conventional "chrome-profile" basename before removing.
166
+ // require a known provider profile basename before removing.
135
167
  const looksLikeProfile = await fs.stat(path.join(profileDir, "Default"))
136
168
  .then((stat) => stat.isDirectory())
137
169
  .catch(() => false);
138
- if (!looksLikeProfile && path.basename(profileDir) !== "chrome-profile") {
170
+ if (!looksLikeProfile && !isProviderProfileBasename(path.basename(profileDir))) {
139
171
  throw new Error(
140
172
  `Refusing to delete ${profileDir}: it does not look like a wtagent Chrome profile.`,
141
173
  );
@@ -144,7 +176,7 @@ async function runLogout(options) {
144
176
  if (!options.yes) {
145
177
  const confirmed = await confirm({
146
178
  message:
147
- `This deletes the local ChatGPT session (Chrome profile at ${profileDir}) `
179
+ `This deletes the local ${provider.label} session (Chrome profile at ${profileDir}) `
148
180
  + "and requires a new login. Continue?",
149
181
  default: false,
150
182
  });
@@ -156,7 +188,11 @@ async function runLogout(options) {
156
188
 
157
189
  await fs.rm(profileDir, { recursive: true, force: true });
158
190
  console.log(`Logged out. Removed ${profileDir}.`);
159
- console.log("Run `wtagent login` to sign in again.");
191
+ console.log(
192
+ provider.id === DEFAULT_PROVIDER
193
+ ? "Run `wtagent login` to sign in again."
194
+ : `Run \`wtagent login --model ${provider.id}\` to sign in again.`,
195
+ );
160
196
  }
161
197
 
162
198
  async function runDoctor(options) {
@@ -184,24 +220,29 @@ class ConversationRunner {
184
220
  constructor({ session, options, interactive = false }) {
185
221
  this.session = session;
186
222
  this.options = options;
187
- this.paths = resolveRuntimePaths(options);
223
+ // A conversation belongs to exactly one provider (recorded at creation).
224
+ // The profile dir and adapter follow from it, so resumes reuse the right
225
+ // login even when the CLI is invoked without --model.
226
+ const provider = resolveProvider(session.state.provider ?? DEFAULT_PROVIDER);
227
+ this.paths = resolveRuntimePaths(options, provider.id);
188
228
  this.limits = resolveLimits({
189
229
  modelTurnTimeoutMs: options.modelTurnTimeoutMs,
190
230
  });
191
231
  this.processManager = new ProcessManager();
192
- this.renderer = createRenderer();
193
- // "Always allow" decisions from the approval prompt persist here across
194
- // turns, resumes, and separate sessions.
232
+ this.renderer = createRenderer({ providerLabel: provider.label });
233
+ // "Always allow" decisions are scoped to this saved session. They persist
234
+ // across turns and `resume`, but never carry over into a different session.
195
235
  this.approvalStore = new ApprovalStore({
196
- filePath: path.join(this.paths.appDataDir, "approvals.json"),
236
+ filePath: path.join(this.session.directory, "approvals.json"),
197
237
  });
198
- this.adapter = new ChatGPTWebAdapter({
238
+ this.adapter = createWebAdapter({
239
+ provider,
199
240
  profileDir: this.paths.profileDir,
200
241
  chromePath: options.chromePath,
201
242
  debug: options.debug,
202
243
  // Minimize by default; `--no-minimize` sets options.minimize === false.
203
244
  minimized: options.minimize !== false,
204
- // ESC cancels the in-flight turn while ChatGPT is processing. Only in
245
+ // ESC cancels the in-flight turn while the model is processing. Only in
205
246
  // interactive TTY sessions where stdin is available to listen on.
206
247
  cancelOnEsc: interactive,
207
248
  });
@@ -242,14 +283,15 @@ class ConversationRunner {
242
283
  this.approvalStore.setAlwaysAllowedTool(toolCall.name);
243
284
  await this.approvalStore.save();
244
285
  console.log(
245
- `Saved: ${toolCall.name} will always be allowed `
286
+ `Saved for this session: ${toolCall.name} will always be allowed `
246
287
  + `(${this.approvalStore.filePath}).`,
247
288
  );
248
289
  } else if (choice === "always-all") {
249
290
  this.approvalStore.setAlwaysAllowAll();
250
291
  await this.approvalStore.save();
251
292
  console.log(
252
- `Saved: every tool will always be allowed (${this.approvalStore.filePath}).`,
293
+ `Saved for this session: every tool will always be allowed `
294
+ + `(${this.approvalStore.filePath}).`,
253
295
  );
254
296
  }
255
297
  return true;
@@ -340,6 +382,15 @@ class ConversationRunner {
340
382
  this.renderer.finish();
341
383
  await this.processManager.stopAll().catch(() => {});
342
384
  if (keepBrowser) {
385
+ // Only offer to keep Chrome when this run actually launched one. A run
386
+ // that failed before launch (e.g. the provider profile lock was held by
387
+ // another live session) has no window to inspect.
388
+ if (!this.adapter.cdpChrome) {
389
+ return;
390
+ }
391
+ await this.adapter.detach().catch((error) => {
392
+ console.error(`Warning: ${error.message}`);
393
+ });
343
394
  console.log(
344
395
  "The run failed; Chrome was left open for debugging. "
345
396
  + "Inspect the page and close it manually, or just run "
@@ -353,6 +404,56 @@ class ConversationRunner {
353
404
  }
354
405
  }
355
406
 
407
+ async function waitForProcessToExit(pid, timeoutMs = 60_000) {
408
+ const deadline = Date.now() + timeoutMs;
409
+ while (Date.now() < deadline) {
410
+ if (!isProcessAlive(pid)) {
411
+ return true;
412
+ }
413
+ await new Promise((resolve) => setTimeout(resolve, 500));
414
+ }
415
+ return !isProcessAlive(pid);
416
+ }
417
+
418
+ // A live session holds the provider's Chrome profile lock. In an interactive
419
+ // session, ask the user how to proceed instead of failing outright: kill the
420
+ // other session, or have the user close its Chrome window so that session
421
+ // fails and releases the profile. Returns true when the turn should be retried.
422
+ async function promptToResolveProfileLock(error) {
423
+ const pid = Number(error.details?.pid);
424
+ console.log(`\n${"\x1b[33m"}${error.message}${"\x1b[0m"}`);
425
+ const choice = await promptForSelect({
426
+ message: "How should this be handled?",
427
+ choices: [
428
+ { name: "Kill that session and continue", value: "kill" },
429
+ { name: "I closed its Chrome window; retry", value: "retry" },
430
+ { name: "Quit", value: "quit" },
431
+ ],
432
+ });
433
+ if (choice == null || choice === "quit") {
434
+ return false;
435
+ }
436
+ if (choice === "kill" && Number.isSafeInteger(pid) && pid > 0) {
437
+ console.log(`Stopping pid=${pid}...`);
438
+ try {
439
+ process.kill(pid, "SIGTERM");
440
+ } catch {
441
+ // Already gone.
442
+ }
443
+ if (!await waitForProcessToExit(pid, 5_000)) {
444
+ try {
445
+ process.kill(pid, "SIGKILL");
446
+ } catch {
447
+ // Already gone.
448
+ }
449
+ }
450
+ }
451
+ // Either the holder was killed or the user closed its Chrome window: wait
452
+ // for the other session to release the profile, then retry the turn.
453
+ await waitForProcessToExit(pid, 60_000);
454
+ return true;
455
+ }
456
+
356
457
  async function executeSession({
357
458
  session,
358
459
  options,
@@ -367,14 +468,19 @@ async function executeSession({
367
468
  const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
368
469
  const runner = new ConversationRunner({ session, options, interactive });
369
470
 
370
- const onInterrupt = async () => {
471
+ const onInterrupt = () => {
371
472
  if (runner.interrupted) {
372
- return;
473
+ console.log("\nForce quitting.");
474
+ // process.exit skips the finally block, so repeat the session/resume
475
+ // hint here — the user should always know how to continue.
476
+ console.log(`Session saved at: ${session.directory}`);
477
+ printResumeHint(session.sessionId);
478
+ process.exit(130);
373
479
  }
374
480
  runner.interrupted = true;
481
+ runner.adapter.escCancelRequested = true;
375
482
  runner.renderer.stopSpinner();
376
- console.log("\nStopping managed processes and Chrome…");
377
- await runner.close();
483
+ console.log("\nCtrl+C — cancelling. Press again to force quit.");
378
484
  process.exitCode = 130;
379
485
  };
380
486
  process.on("SIGINT", onInterrupt);
@@ -394,13 +500,27 @@ async function executeSession({
394
500
  let turnInPlaceRecovery = false;
395
501
 
396
502
  for (;;) {
397
- const result = await runner.runTurn({
398
- resume: turnResume,
399
- instruction: turnInstruction,
400
- files: turnFiles,
401
- inPlaceRecovery: turnInPlaceRecovery,
402
- mode,
403
- });
503
+ let result;
504
+ try {
505
+ result = await runner.runTurn({
506
+ resume: turnResume,
507
+ instruction: turnInstruction,
508
+ files: turnFiles,
509
+ inPlaceRecovery: turnInPlaceRecovery,
510
+ mode,
511
+ });
512
+ } catch (error) {
513
+ // Another live session holds the provider profile lock. Interactively,
514
+ // offer to resolve it and retry the same turn; otherwise fail cleanly
515
+ // (the top-level handler prints the actionable message).
516
+ if (error?.code === "PROFILE_LOCKED" && interactive) {
517
+ if (await promptToResolveProfileLock(error)) {
518
+ continue;
519
+ }
520
+ return null;
521
+ }
522
+ throw error;
523
+ }
404
524
  if (runner.interrupted) {
405
525
  break;
406
526
  }
@@ -418,7 +538,7 @@ async function executeSession({
418
538
  throw result.error;
419
539
  }
420
540
  runner.renderer.hint(
421
- "Type /retry to ask ChatGPT to continue again, enter a new instruction, or quit.",
541
+ `Type /retry to ask ${runner.renderer.providerLabel} to continue again, enter a new instruction, or quit`,
422
542
  );
423
543
  const next = await promptForNextMessage(runner, activeChatInput);
424
544
  if (next == null) {
@@ -536,23 +656,69 @@ async function resolveMessageAttachments(runner, text) {
536
656
  return files;
537
657
  }
538
658
 
659
+ function isInteractiveSession(options) {
660
+ return !options.once && process.stdin.isTTY && process.stdout.isTTY;
661
+ }
662
+
663
+ async function promptForUpdate({ currentVersion, latest }) {
664
+ return await promptForConfirm({
665
+ message: `Update WTAgent from ${currentVersion} to ${latest} now?`,
666
+ default: true,
667
+ });
668
+ }
669
+
670
+ async function maybeRunStartupChecks(options) {
671
+ return await runStartupChecks({
672
+ appDataDir: resolveRuntimePaths(options).appDataDir,
673
+ interactive: isInteractiveSession(options),
674
+ promptUpdate: promptForUpdate,
675
+ });
676
+ }
677
+
678
+ async function runUpdate() {
679
+ const result = await runSelfUpdate();
680
+ if (result.status === "error") {
681
+ process.exitCode = 1;
682
+ }
683
+ return result;
684
+ }
685
+
539
686
  async function runAgent(taskParts, options) {
540
687
  assertNativeRuntimeSupported();
688
+ options = withResolvedProviderSelection(options);
541
689
  const projectRoot = path.resolve(options.project ?? process.cwd());
542
690
  await assertDirectory(projectRoot);
543
- const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
691
+
692
+ // Fail fast on an unknown/unsupported --model before any startup work.
693
+ const provider = resolveProvider(options.model ?? DEFAULT_PROVIDER);
694
+
695
+ const startup = await maybeRunStartupChecks(options);
696
+ if (startup === "updated" || startup === "aborted") {
697
+ return null;
698
+ }
699
+
700
+ const interactive = isInteractiveSession(options);
544
701
  const chatInput = interactive ? new ShellChatInput() : null;
545
702
 
546
703
  if (interactive) {
547
- printChatBanner(projectRoot);
704
+ printChatBanner(projectRoot, provider);
548
705
  }
549
706
 
707
+ // Mode selection is abstracted per provider: some prompt (ChatGPT's
708
+ // Pro/Current), others silently apply their configured default at
709
+ // conversation start (DeepSeek → 专家模式 + 深度思考). The chosen value is
710
+ // handed to the adapter's selectMode() by the runtime.
550
711
  let requestedMode;
551
- if (options.mode != null) {
712
+ if (!provider.promptsForMode) {
713
+ if (options.mode != null) {
714
+ console.log(`Note: --mode is ignored for ${provider.label}.`);
715
+ }
716
+ requestedMode = provider.defaultMode;
717
+ } else if (options.mode != null) {
552
718
  requestedMode = normalizeConfiguredMode(options.mode);
553
719
  } else if (interactive) {
554
720
  const modeChoice = await promptForSelect({
555
- message: "ChatGPT mode",
721
+ message: `${provider.label} mode`,
556
722
  choices: CHATGPT_MODE_CHOICES,
557
723
  });
558
724
  if (modeChoice == null) {
@@ -587,12 +753,13 @@ async function runAgent(taskParts, options) {
587
753
  chatInput.remember(task);
588
754
  }
589
755
 
590
- const paths = resolveRuntimePaths(options);
756
+ const paths = resolveRuntimePaths(options, provider.id);
591
757
  await ensureDirectory(paths.sessionsDir);
592
758
  const session = await AgentSession.create({
593
759
  sessionsDir: paths.sessionsDir,
594
760
  task,
595
761
  projectRoot,
762
+ provider: provider.id,
596
763
  mode: requestedMode,
597
764
  });
598
765
 
@@ -616,13 +783,13 @@ async function runAgent(taskParts, options) {
616
783
  return await executeSession({ session, options, files, chatInput });
617
784
  }
618
785
 
619
- function printChatBanner(projectRoot) {
786
+ function printChatBanner(projectRoot, provider) {
620
787
  const CYAN = "\x1b[36m";
621
788
  const DIM = "\x1b[2m";
622
789
  const RESET = "\x1b[0m";
623
790
  console.log("");
624
- console.log(`${CYAN}WTAgent${RESET} ${DIM}· GPT Web · ${projectRoot}${RESET}`);
625
- console.log(`${DIM}Enter sends · Shift+Enter newline · ESC cancels processing · multiline paste · ↑/↓ history · "exit", Ctrl+C, or Ctrl+D quits${RESET}`);
791
+ console.log(`${CYAN}WTAgent${RESET} ${DIM}· ${provider.label} · ${projectRoot}${RESET}`);
792
+ console.log(`${DIM}Enter sends · Shift+Enter or Ctrl+J newline · ESC cancels processing · multiline paste · ↑/↓ history · "exit", Ctrl+C, or Ctrl+D quits${RESET}`);
626
793
  console.log("");
627
794
  }
628
795
 
@@ -645,17 +812,39 @@ async function loadSession(paths, sessionId) {
645
812
 
646
813
  async function runResume(sessionId, instructionParts, options) {
647
814
  assertNativeRuntimeSupported();
815
+ options = withResolvedProviderSelection(options);
816
+ // Loading only needs the sessions dir (provider-independent); the provider is
817
+ // then read from the session so the run reuses the right adapter + profile.
648
818
  const paths = resolveRuntimePaths(options);
649
819
  await ensureDirectory(paths.sessionsDir);
650
820
  const session = await loadSession(paths, sessionId);
651
821
  await assertDirectory(session.state.projectRoot);
652
822
 
653
- // Like a fresh run, interactive resumes offer a mode choice, defaulting to
654
- // the mode the conversation is already on. `--mode` overrides it and also
655
- // works non-interactively. This matters after a usage limit, where switching
656
- // thinking levels (Pro vs Current) before retrying can get past the block.
823
+ const provider = resolveProvider(session.state.provider ?? DEFAULT_PROVIDER);
824
+ // A conversation belongs to one provider. --model on resume is only allowed
825
+ // if it names the same provider; switching mid-conversation is rejected.
826
+ if (options.model != null && getProvider(options.model).id !== provider.id) {
827
+ throw new Error(
828
+ `Session ${sessionId} uses ${provider.label}; `
829
+ + `--model ${options.model} cannot change a conversation's provider.`,
830
+ );
831
+ }
832
+
833
+ const startup = await maybeRunStartupChecks(options);
834
+ if (startup === "updated" || startup === "aborted") {
835
+ return null;
836
+ }
837
+
838
+ // Like a fresh run, interactive resumes offer a mode choice (prompting
839
+ // providers only), defaulting to the mode the conversation is already on.
840
+ // `--mode` overrides it and also works non-interactively. This matters after
841
+ // a usage limit, where switching thinking levels can get past the block.
842
+ // Non-prompting providers (e.g. DeepSeek) keep the mode the existing
843
+ // conversation was created with — it does not need re-asserting on resume.
657
844
  let requestedMode;
658
- if (options.mode != null) {
845
+ if (!provider.promptsForMode) {
846
+ requestedMode = null;
847
+ } else if (options.mode != null) {
659
848
  requestedMode = normalizeConfiguredMode(options.mode);
660
849
  } else if (
661
850
  !options.once
@@ -663,7 +852,7 @@ async function runResume(sessionId, instructionParts, options) {
663
852
  && process.stdout.isTTY
664
853
  ) {
665
854
  const modeChoice = await promptForSelect({
666
- message: "ChatGPT mode",
855
+ message: `${provider.label} mode`,
667
856
  choices: CHATGPT_MODE_CHOICES,
668
857
  default: session.state.activeMode === "Pro" ? "pro" : "current",
669
858
  });
@@ -774,14 +963,18 @@ async function runExport(sessionId, options) {
774
963
  const program = new Command()
775
964
  .name("wtagent")
776
965
  .description("Turn your web AI session into a local tool-using agent.")
777
- .version("0.1.0")
966
+ .version(getPackageVersion())
778
967
  .option("--home <path>", "Application data directory")
779
968
  .option("--profile-dir <path>", "Dedicated Chrome profile directory")
780
969
  .option("--chrome-path <path>", "Chrome/Chromium executable")
781
970
  .option("-C, --project <path>", "Project directory", process.cwd())
971
+ .option(
972
+ "--model <provider>",
973
+ `Web AI provider: ${listActiveProviderIds().join(", ")} (default: ${DEFAULT_PROVIDER})`,
974
+ )
782
975
  .option(
783
976
  "--mode <name>",
784
- 'ChatGPT mode: "Pro" selects Pro; "Current" keeps the web setting',
977
+ "ChatGPT mode (Pro/Current), or a provider alias such as kimi",
785
978
  )
786
979
  .option(
787
980
  "--once",
@@ -790,7 +983,7 @@ const program = new Command()
790
983
  )
791
984
  .option(
792
985
  "--model-turn-timeout-ms <milliseconds>",
793
- "Maximum wait for one ChatGPT response (default: 1200000)",
986
+ "Maximum wait for one model response (default: 10 minutes; 16 minutes for ChatGPT Pro)",
794
987
  )
795
988
  .option(
796
989
  "--no-minimize",
@@ -805,6 +998,11 @@ const program = new Command()
805
998
  await runAgent(task, command.optsWithGlobals());
806
999
  });
807
1000
 
1001
+ program
1002
+ .command("update")
1003
+ .description("Install the latest WTAgent from npm.")
1004
+ .action(async () => runUpdate());
1005
+
808
1006
  program
809
1007
  .command("doctor")
810
1008
  .description("Check Node, Chrome, and local data directories.")
@@ -812,12 +1010,12 @@ program
812
1010
 
813
1011
  program
814
1012
  .command("login")
815
- .description("Open the dedicated Chrome profile and wait for ChatGPT login.")
1013
+ .description("Open the dedicated Chrome profile and wait for provider login (use --model to pick a provider).")
816
1014
  .action(async (_, command) => runLogin(command.optsWithGlobals()));
817
1015
 
818
1016
  program
819
1017
  .command("logout")
820
- .description("Delete the local Chrome profile to reset the ChatGPT session.")
1018
+ .description("Delete the local Chrome profile to reset a provider session (use --model to pick a provider).")
821
1019
  .option("--yes", "Skip the confirmation prompt", false)
822
1020
  .action(async (options, command) => {
823
1021
  await runLogout({ ...command.optsWithGlobals(), ...options });
@@ -830,7 +1028,7 @@ program
830
1028
  .argument("[instruction...]", "Optional follow-up instruction")
831
1029
  .option(
832
1030
  "--mode <name>",
833
- 'ChatGPT mode: "Pro" selects Pro; "Current" keeps the web setting',
1031
+ "ChatGPT mode (Pro/Current), or a provider alias such as kimi",
834
1032
  )
835
1033
  .action(async (sessionId, instruction, _, command) => {
836
1034
  await runResume(
@@ -859,6 +1057,25 @@ program
859
1057
  });
860
1058
 
861
1059
  program.parseAsync().catch((error) => {
1060
+ // Expected, actionable failures carry a plain message instead of a stack
1061
+ // trace (e.g. the provider's Chrome profile is locked by another session).
1062
+ if (error?.code === "PROFILE_LOCKED") {
1063
+ console.error(`Error: ${error.message}`);
1064
+ process.exitCode = 1;
1065
+ return;
1066
+ }
862
1067
  console.error(error.stack ?? error.message);
863
1068
  process.exitCode = 1;
1069
+ }).finally(() => {
1070
+ // By the time every command finishes, all per-command cleanup (browser
1071
+ // detach/close, stdin raw-mode restore, process stop) has already run. A
1072
+ // stray tty handle (e.g. a keypress-machinery listener left behind on a
1073
+ // failed turn) can still pin the event loop, leaving a dead prompt that
1074
+ // never returns to the shell — exit hard instead of relying on the loop
1075
+ // to drain naturally.
1076
+ if (process.exitCode != null && process.exitCode !== 0) {
1077
+ process.stdin.setRawMode?.(false);
1078
+ process.stdin.pause?.();
1079
+ process.exit(process.exitCode);
1080
+ }
864
1081
  });