wtagent 0.2.2 → 0.4.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
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
4
5
  import { Command } from "commander";
5
6
  import { confirm, select } from "@inquirer/prompts";
6
7
  import { launchNativeLoginBrowser } from "../browser/native-login.js";
@@ -11,11 +12,11 @@ import {
11
12
  getProviderProfileDir,
12
13
  isProviderProfileBasename,
13
14
  listActiveProviderIds,
14
- resolveCliProviderSelection,
15
15
  resolveProvider,
16
16
  } from "../browser/provider-registry.js";
17
17
  import {
18
18
  ensureDirectory,
19
+ formatHomePath,
19
20
  getAppDataDir,
20
21
  getSessionsDir,
21
22
  getTasksDir,
@@ -33,6 +34,7 @@ import { ApprovalStore } from "../policy/approval-store.js";
33
34
  import { createDefaultToolRegistry } from "../tools/default-tools.js";
34
35
  import { ProcessManager } from "../tools/process-manager.js";
35
36
  import { resolveLimits } from "../shared/limits.js";
37
+ import { BrowserAdapterError } from "../shared/errors.js";
36
38
  import { EXPORTERS } from "../session/session-export.js";
37
39
  import { getPackageVersion } from "../shared/package-info.js";
38
40
  import { extractAtMentions } from "./at-files.js";
@@ -45,11 +47,7 @@ import {
45
47
  ShellChatInput,
46
48
  } from "./prompt-input.js";
47
49
  import { createRenderer } from "./render-events.js";
48
- import {
49
- CHATGPT_MODE_CHOICES,
50
- modeFromPromptChoice,
51
- normalizeConfiguredMode,
52
- } from "./mode-choice.js";
50
+ import { t } from "./i18n.js";
53
51
  import { runSelfUpdate } from "./self-update.js";
54
52
  import { runStartupChecks } from "./startup-notices.js";
55
53
 
@@ -58,10 +56,6 @@ import { runStartupChecks } from "./startup-notices.js";
58
56
  // (each provider logs in independently); an explicit `--profile-dir` still
59
57
  // overrides it. Defaults to the ChatGPT profile so callers that predate
60
58
  // multi-provider support are unaffected.
61
- function withResolvedProviderSelection(options) {
62
- return { ...options, ...resolveCliProviderSelection(options) };
63
- }
64
-
65
59
  function resolveRuntimePaths(options, providerId = DEFAULT_PROVIDER) {
66
60
  const appDataDir = path.resolve(options.home ?? getAppDataDir());
67
61
  return {
@@ -83,15 +77,12 @@ async function assertDirectory(directory) {
83
77
 
84
78
  async function runLogin(options) {
85
79
  assertNativeRuntimeSupported();
86
- options = withResolvedProviderSelection(options);
87
80
  const provider = resolveProvider(options.model ?? DEFAULT_PROVIDER);
88
81
  const { profileDir } = resolveRuntimePaths(options, provider.id);
89
82
  const { label, baseUrl } = provider;
90
83
  for (;;) {
91
- console.log(`Opening native Chrome profile: ${profileDir}`);
92
- console.log(
93
- `This window has no CDP flags. Finish until ${label} shows your signed-in home/chat history and no Log in button.`,
94
- );
84
+ console.log(t("login.openingProfile", { profileDir }));
85
+ console.log(t("login.finishSignIn", { provider: label }));
95
86
  const browser = await launchNativeLoginBrowser({
96
87
  profileDir,
97
88
  chromePath: options.chromePath,
@@ -100,13 +91,12 @@ async function runLogin(options) {
100
91
 
101
92
  try {
102
93
  const answer = await promptForText({
103
- message:
104
- `After the signed-in ${label} home is visible, press Enter here to save and verify`,
94
+ message: t("login.pressEnterVerify", { provider: label }),
105
95
  });
106
96
  if (answer == null) {
107
97
  return;
108
98
  }
109
- console.log("Closing native Chrome and saving the dedicated profile...");
99
+ console.log(t("login.savingProfile"));
110
100
  await new Promise((resolve) => setTimeout(resolve, 1_000));
111
101
  } finally {
112
102
  await browser.close();
@@ -129,16 +119,14 @@ async function runLogin(options) {
129
119
  }
130
120
  }
131
121
  if (authenticated) {
132
- console.log(`${label} login verified through a fresh CDP connection.`);
122
+ console.log(t("login.verified", { provider: label }));
133
123
  return;
134
124
  }
135
125
  } finally {
136
126
  await verifier.close();
137
127
  }
138
128
 
139
- console.log(
140
- `${label} is still in guest mode. Reopening native Chrome; complete the final ${label} sign-in/continue step.`,
141
- );
129
+ console.log(t("login.stillGuest", { provider: label }));
142
130
  }
143
131
  }
144
132
 
@@ -150,14 +138,13 @@ async function runLogout(options) {
150
138
  // `logout` targets one provider's profile; unknown ids are rejected but a
151
139
  // "planned" provider is still allowed (its profile may exist from a prior
152
140
  // login attempt), so use getProvider rather than resolveProvider.
153
- options = withResolvedProviderSelection(options);
154
141
  const provider = getProvider(options.model ?? DEFAULT_PROVIDER);
155
142
  const { profileDir } = resolveRuntimePaths(options, provider.id);
156
143
  const exists = await fs.stat(profileDir)
157
144
  .then((stat) => stat.isDirectory())
158
145
  .catch(() => false);
159
146
  if (!exists) {
160
- console.log(`No Chrome profile found at ${profileDir}; already logged out.`);
147
+ console.log(t("logout.none", { profileDir }));
161
148
  return;
162
149
  }
163
150
 
@@ -168,30 +155,29 @@ async function runLogout(options) {
168
155
  .then((stat) => stat.isDirectory())
169
156
  .catch(() => false);
170
157
  if (!looksLikeProfile && !isProviderProfileBasename(path.basename(profileDir))) {
171
- throw new Error(
172
- `Refusing to delete ${profileDir}: it does not look like a wtagent Chrome profile.`,
173
- );
158
+ throw new Error(t("logout.refuse", { profileDir }));
174
159
  }
175
160
 
176
161
  if (!options.yes) {
177
162
  const confirmed = await confirm({
178
- message:
179
- `This deletes the local ${provider.label} session (Chrome profile at ${profileDir}) `
180
- + "and requires a new login. Continue?",
163
+ message: t("logout.confirm", {
164
+ provider: provider.label,
165
+ profileDir,
166
+ }),
181
167
  default: false,
182
168
  });
183
169
  if (!confirmed) {
184
- console.log("Logout cancelled.");
170
+ console.log(t("logout.cancelled"));
185
171
  return;
186
172
  }
187
173
  }
188
174
 
189
175
  await fs.rm(profileDir, { recursive: true, force: true });
190
- console.log(`Logged out. Removed ${profileDir}.`);
176
+ console.log(t("logout.done", { profileDir }));
191
177
  console.log(
192
178
  provider.id === DEFAULT_PROVIDER
193
- ? "Run `wtagent login` to sign in again."
194
- : `Run \`wtagent login --model ${provider.id}\` to sign in again.`,
179
+ ? t("logout.loginAgainDefault")
180
+ : t("logout.loginAgainProvider", { provider: provider.id }),
195
181
  );
196
182
  }
197
183
 
@@ -231,6 +217,8 @@ class ConversationRunner {
231
217
  // The profile dir and adapter follow from it, so resumes reuse the right
232
218
  // login even when the CLI is invoked without --model.
233
219
  const provider = resolveProvider(session.state.provider ?? DEFAULT_PROVIDER);
220
+ this.provider = provider;
221
+ this.interactive = interactive;
234
222
  this.paths = resolveRuntimePaths(options, provider.id);
235
223
  this.limits = resolveLimits({
236
224
  modelTurnTimeoutMs: options.modelTurnTimeoutMs,
@@ -267,20 +255,45 @@ class ConversationRunner {
267
255
  policy: new PolicyEngine({ store: this.approvalStore }),
268
256
  session: this.session,
269
257
  limits: this.limits,
258
+ postAuthSetup: (
259
+ this.interactive
260
+ ? async ({ adapter }) => {
261
+ this.renderer.stopSpinner();
262
+ await adapter.restoreWindow?.();
263
+ try {
264
+ const answer = await promptForText({
265
+ message: t("model.chooseInBrowser", {
266
+ provider: this.provider.label,
267
+ }),
268
+ });
269
+ if (answer == null) {
270
+ throw new Error(t("model.setupCancelled"));
271
+ }
272
+ } finally {
273
+ await adapter.minimizeWindow?.();
274
+ }
275
+ }
276
+ : null
277
+ ),
270
278
  approval: async ({ toolCall, reasons }) => {
271
279
  this.renderer.stopSpinner();
272
- console.log(`\n${"\x1b[33m"}Approval required for ${toolCall.name}:${"\x1b[0m"}`);
280
+ console.log(
281
+ `\n${"\x1b[33m"}${t("approval.required", { tool: toolCall.name })}${"\x1b[0m"}`,
282
+ );
273
283
  for (const reason of reasons) {
274
284
  console.log(`- ${reason}`);
275
285
  }
276
286
  console.log(JSON.stringify(toolCall.args, null, 2));
277
287
  const choice = await select({
278
- message: "How should this action be handled?",
288
+ message: t("approval.how"),
279
289
  choices: [
280
- { name: "Allow once", value: "once" },
281
- { name: `Always allow ${toolCall.name}`, value: "always-tool" },
282
- { name: "Always allow everything", value: "always-all" },
283
- { name: "Deny", value: "deny" },
290
+ { name: t("approval.once"), value: "once" },
291
+ {
292
+ name: t("approval.alwaysTool", { tool: toolCall.name }),
293
+ value: "always-tool",
294
+ },
295
+ { name: t("approval.alwaysAll"), value: "always-all" },
296
+ { name: t("approval.deny"), value: "deny" },
284
297
  ],
285
298
  });
286
299
  if (choice === "deny") {
@@ -289,17 +302,16 @@ class ConversationRunner {
289
302
  if (choice === "always-tool") {
290
303
  this.approvalStore.setAlwaysAllowedTool(toolCall.name);
291
304
  await this.approvalStore.save();
292
- console.log(
293
- `Saved for this session: ${toolCall.name} will always be allowed `
294
- + `(${this.approvalStore.filePath}).`,
295
- );
305
+ console.log(t("approval.savedTool", {
306
+ tool: toolCall.name,
307
+ file: formatHomePath(this.approvalStore.filePath),
308
+ }));
296
309
  } else if (choice === "always-all") {
297
310
  this.approvalStore.setAlwaysAllowAll();
298
311
  await this.approvalStore.save();
299
- console.log(
300
- `Saved for this session: every tool will always be allowed `
301
- + `(${this.approvalStore.filePath}).`,
302
- );
312
+ console.log(t("approval.savedAll", {
313
+ file: formatHomePath(this.approvalStore.filePath),
314
+ }));
303
315
  }
304
316
  return true;
305
317
  },
@@ -426,22 +438,28 @@ async function waitForProcessToExit(pid, timeoutMs = 60_000) {
426
438
  // session, ask the user how to proceed instead of failing outright: kill the
427
439
  // other session, or have the user close its Chrome window so that session
428
440
  // fails and releases the profile. Returns true when the turn should be retried.
429
- async function promptToResolveProfileLock(error) {
441
+ export async function promptToResolveProfileLock(error) {
430
442
  const pid = Number(error.details?.pid);
443
+ if (pid === process.pid) {
444
+ throw new Error(
445
+ "The current WTAgent process still holds its browser profile lock. Refusing to terminate itself.",
446
+ { cause: error },
447
+ );
448
+ }
431
449
  console.log(`\n${"\x1b[33m"}${error.message}${"\x1b[0m"}`);
432
450
  const choice = await promptForSelect({
433
- message: "How should this be handled?",
451
+ message: t("profileLock.how"),
434
452
  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" },
453
+ { name: t("profileLock.kill"), value: "kill" },
454
+ { name: t("profileLock.retry"), value: "retry" },
455
+ { name: t("profileLock.quit"), value: "quit" },
438
456
  ],
439
457
  });
440
458
  if (choice == null || choice === "quit") {
441
459
  return false;
442
460
  }
443
461
  if (choice === "kill" && Number.isSafeInteger(pid) && pid > 0) {
444
- console.log(`Stopping pid=${pid}...`);
462
+ console.log(t("profileLock.stopping", { pid }));
445
463
  try {
446
464
  process.kill(pid, "SIGTERM");
447
465
  } catch {
@@ -461,7 +479,7 @@ async function promptToResolveProfileLock(error) {
461
479
  return true;
462
480
  }
463
481
 
464
- async function executeSession({
482
+ export async function executeSession({
465
483
  session,
466
484
  options,
467
485
  resume = false,
@@ -469,25 +487,28 @@ async function executeSession({
469
487
  files = [],
470
488
  chatInput = null,
471
489
  mode = null,
472
- }) {
490
+ }, {
491
+ interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY,
492
+ createRunner = (config) => new ConversationRunner(config),
493
+ readNextMessage = promptForNextMessage,
494
+ } = {}) {
473
495
  const paths = resolveRuntimePaths(options);
474
496
  await ensureDirectory(paths.sessionsDir);
475
- const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
476
- const runner = new ConversationRunner({ session, options, interactive });
497
+ const runner = createRunner({ session, options, interactive });
477
498
 
478
499
  const onInterrupt = () => {
479
500
  if (runner.interrupted) {
480
- console.log("\nForce quitting.");
501
+ console.log(`\n${t("session.forceQuit")}`);
481
502
  // process.exit skips the finally block, so repeat the session/resume
482
503
  // hint here — the user should always know how to continue.
483
- console.log(`Session saved at: ${session.directory}`);
504
+ console.log(t("session.savedAt", { directory: session.directory }));
484
505
  printResumeHint(session.sessionId);
485
506
  process.exit(130);
486
507
  }
487
508
  runner.interrupted = true;
488
509
  runner.adapter.escCancelRequested = true;
489
510
  runner.renderer.stopSpinner();
490
- console.log("\nCtrl+C — cancelling. Press again to force quit.");
511
+ console.log(`\n${t("session.ctrlC")}`);
491
512
  process.exitCode = 130;
492
513
  };
493
514
  process.on("SIGINT", onInterrupt);
@@ -535,23 +556,43 @@ async function executeSession({
535
556
  if (!interactive) {
536
557
  throw result.error;
537
558
  }
538
- runner.renderer.hint("Turn cancelled. Type a new message or quit.");
539
- turnResume = true;
540
- turnInPlaceRecovery = false;
541
- continue;
559
+ runner.renderer.hint(t("session.cancelledHint"));
560
+ // Fall through to the same input prompt as a completed turn. Never
561
+ // resume automatically or reuse the cancelled instruction/attachments.
542
562
  }
543
563
  if (result?.recoveryRequired) {
544
564
  if (!interactive) {
545
565
  throw result.error;
546
566
  }
547
567
  runner.renderer.hint(
548
- `Type /retry to ask ${runner.renderer.providerLabel} to continue again, enter a new instruction, or quit`,
568
+ t("session.recoveryHint", {
569
+ provider: runner.renderer.providerLabel,
570
+ }),
549
571
  );
550
- const next = await promptForNextMessage(runner, activeChatInput);
572
+ const pendingRecovery = session.state.pendingOutbound != null
573
+ || session.state.pendingAssistantTurn != null;
574
+ let next;
575
+ let retryOnly = false;
576
+ for (;;) {
577
+ next = await readNextMessage(runner, activeChatInput, {
578
+ resolveAttachments: !pendingRecovery,
579
+ });
580
+ if (next == null) {
581
+ break;
582
+ }
583
+ retryOnly = next.text.trim().toLowerCase() === "/retry";
584
+ if (!pendingRecovery || retryOnly) {
585
+ break;
586
+ }
587
+ runner.renderer.hint(
588
+ t("session.recoveryHint", {
589
+ provider: runner.renderer.providerLabel,
590
+ }),
591
+ );
592
+ }
551
593
  if (next == null) {
552
594
  break;
553
595
  }
554
- const retryOnly = next.text.trim().toLowerCase() === "/retry";
555
596
  if (!retryOnly) {
556
597
  await session.appendInstruction(next.text, { files: next.files });
557
598
  }
@@ -574,7 +615,7 @@ async function executeSession({
574
615
  (item) => item.status === "running",
575
616
  );
576
617
  if (running.length > 0) {
577
- runner.renderer.hint("Managed processes still running:");
618
+ runner.renderer.hint(t("session.processesRunning"));
578
619
  for (const item of running) {
579
620
  runner.renderer.hint(
580
621
  ` ${item.processId} pid=${item.pid} ${item.detectedUrls.join(" ")}`,
@@ -582,7 +623,7 @@ async function executeSession({
582
623
  }
583
624
  }
584
625
 
585
- const next = await promptForNextMessage(runner, activeChatInput);
626
+ const next = await readNextMessage(runner, activeChatInput);
586
627
  if (next == null) {
587
628
  break;
588
629
  }
@@ -599,7 +640,7 @@ async function executeSession({
599
640
  process.removeListener("SIGINT", onInterrupt);
600
641
  activeChatInput?.close();
601
642
  await runner.close({ keepBrowser: runFailed });
602
- console.log(`Session saved at: ${session.directory}`);
643
+ console.log(t("session.savedAt", { directory: session.directory }));
603
644
  printResumeHint(session.sessionId);
604
645
  }
605
646
  }
@@ -610,7 +651,7 @@ function printResumeHint(sessionId) {
610
651
  const rule = "─".repeat(48);
611
652
  console.log("");
612
653
  console.log(rule);
613
- console.log(`Resume this conversation with: wtagent resume ${sessionId}`);
654
+ console.log(t("session.resumeWith", { sessionId }));
614
655
  console.log(rule);
615
656
  console.log("");
616
657
  }
@@ -619,12 +660,16 @@ function printResumeHint(sessionId) {
619
660
  // re-prompts. Returns null for an explicit exit command, Ctrl+C, Ctrl+D, or EOF;
620
661
  // otherwise returns
621
662
  // { text, files } where files are resolved @file attachments.
622
- async function promptForNextMessage(runner, chatInput) {
663
+ async function promptForNextMessage(
664
+ runner,
665
+ chatInput,
666
+ { resolveAttachments = true } = {},
667
+ ) {
623
668
  for (;;) {
624
669
  const answer = chatInput
625
670
  ? await chatInput.read()
626
671
  : await promptForText({
627
- message: "you ›",
672
+ message: t("task.youPrompt"),
628
673
  theme: { prefix: "" },
629
674
  });
630
675
  if (answer == null) {
@@ -640,7 +685,9 @@ async function promptForNextMessage(runner, chatInput) {
640
685
  return null;
641
686
  }
642
687
  const { text } = classified;
643
- const files = await resolveMessageAttachments(runner, text);
688
+ const files = resolveAttachments
689
+ ? await resolveMessageAttachments(runner, text)
690
+ : [];
644
691
  return { text, files };
645
692
  }
646
693
  }
@@ -652,12 +699,16 @@ async function resolveMessageAttachments(runner, text) {
652
699
  const { files, missing } = await extractAtMentions(text, projectRoot);
653
700
  if (files.length > 0) {
654
701
  runner.renderer.hint(
655
- `Attaching: ${files.map((file) => file.name).join(", ")}`,
702
+ t("attachment.attaching", {
703
+ files: files.map((file) => file.name).join(", "),
704
+ }),
656
705
  );
657
706
  }
658
707
  if (missing.length > 0) {
659
708
  runner.renderer.hint(
660
- `Not attached (${missing.map((m) => `${m.requested}: ${m.reason}`).join("; ")})`,
709
+ t("attachment.notAttached", {
710
+ details: missing.map((m) => `${m.requested}: ${m.reason}`).join("; "),
711
+ }),
661
712
  );
662
713
  }
663
714
  return files;
@@ -669,7 +720,7 @@ function isInteractiveSession(options) {
669
720
 
670
721
  async function promptForUpdate({ currentVersion, latest }) {
671
722
  return await promptForConfirm({
672
- message: `Update WTAgent from ${currentVersion} to ${latest} now?`,
723
+ message: t("update.prompt", { currentVersion, latest }),
673
724
  default: true,
674
725
  });
675
726
  }
@@ -692,7 +743,6 @@ async function runUpdate() {
692
743
 
693
744
  async function runAgent(taskParts, options) {
694
745
  assertNativeRuntimeSupported();
695
- options = withResolvedProviderSelection(options);
696
746
  const projectRoot = path.resolve(options.project ?? process.cwd());
697
747
  await assertDirectory(projectRoot);
698
748
 
@@ -711,35 +761,6 @@ async function runAgent(taskParts, options) {
711
761
  printChatBanner(projectRoot, provider);
712
762
  }
713
763
 
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.
718
- let requestedMode;
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) {
725
- requestedMode = normalizeConfiguredMode(options.mode);
726
- } else if (interactive) {
727
- const modeChoice = await promptForSelect({
728
- message: `${provider.label} mode`,
729
- choices: CHATGPT_MODE_CHOICES,
730
- });
731
- if (modeChoice == null) {
732
- chatInput?.close();
733
- console.log("");
734
- return null;
735
- }
736
- requestedMode = modeFromPromptChoice(modeChoice);
737
- } else {
738
- // Non-interactive callers cannot answer a picker. Preserve the current web
739
- // setting unless they explicitly opt into `--mode Pro`.
740
- requestedMode = null;
741
- }
742
-
743
764
  // In interactive mode an initial task is optional: the user can just start
744
765
  // typing at the prompt. In one-shot mode a task is required.
745
766
  let task = taskParts.join(" ").trim();
@@ -747,8 +768,8 @@ async function runAgent(taskParts, options) {
747
768
  const initialMessage = interactive
748
769
  ? await readChatMessage(() => chatInput.read())
749
770
  : await promptForText({
750
- message: "Task",
751
- validate: (value) => value.trim() ? true : "Please type a message.",
771
+ message: t("task.prompt"),
772
+ validate: (value) => value.trim() ? true : t("task.required"),
752
773
  });
753
774
  if (initialMessage == null) {
754
775
  chatInput?.close();
@@ -767,7 +788,7 @@ async function runAgent(taskParts, options) {
767
788
  task,
768
789
  projectRoot,
769
790
  provider: provider.id,
770
- mode: requestedMode,
791
+ mode: null,
771
792
  });
772
793
 
773
794
  // Resolve @file attachments in the opening task, if any. The task itself is
@@ -778,12 +799,14 @@ async function runAgent(taskParts, options) {
778
799
  const { files: found, missing } = await extractAtMentions(task, projectRoot);
779
800
  files = found;
780
801
  if (found.length > 0) {
781
- console.log(`Attaching: ${found.map((file) => file.name).join(", ")}`);
802
+ console.log(t("attachment.attaching", {
803
+ files: found.map((file) => file.name).join(", "),
804
+ }));
782
805
  }
783
806
  if (missing.length > 0) {
784
- console.log(
785
- `Not attached (${missing.map((m) => `${m.requested}: ${m.reason}`).join("; ")})`,
786
- );
807
+ console.log(t("attachment.notAttached", {
808
+ details: missing.map((m) => `${m.requested}: ${m.reason}`).join("; "),
809
+ }));
787
810
  }
788
811
  }
789
812
 
@@ -795,8 +818,8 @@ function printChatBanner(projectRoot, provider) {
795
818
  const DIM = "\x1b[2m";
796
819
  const RESET = "\x1b[0m";
797
820
  console.log("");
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}`);
821
+ console.log(`${CYAN}WTAgent${RESET} ${DIM}· ${provider.label} · ${formatHomePath(projectRoot)}${RESET}`);
822
+ console.log(`${DIM}${t("banner.controls")}${RESET}`);
800
823
  console.log("");
801
824
  }
802
825
 
@@ -819,13 +842,25 @@ async function loadSession(paths, sessionId) {
819
842
 
820
843
  async function runResume(sessionId, instructionParts, options) {
821
844
  assertNativeRuntimeSupported();
822
- options = withResolvedProviderSelection(options);
823
845
  // Loading only needs the sessions dir (provider-independent); the provider is
824
846
  // then read from the session so the run reuses the right adapter + profile.
825
847
  const paths = resolveRuntimePaths(options);
826
848
  await ensureDirectory(paths.sessionsDir);
827
849
  const session = await loadSession(paths, sessionId);
828
850
  await assertDirectory(session.state.projectRoot);
851
+ const instruction = instructionParts.join(" ").trim();
852
+ if (
853
+ instruction
854
+ && (
855
+ session.state.pendingOutbound != null
856
+ || session.state.pendingAssistantTurn != null
857
+ )
858
+ ) {
859
+ throw new BrowserAdapterError(
860
+ "This session has a pending browser handoff. Resume it without an instruction or attachment first.",
861
+ { code: "RECOVERY_REQUIRES_BARE_RESUME", recoverable: false },
862
+ );
863
+ }
829
864
 
830
865
  const provider = resolveProvider(session.state.provider ?? DEFAULT_PROVIDER);
831
866
  // A conversation belongs to one provider. --model on resume is only allowed
@@ -842,49 +877,20 @@ async function runResume(sessionId, instructionParts, options) {
842
877
  return null;
843
878
  }
844
879
 
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.
851
- let requestedMode;
852
- if (!provider.promptsForMode) {
853
- requestedMode = null;
854
- } else if (options.mode != null) {
855
- requestedMode = normalizeConfiguredMode(options.mode);
856
- } else if (
857
- !options.once
858
- && process.stdin.isTTY
859
- && process.stdout.isTTY
860
- ) {
861
- const modeChoice = await promptForSelect({
862
- message: `${provider.label} mode`,
863
- choices: CHATGPT_MODE_CHOICES,
864
- default: session.state.activeMode === "Pro" ? "pro" : "current",
865
- });
866
- if (modeChoice == null) {
867
- console.log("");
868
- return null;
869
- }
870
- requestedMode = modeFromPromptChoice(modeChoice);
871
- } else {
872
- requestedMode = null;
873
- }
874
-
875
- const instruction = instructionParts.join(" ").trim();
876
880
  let files = [];
877
881
  if (instruction) {
878
882
  const projectRoot = session.state.projectRoot;
879
883
  const { files: found, missing } = await extractAtMentions(instruction, projectRoot);
880
884
  files = found;
881
885
  if (found.length > 0) {
882
- console.log(`Attaching: ${found.map((file) => file.name).join(", ")}`);
886
+ console.log(t("attachment.attaching", {
887
+ files: found.map((file) => file.name).join(", "),
888
+ }));
883
889
  }
884
890
  if (missing.length > 0) {
885
- console.log(
886
- `Not attached (${missing.map((m) => `${m.requested}: ${m.reason}`).join("; ")})`,
887
- );
891
+ console.log(t("attachment.notAttached", {
892
+ details: missing.map((m) => `${m.requested}: ${m.reason}`).join("; "),
893
+ }));
888
894
  }
889
895
  await session.appendInstruction(instruction, { files });
890
896
  }
@@ -895,7 +901,6 @@ async function runResume(sessionId, instructionParts, options) {
895
901
  resume: true,
896
902
  instruction: instruction || null,
897
903
  files,
898
- mode: requestedMode,
899
904
  });
900
905
  }
901
906
 
@@ -923,7 +928,7 @@ async function runStatus(sessionId, options) {
923
928
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
924
929
  .slice(0, 20);
925
930
  if (sessions.length === 0) {
926
- console.log("No sessions found.");
931
+ console.log(t("status.none"));
927
932
  return;
928
933
  }
929
934
 
@@ -969,74 +974,56 @@ async function runExport(sessionId, options) {
969
974
 
970
975
  const program = new Command()
971
976
  .name("wtagent")
972
- .description("Turn your web AI session into a local tool-using agent.")
977
+ .description(t("app.description"))
973
978
  .version(getPackageVersion())
974
- .option("--home <path>", "Application data directory")
975
- .option("--profile-dir <path>", "Dedicated Chrome profile directory")
976
- .option("--chrome-path <path>", "Chrome/Chromium executable")
977
- .option("-C, --project <path>", "Project directory", process.cwd())
979
+ .option("--home <path>", t("option.home"))
980
+ .option("--profile-dir <path>", t("option.profileDir"))
981
+ .option("--chrome-path <path>", t("option.chromePath"))
982
+ .option("-C, --project <path>", t("option.project"), process.cwd())
978
983
  .option(
979
984
  "--model <provider>",
980
- `Web AI provider: ${listActiveProviderIds().join(", ")} (default: ${DEFAULT_PROVIDER})`,
981
- )
982
- .option(
983
- "--mode <name>",
984
- "ChatGPT mode (Pro/Current), or a provider alias such as kimi",
985
- )
986
- .option(
987
- "--once",
988
- "Run a single request and exit instead of a conversation",
989
- false,
990
- )
991
- .option(
992
- "--model-turn-timeout-ms <milliseconds>",
993
- "Maximum wait for one model response (default: 10 minutes; 16 minutes for ChatGPT Pro)",
994
- )
995
- .option(
996
- "--no-minimize",
997
- "Keep the Chrome window visible instead of minimizing it",
998
- )
999
- .option("--debug", "Write browser diagnostics", false)
1000
- .argument(
1001
- "[task...]",
1002
- "Initial request (optional; you can also type at the prompt)",
985
+ t("option.model", {
986
+ providers: listActiveProviderIds().join(", "),
987
+ defaultProvider: DEFAULT_PROVIDER,
988
+ }),
1003
989
  )
990
+ .option("--once", t("option.once"), false)
991
+ .option("--model-turn-timeout-ms <milliseconds>", t("option.timeout"))
992
+ .option("--no-minimize", t("option.noMinimize"))
993
+ .option("--debug", t("option.debug"), false)
994
+ .argument("[task...]", t("argument.task"))
1004
995
  .action(async (task, _, command) => {
1005
996
  await runAgent(task, command.optsWithGlobals());
1006
997
  });
1007
998
 
1008
999
  program
1009
1000
  .command("update")
1010
- .description("Install the latest WTAgent from npm.")
1001
+ .description(t("command.update"))
1011
1002
  .action(async () => runUpdate());
1012
1003
 
1013
1004
  program
1014
1005
  .command("doctor")
1015
- .description("Check Node, Chrome, and local data directories.")
1006
+ .description(t("command.doctor"))
1016
1007
  .action(async (_, command) => runDoctor(command.optsWithGlobals()));
1017
1008
 
1018
1009
  program
1019
1010
  .command("login")
1020
- .description("Open the dedicated Chrome profile and wait for provider login (use --model to pick a provider).")
1011
+ .description(t("command.login"))
1021
1012
  .action(async (_, command) => runLogin(command.optsWithGlobals()));
1022
1013
 
1023
1014
  program
1024
1015
  .command("logout")
1025
- .description("Delete the local Chrome profile to reset a provider session (use --model to pick a provider).")
1026
- .option("--yes", "Skip the confirmation prompt", false)
1016
+ .description(t("command.logout"))
1017
+ .option("--yes", t("option.yes"), false)
1027
1018
  .action(async (options, command) => {
1028
1019
  await runLogout({ ...command.optsWithGlobals(), ...options });
1029
1020
  });
1030
1021
 
1031
1022
  program
1032
1023
  .command("resume")
1033
- .description("Continue an existing session or recover an interrupted run.")
1034
- .argument("<session-id>", "Saved session ID")
1035
- .argument("[instruction...]", "Optional follow-up instruction")
1036
- .option(
1037
- "--mode <name>",
1038
- "ChatGPT mode (Pro/Current), or a provider alias such as kimi",
1039
- )
1024
+ .description(t("command.resume"))
1025
+ .argument("<session-id>", t("argument.sessionId"))
1026
+ .argument("[instruction...]", t("argument.instruction"))
1040
1027
  .action(async (sessionId, instruction, _, command) => {
1041
1028
  await runResume(
1042
1029
  sessionId,
@@ -1047,42 +1034,49 @@ program
1047
1034
 
1048
1035
  program
1049
1036
  .command("status")
1050
- .description("List saved sessions or show one session as JSON.")
1051
- .argument("[session-id]", "Saved session ID")
1037
+ .description(t("command.status"))
1038
+ .argument("[session-id]", t("argument.sessionId"))
1052
1039
  .action(async (sessionId, _, command) => {
1053
1040
  await runStatus(sessionId, command.optsWithGlobals());
1054
1041
  });
1055
1042
 
1056
1043
  program
1057
1044
  .command("export")
1058
- .description("Export a saved session to a Codex or Claude Code session.")
1059
- .argument("<session-id>", "Saved session ID")
1060
- .option("--format <name>", "codex or claude-code", "codex")
1061
- .option("-o, --output <path>", "Write to a file instead of stdout")
1045
+ .description(t("command.export"))
1046
+ .argument("<session-id>", t("argument.sessionId"))
1047
+ .option("--format <name>", t("option.format"), "codex")
1048
+ .option("-o, --output <path>", t("option.output"))
1062
1049
  .action(async (sessionId, options, command) => {
1063
1050
  await runExport(sessionId, { ...command.optsWithGlobals(), ...options });
1064
1051
  });
1065
1052
 
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}`);
1053
+ // Resolve npm's executable symlink as well as direct `node main.js` invocations.
1054
+ // Importing the session runner in tests must not start the CLI.
1055
+ const entryPath = process.argv[1]
1056
+ ? await fs.realpath(process.argv[1]).catch(() => null)
1057
+ : null;
1058
+ if (entryPath && import.meta.url === pathToFileURL(entryPath).href) {
1059
+ await 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
+ }
1067
+ console.error(error.stack ?? error.message);
1071
1068
  process.exitCode = 1;
1072
- return;
1073
- }
1074
- console.error(error.stack ?? error.message);
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
- }
1088
- });
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
+ }
1081
+ });
1082
+ }