wtagent 0.1.0 → 0.2.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/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) {
@@ -172,7 +208,14 @@ async function runDoctor(options) {
172
208
  }
173
209
  console.log(`Data: ${paths.appDataDir}`);
174
210
  console.log(`Profile: ${paths.profileDir}`);
175
- console.log(report.exitCode === 0 ? "Doctor: OK" : "Doctor: FAILED");
211
+ const hasWarnings = report.items.some((item) => item.status === "degraded");
212
+ console.log(
213
+ report.exitCode !== 0
214
+ ? "Doctor: FAILED"
215
+ : hasWarnings
216
+ ? "Doctor: OK (with warnings)"
217
+ : "Doctor: OK",
218
+ );
176
219
  process.exitCode = report.exitCode;
177
220
  }
178
221
 
@@ -184,24 +227,29 @@ class ConversationRunner {
184
227
  constructor({ session, options, interactive = false }) {
185
228
  this.session = session;
186
229
  this.options = options;
187
- this.paths = resolveRuntimePaths(options);
230
+ // A conversation belongs to exactly one provider (recorded at creation).
231
+ // The profile dir and adapter follow from it, so resumes reuse the right
232
+ // login even when the CLI is invoked without --model.
233
+ const provider = resolveProvider(session.state.provider ?? DEFAULT_PROVIDER);
234
+ this.paths = resolveRuntimePaths(options, provider.id);
188
235
  this.limits = resolveLimits({
189
236
  modelTurnTimeoutMs: options.modelTurnTimeoutMs,
190
237
  });
191
238
  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.
239
+ this.renderer = createRenderer({ providerLabel: provider.label });
240
+ // "Always allow" decisions are scoped to this saved session. They persist
241
+ // across turns and `resume`, but never carry over into a different session.
195
242
  this.approvalStore = new ApprovalStore({
196
- filePath: path.join(this.paths.appDataDir, "approvals.json"),
243
+ filePath: path.join(this.session.directory, "approvals.json"),
197
244
  });
198
- this.adapter = new ChatGPTWebAdapter({
245
+ this.adapter = createWebAdapter({
246
+ provider,
199
247
  profileDir: this.paths.profileDir,
200
248
  chromePath: options.chromePath,
201
249
  debug: options.debug,
202
250
  // Minimize by default; `--no-minimize` sets options.minimize === false.
203
251
  minimized: options.minimize !== false,
204
- // ESC cancels the in-flight turn while ChatGPT is processing. Only in
252
+ // ESC cancels the in-flight turn while the model is processing. Only in
205
253
  // interactive TTY sessions where stdin is available to listen on.
206
254
  cancelOnEsc: interactive,
207
255
  });
@@ -242,14 +290,15 @@ class ConversationRunner {
242
290
  this.approvalStore.setAlwaysAllowedTool(toolCall.name);
243
291
  await this.approvalStore.save();
244
292
  console.log(
245
- `Saved: ${toolCall.name} will always be allowed `
293
+ `Saved for this session: ${toolCall.name} will always be allowed `
246
294
  + `(${this.approvalStore.filePath}).`,
247
295
  );
248
296
  } else if (choice === "always-all") {
249
297
  this.approvalStore.setAlwaysAllowAll();
250
298
  await this.approvalStore.save();
251
299
  console.log(
252
- `Saved: every tool will always be allowed (${this.approvalStore.filePath}).`,
300
+ `Saved for this session: every tool will always be allowed `
301
+ + `(${this.approvalStore.filePath}).`,
253
302
  );
254
303
  }
255
304
  return true;
@@ -340,6 +389,15 @@ class ConversationRunner {
340
389
  this.renderer.finish();
341
390
  await this.processManager.stopAll().catch(() => {});
342
391
  if (keepBrowser) {
392
+ // Only offer to keep Chrome when this run actually launched one. A run
393
+ // that failed before launch (e.g. the provider profile lock was held by
394
+ // another live session) has no window to inspect.
395
+ if (!this.adapter.cdpChrome) {
396
+ return;
397
+ }
398
+ await this.adapter.detach().catch((error) => {
399
+ console.error(`Warning: ${error.message}`);
400
+ });
343
401
  console.log(
344
402
  "The run failed; Chrome was left open for debugging. "
345
403
  + "Inspect the page and close it manually, or just run "
@@ -353,6 +411,56 @@ class ConversationRunner {
353
411
  }
354
412
  }
355
413
 
414
+ async function waitForProcessToExit(pid, timeoutMs = 60_000) {
415
+ const deadline = Date.now() + timeoutMs;
416
+ while (Date.now() < deadline) {
417
+ if (!isProcessAlive(pid)) {
418
+ return true;
419
+ }
420
+ await new Promise((resolve) => setTimeout(resolve, 500));
421
+ }
422
+ return !isProcessAlive(pid);
423
+ }
424
+
425
+ // A live session holds the provider's Chrome profile lock. In an interactive
426
+ // session, ask the user how to proceed instead of failing outright: kill the
427
+ // other session, or have the user close its Chrome window so that session
428
+ // fails and releases the profile. Returns true when the turn should be retried.
429
+ async function promptToResolveProfileLock(error) {
430
+ const pid = Number(error.details?.pid);
431
+ console.log(`\n${"\x1b[33m"}${error.message}${"\x1b[0m"}`);
432
+ const choice = await promptForSelect({
433
+ message: "How should this be handled?",
434
+ choices: [
435
+ { name: "Kill that session and continue", value: "kill" },
436
+ { name: "I closed its Chrome window; retry", value: "retry" },
437
+ { name: "Quit", value: "quit" },
438
+ ],
439
+ });
440
+ if (choice == null || choice === "quit") {
441
+ return false;
442
+ }
443
+ if (choice === "kill" && Number.isSafeInteger(pid) && pid > 0) {
444
+ console.log(`Stopping pid=${pid}...`);
445
+ try {
446
+ process.kill(pid, "SIGTERM");
447
+ } catch {
448
+ // Already gone.
449
+ }
450
+ if (!await waitForProcessToExit(pid, 5_000)) {
451
+ try {
452
+ process.kill(pid, "SIGKILL");
453
+ } catch {
454
+ // Already gone.
455
+ }
456
+ }
457
+ }
458
+ // Either the holder was killed or the user closed its Chrome window: wait
459
+ // for the other session to release the profile, then retry the turn.
460
+ await waitForProcessToExit(pid, 60_000);
461
+ return true;
462
+ }
463
+
356
464
  async function executeSession({
357
465
  session,
358
466
  options,
@@ -367,14 +475,19 @@ async function executeSession({
367
475
  const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
368
476
  const runner = new ConversationRunner({ session, options, interactive });
369
477
 
370
- const onInterrupt = async () => {
478
+ const onInterrupt = () => {
371
479
  if (runner.interrupted) {
372
- return;
480
+ console.log("\nForce quitting.");
481
+ // process.exit skips the finally block, so repeat the session/resume
482
+ // hint here — the user should always know how to continue.
483
+ console.log(`Session saved at: ${session.directory}`);
484
+ printResumeHint(session.sessionId);
485
+ process.exit(130);
373
486
  }
374
487
  runner.interrupted = true;
488
+ runner.adapter.escCancelRequested = true;
375
489
  runner.renderer.stopSpinner();
376
- console.log("\nStopping managed processes and Chrome…");
377
- await runner.close();
490
+ console.log("\nCtrl+C — cancelling. Press again to force quit.");
378
491
  process.exitCode = 130;
379
492
  };
380
493
  process.on("SIGINT", onInterrupt);
@@ -394,13 +507,27 @@ async function executeSession({
394
507
  let turnInPlaceRecovery = false;
395
508
 
396
509
  for (;;) {
397
- const result = await runner.runTurn({
398
- resume: turnResume,
399
- instruction: turnInstruction,
400
- files: turnFiles,
401
- inPlaceRecovery: turnInPlaceRecovery,
402
- mode,
403
- });
510
+ let result;
511
+ try {
512
+ result = await runner.runTurn({
513
+ resume: turnResume,
514
+ instruction: turnInstruction,
515
+ files: turnFiles,
516
+ inPlaceRecovery: turnInPlaceRecovery,
517
+ mode,
518
+ });
519
+ } catch (error) {
520
+ // Another live session holds the provider profile lock. Interactively,
521
+ // offer to resolve it and retry the same turn; otherwise fail cleanly
522
+ // (the top-level handler prints the actionable message).
523
+ if (error?.code === "PROFILE_LOCKED" && interactive) {
524
+ if (await promptToResolveProfileLock(error)) {
525
+ continue;
526
+ }
527
+ return null;
528
+ }
529
+ throw error;
530
+ }
404
531
  if (runner.interrupted) {
405
532
  break;
406
533
  }
@@ -418,7 +545,7 @@ async function executeSession({
418
545
  throw result.error;
419
546
  }
420
547
  runner.renderer.hint(
421
- "Type /retry to ask ChatGPT to continue again, enter a new instruction, or quit.",
548
+ `Type /retry to ask ${runner.renderer.providerLabel} to continue again, enter a new instruction, or quit`,
422
549
  );
423
550
  const next = await promptForNextMessage(runner, activeChatInput);
424
551
  if (next == null) {
@@ -536,23 +663,69 @@ async function resolveMessageAttachments(runner, text) {
536
663
  return files;
537
664
  }
538
665
 
666
+ function isInteractiveSession(options) {
667
+ return !options.once && process.stdin.isTTY && process.stdout.isTTY;
668
+ }
669
+
670
+ async function promptForUpdate({ currentVersion, latest }) {
671
+ return await promptForConfirm({
672
+ message: `Update WTAgent from ${currentVersion} to ${latest} now?`,
673
+ default: true,
674
+ });
675
+ }
676
+
677
+ async function maybeRunStartupChecks(options) {
678
+ return await runStartupChecks({
679
+ appDataDir: resolveRuntimePaths(options).appDataDir,
680
+ interactive: isInteractiveSession(options),
681
+ promptUpdate: promptForUpdate,
682
+ });
683
+ }
684
+
685
+ async function runUpdate() {
686
+ const result = await runSelfUpdate();
687
+ if (result.status === "error") {
688
+ process.exitCode = 1;
689
+ }
690
+ return result;
691
+ }
692
+
539
693
  async function runAgent(taskParts, options) {
540
694
  assertNativeRuntimeSupported();
695
+ options = withResolvedProviderSelection(options);
541
696
  const projectRoot = path.resolve(options.project ?? process.cwd());
542
697
  await assertDirectory(projectRoot);
543
- const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
698
+
699
+ // Fail fast on an unknown/unsupported --model before any startup work.
700
+ const provider = resolveProvider(options.model ?? DEFAULT_PROVIDER);
701
+
702
+ const startup = await maybeRunStartupChecks(options);
703
+ if (startup === "updated" || startup === "aborted") {
704
+ return null;
705
+ }
706
+
707
+ const interactive = isInteractiveSession(options);
544
708
  const chatInput = interactive ? new ShellChatInput() : null;
545
709
 
546
710
  if (interactive) {
547
- printChatBanner(projectRoot);
711
+ printChatBanner(projectRoot, provider);
548
712
  }
549
713
 
714
+ // Mode selection is abstracted per provider: some prompt (ChatGPT's
715
+ // Pro/Current), others silently apply their configured default at
716
+ // conversation start (DeepSeek → 专家模式 + 深度思考). The chosen value is
717
+ // handed to the adapter's selectMode() by the runtime.
550
718
  let requestedMode;
551
- if (options.mode != null) {
719
+ if (!provider.promptsForMode) {
720
+ if (options.mode != null) {
721
+ console.log(`Note: --mode is ignored for ${provider.label}.`);
722
+ }
723
+ requestedMode = provider.defaultMode;
724
+ } else if (options.mode != null) {
552
725
  requestedMode = normalizeConfiguredMode(options.mode);
553
726
  } else if (interactive) {
554
727
  const modeChoice = await promptForSelect({
555
- message: "ChatGPT mode",
728
+ message: `${provider.label} mode`,
556
729
  choices: CHATGPT_MODE_CHOICES,
557
730
  });
558
731
  if (modeChoice == null) {
@@ -587,12 +760,13 @@ async function runAgent(taskParts, options) {
587
760
  chatInput.remember(task);
588
761
  }
589
762
 
590
- const paths = resolveRuntimePaths(options);
763
+ const paths = resolveRuntimePaths(options, provider.id);
591
764
  await ensureDirectory(paths.sessionsDir);
592
765
  const session = await AgentSession.create({
593
766
  sessionsDir: paths.sessionsDir,
594
767
  task,
595
768
  projectRoot,
769
+ provider: provider.id,
596
770
  mode: requestedMode,
597
771
  });
598
772
 
@@ -616,13 +790,13 @@ async function runAgent(taskParts, options) {
616
790
  return await executeSession({ session, options, files, chatInput });
617
791
  }
618
792
 
619
- function printChatBanner(projectRoot) {
793
+ function printChatBanner(projectRoot, provider) {
620
794
  const CYAN = "\x1b[36m";
621
795
  const DIM = "\x1b[2m";
622
796
  const RESET = "\x1b[0m";
623
797
  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}`);
798
+ console.log(`${CYAN}WTAgent${RESET} ${DIM}· ${provider.label} · ${projectRoot}${RESET}`);
799
+ 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
800
  console.log("");
627
801
  }
628
802
 
@@ -645,17 +819,39 @@ async function loadSession(paths, sessionId) {
645
819
 
646
820
  async function runResume(sessionId, instructionParts, options) {
647
821
  assertNativeRuntimeSupported();
822
+ options = withResolvedProviderSelection(options);
823
+ // Loading only needs the sessions dir (provider-independent); the provider is
824
+ // then read from the session so the run reuses the right adapter + profile.
648
825
  const paths = resolveRuntimePaths(options);
649
826
  await ensureDirectory(paths.sessionsDir);
650
827
  const session = await loadSession(paths, sessionId);
651
828
  await assertDirectory(session.state.projectRoot);
652
829
 
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.
830
+ const provider = resolveProvider(session.state.provider ?? DEFAULT_PROVIDER);
831
+ // A conversation belongs to one provider. --model on resume is only allowed
832
+ // if it names the same provider; switching mid-conversation is rejected.
833
+ if (options.model != null && getProvider(options.model).id !== provider.id) {
834
+ throw new Error(
835
+ `Session ${sessionId} uses ${provider.label}; `
836
+ + `--model ${options.model} cannot change a conversation's provider.`,
837
+ );
838
+ }
839
+
840
+ const startup = await maybeRunStartupChecks(options);
841
+ if (startup === "updated" || startup === "aborted") {
842
+ return null;
843
+ }
844
+
845
+ // Like a fresh run, interactive resumes offer a mode choice (prompting
846
+ // providers only), defaulting to the mode the conversation is already on.
847
+ // `--mode` overrides it and also works non-interactively. This matters after
848
+ // a usage limit, where switching thinking levels can get past the block.
849
+ // Non-prompting providers (e.g. DeepSeek) keep the mode the existing
850
+ // conversation was created with — it does not need re-asserting on resume.
657
851
  let requestedMode;
658
- if (options.mode != null) {
852
+ if (!provider.promptsForMode) {
853
+ requestedMode = null;
854
+ } else if (options.mode != null) {
659
855
  requestedMode = normalizeConfiguredMode(options.mode);
660
856
  } else if (
661
857
  !options.once
@@ -663,7 +859,7 @@ async function runResume(sessionId, instructionParts, options) {
663
859
  && process.stdout.isTTY
664
860
  ) {
665
861
  const modeChoice = await promptForSelect({
666
- message: "ChatGPT mode",
862
+ message: `${provider.label} mode`,
667
863
  choices: CHATGPT_MODE_CHOICES,
668
864
  default: session.state.activeMode === "Pro" ? "pro" : "current",
669
865
  });
@@ -774,14 +970,18 @@ async function runExport(sessionId, options) {
774
970
  const program = new Command()
775
971
  .name("wtagent")
776
972
  .description("Turn your web AI session into a local tool-using agent.")
777
- .version("0.1.0")
973
+ .version(getPackageVersion())
778
974
  .option("--home <path>", "Application data directory")
779
975
  .option("--profile-dir <path>", "Dedicated Chrome profile directory")
780
976
  .option("--chrome-path <path>", "Chrome/Chromium executable")
781
977
  .option("-C, --project <path>", "Project directory", process.cwd())
978
+ .option(
979
+ "--model <provider>",
980
+ `Web AI provider: ${listActiveProviderIds().join(", ")} (default: ${DEFAULT_PROVIDER})`,
981
+ )
782
982
  .option(
783
983
  "--mode <name>",
784
- 'ChatGPT mode: "Pro" selects Pro; "Current" keeps the web setting',
984
+ "ChatGPT mode (Pro/Current), or a provider alias such as kimi",
785
985
  )
786
986
  .option(
787
987
  "--once",
@@ -790,7 +990,7 @@ const program = new Command()
790
990
  )
791
991
  .option(
792
992
  "--model-turn-timeout-ms <milliseconds>",
793
- "Maximum wait for one ChatGPT response (default: 1200000)",
993
+ "Maximum wait for one model response (default: 10 minutes; 16 minutes for ChatGPT Pro)",
794
994
  )
795
995
  .option(
796
996
  "--no-minimize",
@@ -805,6 +1005,11 @@ const program = new Command()
805
1005
  await runAgent(task, command.optsWithGlobals());
806
1006
  });
807
1007
 
1008
+ program
1009
+ .command("update")
1010
+ .description("Install the latest WTAgent from npm.")
1011
+ .action(async () => runUpdate());
1012
+
808
1013
  program
809
1014
  .command("doctor")
810
1015
  .description("Check Node, Chrome, and local data directories.")
@@ -812,12 +1017,12 @@ program
812
1017
 
813
1018
  program
814
1019
  .command("login")
815
- .description("Open the dedicated Chrome profile and wait for ChatGPT login.")
1020
+ .description("Open the dedicated Chrome profile and wait for provider login (use --model to pick a provider).")
816
1021
  .action(async (_, command) => runLogin(command.optsWithGlobals()));
817
1022
 
818
1023
  program
819
1024
  .command("logout")
820
- .description("Delete the local Chrome profile to reset the ChatGPT session.")
1025
+ .description("Delete the local Chrome profile to reset a provider session (use --model to pick a provider).")
821
1026
  .option("--yes", "Skip the confirmation prompt", false)
822
1027
  .action(async (options, command) => {
823
1028
  await runLogout({ ...command.optsWithGlobals(), ...options });
@@ -830,7 +1035,7 @@ program
830
1035
  .argument("[instruction...]", "Optional follow-up instruction")
831
1036
  .option(
832
1037
  "--mode <name>",
833
- 'ChatGPT mode: "Pro" selects Pro; "Current" keeps the web setting',
1038
+ "ChatGPT mode (Pro/Current), or a provider alias such as kimi",
834
1039
  )
835
1040
  .action(async (sessionId, instruction, _, command) => {
836
1041
  await runResume(
@@ -859,6 +1064,25 @@ program
859
1064
  });
860
1065
 
861
1066
  program.parseAsync().catch((error) => {
1067
+ // Expected, actionable failures carry a plain message instead of a stack
1068
+ // trace (e.g. the provider's Chrome profile is locked by another session).
1069
+ if (error?.code === "PROFILE_LOCKED") {
1070
+ console.error(`Error: ${error.message}`);
1071
+ process.exitCode = 1;
1072
+ return;
1073
+ }
862
1074
  console.error(error.stack ?? error.message);
863
1075
  process.exitCode = 1;
1076
+ }).finally(() => {
1077
+ // By the time every command finishes, all per-command cleanup (browser
1078
+ // detach/close, stdin raw-mode restore, process stop) has already run. A
1079
+ // stray tty handle (e.g. a keypress-machinery listener left behind on a
1080
+ // failed turn) can still pin the event loop, leaving a dead prompt that
1081
+ // never returns to the shell — exit hard instead of relying on the loop
1082
+ // to drain naturally.
1083
+ if (process.exitCode != null && process.exitCode !== 0) {
1084
+ process.stdin.setRawMode?.(false);
1085
+ process.stdin.pause?.();
1086
+ process.exit(process.exitCode);
1087
+ }
864
1088
  });