wtagent 0.3.0 → 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.
@@ -22,6 +22,19 @@ export class ClaudeWebAdapter extends BaseWebAdapter {
22
22
  return /^\/chat\//;
23
23
  }
24
24
 
25
+ freshConversationUrlPattern() {
26
+ return /^\/new\/?$/;
27
+ }
28
+
29
+ pendingAttachmentLocators() {
30
+ return [
31
+ this.page.locator(
32
+ 'form [data-testid="file-thumbnail"], '
33
+ + 'form button[aria-label*="remove file" i]',
34
+ ),
35
+ ];
36
+ }
37
+
25
38
  composerLocators() {
26
39
  return [
27
40
  this.page.locator('[data-testid="chat-input"]'),
@@ -7,17 +7,110 @@ export class FakeWebModelAdapter {
7
7
  this.mode = null;
8
8
  this.conversationUrl = "https://chatgpt.com/";
9
9
  this.lastAssistantMessageId = null;
10
+ this.lastAssistantTurn = null;
10
11
  this.responseNumber = 0;
11
12
  this.startConversationCalls = [];
12
13
  this.startConversationOptions = [];
14
+ this.startConversationOutcome = null;
13
15
  this.sentAttachments = [];
16
+ this.sentOutboundIds = [];
17
+ this.targetId = "fake-target";
18
+ this.lastUserMessageId = null;
19
+ this.lastSendStatus = "not-submitted";
20
+ this.conversationIdentityListener = null;
21
+ this.pendingOutboundRecoverySupported = true;
22
+ this.recoveryTargetCalls = [];
23
+ this.reconciliationCalls = [];
24
+ this.reconciliationOutcome = null;
14
25
  // Records window-state calls so tests can assert restore/minimize ordering.
15
26
  this.windowStateCalls = [];
16
27
  }
17
28
 
18
- async launch(preferredUrl = null) {
29
+ async launch(preferredUrl = null, options = {}) {
19
30
  this.launched = true;
20
31
  this.lastLaunchUrl = preferredUrl;
32
+ this.lastLaunchOptions = options;
33
+ }
34
+
35
+ supportsPendingOutboundRecovery() {
36
+ return this.pendingOutboundRecoverySupported;
37
+ }
38
+
39
+ async launchRecoveryTarget(targetId) {
40
+ this.launched = true;
41
+ this.recoveryTargetCalls.push(targetId);
42
+ if (!targetId || targetId !== this.targetId) {
43
+ const error = new Error("The fake recovery target is unavailable.");
44
+ error.code = "RECOVERY_TARGET_UNAVAILABLE";
45
+ throw error;
46
+ }
47
+ return {
48
+ conversationUrl: this.conversationUrl,
49
+ targetId,
50
+ };
51
+ }
52
+
53
+ async reconcilePendingOutbound(options = {}) {
54
+ this.reconciliationCalls.push(options);
55
+ const outcome = typeof this.reconciliationOutcome === "function"
56
+ ? await this.reconciliationOutcome(options)
57
+ : this.reconciliationOutcome;
58
+ if (!outcome) {
59
+ const error = new Error("The fake pending outbound remains uncertain.");
60
+ error.code = "OUTBOUND_COMMIT_UNCERTAIN";
61
+ throw error;
62
+ }
63
+ if (outcome.conversationUrl) {
64
+ this.conversationUrl = outcome.conversationUrl;
65
+ }
66
+ if (outcome.conversationTargetId) {
67
+ this.targetId = outcome.conversationTargetId;
68
+ }
69
+ this.lastUserMessageId = outcome.userMessageId ?? this.lastUserMessageId;
70
+ if (outcome.status === "complete") {
71
+ this.lastAssistantMessageId = outcome.assistantMessageId
72
+ ?? this.lastAssistantMessageId;
73
+ this.lastAssistantTurn = outcome.assistantTurn ?? this.lastAssistantTurn;
74
+ }
75
+ return outcome;
76
+ }
77
+
78
+ classifyConversationUrl(value) {
79
+ try {
80
+ const url = new URL(value);
81
+ if (url.protocol !== "https:" || url.origin !== "https://chatgpt.com") {
82
+ return "invalid";
83
+ }
84
+ if (/^\/c\/WEB:/.test(url.pathname)) {
85
+ return "provisional";
86
+ }
87
+ if (/^\/c\//.test(url.pathname)) {
88
+ return "restorable";
89
+ }
90
+ return url.pathname === "/" ? "fresh" : "unknown";
91
+ } catch {
92
+ return "invalid";
93
+ }
94
+ }
95
+
96
+ setConversationIdentityListener(listener) {
97
+ this.conversationIdentityListener = listener;
98
+ }
99
+
100
+ getConversationIdentity() {
101
+ return {
102
+ conversationUrl: this.conversationUrl,
103
+ targetId: this.targetId,
104
+ kind: this.classifyConversationUrl(this.conversationUrl),
105
+ };
106
+ }
107
+
108
+ getLastSendStatus() {
109
+ return this.lastSendStatus;
110
+ }
111
+
112
+ async getLastUserMessageId() {
113
+ return this.lastUserMessageId;
21
114
  }
22
115
 
23
116
  async close() {
@@ -46,6 +139,19 @@ export class FakeWebModelAdapter {
46
139
  } else {
47
140
  this.conversationUrl = "https://chatgpt.com/";
48
141
  }
142
+ const configuredOutcome = typeof this.startConversationOutcome === "function"
143
+ ? await this.startConversationOutcome(conversationUrl, options)
144
+ : this.startConversationOutcome;
145
+ const isConversation = /^\/c\//.test(new URL(this.conversationUrl).pathname);
146
+ const outcome = configuredOutcome ?? {
147
+ status: isConversation ? "restored-existing" : "verified-fresh",
148
+ conversationUrl: this.conversationUrl,
149
+ targetId: this.targetId,
150
+ };
151
+ if (outcome.conversationUrl) {
152
+ this.conversationUrl = outcome.conversationUrl;
153
+ }
154
+ return outcome;
49
155
  }
50
156
 
51
157
  async selectMode(mode) {
@@ -63,13 +169,36 @@ export class FakeWebModelAdapter {
63
169
  return this.conversationUrl;
64
170
  }
65
171
 
66
- async sendMessage(text, { files = [] } = {}) {
172
+ async sendMessage(text, { files = [], outboundId = null } = {}) {
173
+ const priorUserMessageId = this.lastUserMessageId;
174
+ const priorAssistantMessageId = this.lastAssistantMessageId;
175
+ this.lastSendStatus = "commit-unknown";
67
176
  this.sentMessages.push(text);
68
177
  this.sentAttachments.push(files);
178
+ this.sentOutboundIds.push(outboundId);
69
179
  if (this.conversationUrl === "https://chatgpt.com/") {
70
180
  this.conversationUrl = "https://chatgpt.com/c/fake";
71
181
  }
72
- return { attachment: files.length ? { attached: files, failed: [] } : null };
182
+ this.lastSendStatus = "confirmed";
183
+ this.lastUserMessageId = `user-${this.sentMessages.length}`;
184
+ await this.conversationIdentityListener?.(this.getConversationIdentity());
185
+ return {
186
+ attachment: files.length ? { attached: files, failed: [] } : null,
187
+ userMessageId: this.lastUserMessageId,
188
+ userTurn: (this.sentMessages.length * 2) - 1,
189
+ conversationUrl: this.conversationUrl,
190
+ conversationTargetId: this.targetId,
191
+ assistantBaseline: {
192
+ ids: priorAssistantMessageId ? [priorAssistantMessageId] : [],
193
+ count: priorAssistantMessageId ? 1 : 0,
194
+ maxTurn: priorAssistantMessageId ? (this.responseNumber * 2) : null,
195
+ lastText: "",
196
+ },
197
+ preOutboundMarkerIds: [
198
+ priorUserMessageId,
199
+ priorAssistantMessageId,
200
+ ].filter(Boolean),
201
+ };
73
202
  }
74
203
 
75
204
  async waitForTurnComplete({ onDelta } = {}) {
@@ -79,6 +208,7 @@ export class FakeWebModelAdapter {
79
208
  const response = this.responses.shift();
80
209
  this.responseNumber += 1;
81
210
  this.lastAssistantMessageId = `assistant-${this.responseNumber}`;
211
+ this.lastAssistantTurn = this.responseNumber * 2;
82
212
  if (response instanceof Error) {
83
213
  throw response;
84
214
  }
@@ -89,4 +219,8 @@ export class FakeWebModelAdapter {
89
219
  async getLastAssistantMessageId() {
90
220
  return this.lastAssistantMessageId;
91
221
  }
222
+
223
+ async getLastAssistantTurn() {
224
+ return this.lastAssistantTurn;
225
+ }
92
226
  }
@@ -25,6 +25,16 @@ export class GeminiWebAdapter extends BaseWebAdapter {
25
25
  return /^\/app\/[^/]+/;
26
26
  }
27
27
 
28
+ pendingAttachmentLocators() {
29
+ return [
30
+ this.page.locator(
31
+ '[data-test-id="textarea-wrapper"] .attachment-container, '
32
+ + '[data-test-id="textarea-wrapper"] '
33
+ + 'button[aria-label*="remove" i]',
34
+ ),
35
+ ];
36
+ }
37
+
28
38
  composerLocators() {
29
39
  return [
30
40
  this.page.locator(
@@ -152,8 +152,8 @@ export class GLMWebAdapter extends BaseWebAdapter {
152
152
  return 5;
153
153
  }
154
154
 
155
- sentUserWaitAttempts() {
156
- return 120;
155
+ sendConfirmationTimeoutMs() {
156
+ return 30_000;
157
157
  }
158
158
 
159
159
  }
package/src/cli/i18n.js CHANGED
@@ -68,7 +68,7 @@ const MESSAGES = Object.freeze({
68
68
  "session.savedAt": "Session saved at: {directory}",
69
69
  "session.ctrlC": "Ctrl+C — cancelling. Press again to force quit.",
70
70
  "session.cancelledHint": "Turn cancelled. Type a new message or quit.",
71
- "session.recoveryHint": "Type /retry to ask {provider} to continue again, enter a new instruction, or quit",
71
+ "session.recoveryHint": "Type /retry to recover the pending {provider} turn, or quit",
72
72
  "session.processesRunning": "Managed processes still running:",
73
73
  "session.resumeWith": "Resume this conversation with: wtagent resume {sessionId}",
74
74
  "attachment.attaching": "Attaching: {files}",
@@ -154,7 +154,7 @@ const MESSAGES = Object.freeze({
154
154
  "session.savedAt": "会话已保存到:{directory}",
155
155
  "session.ctrlC": "Ctrl+C — 正在取消。再次按下可强制退出。",
156
156
  "session.cancelledHint": "本轮已取消。请输入新消息或退出。",
157
- "session.recoveryHint": "输入 /retry 让 {provider} 再次继续,或输入新指令,也可以退出",
157
+ "session.recoveryHint": "输入 /retry 恢复待处理的 {provider} 回合,或退出",
158
158
  "session.processesRunning": "仍在运行的托管进程:",
159
159
  "session.resumeWith": "继续此对话:wtagent resume {sessionId}",
160
160
  "attachment.attaching": "正在附加:{files}",
@@ -241,7 +241,7 @@ const MESSAGES = Object.freeze({
241
241
  "session.savedAt": "工作階段已儲存到:{directory}",
242
242
  "session.ctrlC": "Ctrl+C — 正在取消。再次按下可強制退出。",
243
243
  "session.cancelledHint": "本輪已取消。請輸入新訊息或退出。",
244
- "session.recoveryHint": "輸入 /retry 讓 {provider} 再次繼續,或輸入新指令,也可以退出",
244
+ "session.recoveryHint": "輸入 /retry 恢復待處理的 {provider} 回合,或退出",
245
245
  "session.processesRunning": "仍在執行的受管程序:",
246
246
  "session.resumeWith": "繼續此對話:wtagent resume {sessionId}",
247
247
  "attachment.attaching": "正在附加:{files}",
@@ -327,7 +327,7 @@ const MESSAGES = Object.freeze({
327
327
  "session.savedAt": "セッション保存先: {directory}",
328
328
  "session.ctrlC": "Ctrl+C — キャンセル中です。もう一度押すと強制終了します。",
329
329
  "session.cancelledHint": "このターンはキャンセルされました。新しいメッセージを入力するか終了してください。",
330
- "session.recoveryHint": "/retry で {provider} に再度続行させるか、新しい指示を入力するか、終了してください",
330
+ "session.recoveryHint": "/retry で保留中の {provider} ターンを復旧するか、終了してください",
331
331
  "session.processesRunning": "管理対象プロセスが実行中です:",
332
332
  "session.resumeWith": "この会話を再開: wtagent resume {sessionId}",
333
333
  "attachment.attaching": "添付中: {files}",
@@ -413,7 +413,7 @@ const MESSAGES = Object.freeze({
413
413
  "session.savedAt": "세션 저장 위치: {directory}",
414
414
  "session.ctrlC": "Ctrl+C — 취소 중입니다. 다시 누르면 강제 종료합니다.",
415
415
  "session.cancelledHint": "이번 턴이 취소되었습니다. 새 메시지를 입력하거나 종료하세요.",
416
- "session.recoveryHint": "/retry로 {provider}에 다시 계속하도록 요청하거나 새 지시를 입력하거나 종료하세요",
416
+ "session.recoveryHint": "/retry로 보류 중인 {provider} 턴을 복구하거나 종료하세요",
417
417
  "session.processesRunning": "관리 중인 프로세스가 실행 중입니다:",
418
418
  "session.resumeWith": "이 대화 재개: wtagent resume {sessionId}",
419
419
  "attachment.attaching": "첨부 중: {files}",
@@ -499,7 +499,7 @@ const MESSAGES = Object.freeze({
499
499
  "session.savedAt": "Sesión guardada en: {directory}",
500
500
  "session.ctrlC": "Ctrl+C — cancelando. Pulsa de nuevo para forzar la salida.",
501
501
  "session.cancelledHint": "Turno cancelado. Escribe un nuevo mensaje o sal.",
502
- "session.recoveryHint": "Escribe /retry para pedir a {provider} que continúe de nuevo, introduce otra instrucción o sal",
502
+ "session.recoveryHint": "Escribe /retry para recuperar el turno pendiente de {provider}, o sal",
503
503
  "session.processesRunning": "Procesos administrados aún en ejecución:",
504
504
  "session.resumeWith": "Reanuda esta conversación con: wtagent resume {sessionId}",
505
505
  "attachment.attaching": "Adjuntando: {files}",
@@ -585,7 +585,7 @@ const MESSAGES = Object.freeze({
585
585
  "session.savedAt": "Session enregistrée dans : {directory}",
586
586
  "session.ctrlC": "Ctrl+C — annulation en cours. Appuyez encore pour forcer l’arrêt.",
587
587
  "session.cancelledHint": "Tour annulé. Saisissez un nouveau message ou quittez.",
588
- "session.recoveryHint": "Saisissez /retry pour demander à {provider} de continuer, entrez une nouvelle instruction ou quittez",
588
+ "session.recoveryHint": "Saisissez /retry pour récupérer le tour {provider} en attente, ou quittez",
589
589
  "session.processesRunning": "Processus gérés encore actifs :",
590
590
  "session.resumeWith": "Reprendre cette conversation avec : wtagent resume {sessionId}",
591
591
  "attachment.attaching": "Ajout de pièces jointes : {files}",
@@ -671,7 +671,7 @@ const MESSAGES = Object.freeze({
671
671
  "session.savedAt": "Sitzung gespeichert unter: {directory}",
672
672
  "session.ctrlC": "Ctrl+C — wird abgebrochen. Erneut drücken, um das Beenden zu erzwingen.",
673
673
  "session.cancelledHint": "Durchgang abgebrochen. Neue Nachricht eingeben oder beenden.",
674
- "session.recoveryHint": "Gib /retry ein, um {provider} erneut fortsetzen zu lassen, gib eine neue Anweisung ein oder beende",
674
+ "session.recoveryHint": "Gib /retry ein, um den ausstehenden {provider}-Durchlauf wiederherzustellen, oder beende",
675
675
  "session.processesRunning": "Verwaltete Prozesse laufen noch:",
676
676
  "session.resumeWith": "Diese Unterhaltung fortsetzen mit: wtagent resume {sessionId}",
677
677
  "attachment.attaching": "Anhängen: {files}",
@@ -757,7 +757,7 @@ const MESSAGES = Object.freeze({
757
757
  "session.savedAt": "Sessão salva em: {directory}",
758
758
  "session.ctrlC": "Ctrl+C — cancelando. Pressione novamente para forçar a saída.",
759
759
  "session.cancelledHint": "Turno cancelado. Digite uma nova mensagem ou saia.",
760
- "session.recoveryHint": "Digite /retry para pedir que {provider} continue novamente, insira uma nova instrução ou saia",
760
+ "session.recoveryHint": "Digite /retry para recuperar o turno pendente do {provider}, ou saia",
761
761
  "session.processesRunning": "Processos gerenciados ainda em execução:",
762
762
  "session.resumeWith": "Retome esta conversa com: wtagent resume {sessionId}",
763
763
  "attachment.attaching": "Anexando: {files}",
@@ -843,7 +843,7 @@ const MESSAGES = Object.freeze({
843
843
  "session.savedAt": "Сессия сохранена в: {directory}",
844
844
  "session.ctrlC": "Ctrl+C — отмена. Нажмите ещё раз для принудительного выхода.",
845
845
  "session.cancelledHint": "Ход отменён. Введите новое сообщение или выйдите.",
846
- "session.recoveryHint": "Введите /retry, чтобы снова попросить {provider} продолжить, введите новую инструкцию или выйдите",
846
+ "session.recoveryHint": "Введите /retry, чтобы восстановить ожидающий ход {provider}, или выйдите",
847
847
  "session.processesRunning": "Управляемые процессы всё ещё запущены:",
848
848
  "session.resumeWith": "Продолжить этот диалог: wtagent resume {sessionId}",
849
849
  "attachment.attaching": "Прикрепление: {files}",
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";
@@ -15,6 +16,7 @@ import {
15
16
  } from "../browser/provider-registry.js";
16
17
  import {
17
18
  ensureDirectory,
19
+ formatHomePath,
18
20
  getAppDataDir,
19
21
  getSessionsDir,
20
22
  getTasksDir,
@@ -32,6 +34,7 @@ import { ApprovalStore } from "../policy/approval-store.js";
32
34
  import { createDefaultToolRegistry } from "../tools/default-tools.js";
33
35
  import { ProcessManager } from "../tools/process-manager.js";
34
36
  import { resolveLimits } from "../shared/limits.js";
37
+ import { BrowserAdapterError } from "../shared/errors.js";
35
38
  import { EXPORTERS } from "../session/session-export.js";
36
39
  import { getPackageVersion } from "../shared/package-info.js";
37
40
  import { extractAtMentions } from "./at-files.js";
@@ -301,13 +304,13 @@ class ConversationRunner {
301
304
  await this.approvalStore.save();
302
305
  console.log(t("approval.savedTool", {
303
306
  tool: toolCall.name,
304
- file: this.approvalStore.filePath,
307
+ file: formatHomePath(this.approvalStore.filePath),
305
308
  }));
306
309
  } else if (choice === "always-all") {
307
310
  this.approvalStore.setAlwaysAllowAll();
308
311
  await this.approvalStore.save();
309
312
  console.log(t("approval.savedAll", {
310
- file: this.approvalStore.filePath,
313
+ file: formatHomePath(this.approvalStore.filePath),
311
314
  }));
312
315
  }
313
316
  return true;
@@ -435,8 +438,14 @@ async function waitForProcessToExit(pid, timeoutMs = 60_000) {
435
438
  // session, ask the user how to proceed instead of failing outright: kill the
436
439
  // other session, or have the user close its Chrome window so that session
437
440
  // fails and releases the profile. Returns true when the turn should be retried.
438
- async function promptToResolveProfileLock(error) {
441
+ export async function promptToResolveProfileLock(error) {
439
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
+ }
440
449
  console.log(`\n${"\x1b[33m"}${error.message}${"\x1b[0m"}`);
441
450
  const choice = await promptForSelect({
442
451
  message: t("profileLock.how"),
@@ -470,7 +479,7 @@ async function promptToResolveProfileLock(error) {
470
479
  return true;
471
480
  }
472
481
 
473
- async function executeSession({
482
+ export async function executeSession({
474
483
  session,
475
484
  options,
476
485
  resume = false,
@@ -478,11 +487,14 @@ async function executeSession({
478
487
  files = [],
479
488
  chatInput = null,
480
489
  mode = null,
481
- }) {
490
+ }, {
491
+ interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY,
492
+ createRunner = (config) => new ConversationRunner(config),
493
+ readNextMessage = promptForNextMessage,
494
+ } = {}) {
482
495
  const paths = resolveRuntimePaths(options);
483
496
  await ensureDirectory(paths.sessionsDir);
484
- const interactive = !options.once && process.stdin.isTTY && process.stdout.isTTY;
485
- const runner = new ConversationRunner({ session, options, interactive });
497
+ const runner = createRunner({ session, options, interactive });
486
498
 
487
499
  const onInterrupt = () => {
488
500
  if (runner.interrupted) {
@@ -545,9 +557,8 @@ async function executeSession({
545
557
  throw result.error;
546
558
  }
547
559
  runner.renderer.hint(t("session.cancelledHint"));
548
- turnResume = true;
549
- turnInPlaceRecovery = false;
550
- continue;
560
+ // Fall through to the same input prompt as a completed turn. Never
561
+ // resume automatically or reuse the cancelled instruction/attachments.
551
562
  }
552
563
  if (result?.recoveryRequired) {
553
564
  if (!interactive) {
@@ -558,11 +569,30 @@ async function executeSession({
558
569
  provider: runner.renderer.providerLabel,
559
570
  }),
560
571
  );
561
- 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
+ }
562
593
  if (next == null) {
563
594
  break;
564
595
  }
565
- const retryOnly = next.text.trim().toLowerCase() === "/retry";
566
596
  if (!retryOnly) {
567
597
  await session.appendInstruction(next.text, { files: next.files });
568
598
  }
@@ -593,7 +623,7 @@ async function executeSession({
593
623
  }
594
624
  }
595
625
 
596
- const next = await promptForNextMessage(runner, activeChatInput);
626
+ const next = await readNextMessage(runner, activeChatInput);
597
627
  if (next == null) {
598
628
  break;
599
629
  }
@@ -630,7 +660,11 @@ function printResumeHint(sessionId) {
630
660
  // re-prompts. Returns null for an explicit exit command, Ctrl+C, Ctrl+D, or EOF;
631
661
  // otherwise returns
632
662
  // { text, files } where files are resolved @file attachments.
633
- async function promptForNextMessage(runner, chatInput) {
663
+ async function promptForNextMessage(
664
+ runner,
665
+ chatInput,
666
+ { resolveAttachments = true } = {},
667
+ ) {
634
668
  for (;;) {
635
669
  const answer = chatInput
636
670
  ? await chatInput.read()
@@ -651,7 +685,9 @@ async function promptForNextMessage(runner, chatInput) {
651
685
  return null;
652
686
  }
653
687
  const { text } = classified;
654
- const files = await resolveMessageAttachments(runner, text);
688
+ const files = resolveAttachments
689
+ ? await resolveMessageAttachments(runner, text)
690
+ : [];
655
691
  return { text, files };
656
692
  }
657
693
  }
@@ -782,7 +818,7 @@ function printChatBanner(projectRoot, provider) {
782
818
  const DIM = "\x1b[2m";
783
819
  const RESET = "\x1b[0m";
784
820
  console.log("");
785
- console.log(`${CYAN}WTAgent${RESET} ${DIM}· ${provider.label} · ${projectRoot}${RESET}`);
821
+ console.log(`${CYAN}WTAgent${RESET} ${DIM}· ${provider.label} · ${formatHomePath(projectRoot)}${RESET}`);
786
822
  console.log(`${DIM}${t("banner.controls")}${RESET}`);
787
823
  console.log("");
788
824
  }
@@ -812,6 +848,19 @@ async function runResume(sessionId, instructionParts, options) {
812
848
  await ensureDirectory(paths.sessionsDir);
813
849
  const session = await loadSession(paths, sessionId);
814
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
+ }
815
864
 
816
865
  const provider = resolveProvider(session.state.provider ?? DEFAULT_PROVIDER);
817
866
  // A conversation belongs to one provider. --model on resume is only allowed
@@ -828,7 +877,6 @@ async function runResume(sessionId, instructionParts, options) {
828
877
  return null;
829
878
  }
830
879
 
831
- const instruction = instructionParts.join(" ").trim();
832
880
  let files = [];
833
881
  if (instruction) {
834
882
  const projectRoot = session.state.projectRoot;
@@ -1002,26 +1050,33 @@ program
1002
1050
  await runExport(sessionId, { ...command.optsWithGlobals(), ...options });
1003
1051
  });
1004
1052
 
1005
- program.parseAsync().catch((error) => {
1006
- // Expected, actionable failures carry a plain message instead of a stack
1007
- // trace (e.g. the provider's Chrome profile is locked by another session).
1008
- if (error?.code === "PROFILE_LOCKED") {
1009
- 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);
1010
1068
  process.exitCode = 1;
1011
- return;
1012
- }
1013
- console.error(error.stack ?? error.message);
1014
- process.exitCode = 1;
1015
- }).finally(() => {
1016
- // By the time every command finishes, all per-command cleanup (browser
1017
- // detach/close, stdin raw-mode restore, process stop) has already run. A
1018
- // stray tty handle (e.g. a keypress-machinery listener left behind on a
1019
- // failed turn) can still pin the event loop, leaving a dead prompt that
1020
- // never returns to the shell — exit hard instead of relying on the loop
1021
- // to drain naturally.
1022
- if (process.exitCode != null && process.exitCode !== 0) {
1023
- process.stdin.setRawMode?.(false);
1024
- process.stdin.pause?.();
1025
- process.exit(process.exitCode);
1026
- }
1027
- });
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
+ }
@@ -178,7 +178,7 @@ function buildBatchCommand(scriptPath, argv, {
178
178
  export function resolveLaunchPlan({
179
179
  program,
180
180
  argv = [],
181
- cwd,
181
+ cwd = process.cwd(),
182
182
  env = process.env,
183
183
  platform = process.platform,
184
184
  existsSync = fs.existsSync,
@@ -7,6 +7,25 @@ import { getEnvCaseInsensitive } from "./command-launcher.js";
7
7
  const APP_NAME = "wtagent";
8
8
  const LEGACY_APP_NAME = "webagent";
9
9
 
10
+ export function formatHomePath(filePath, {
11
+ platform = process.platform,
12
+ homeDir = os.homedir(),
13
+ } = {}) {
14
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
15
+ if (!pathApi.isAbsolute(filePath)) {
16
+ return filePath;
17
+ }
18
+ const relative = pathApi.relative(homeDir, filePath);
19
+ if (relative === "") {
20
+ return "~";
21
+ }
22
+ if (relative === ".." || relative.startsWith(`..${pathApi.sep}`)
23
+ || pathApi.isAbsolute(relative)) {
24
+ return filePath;
25
+ }
26
+ return `~${pathApi.sep}${relative}`;
27
+ }
28
+
10
29
  function platformAppDataDir(appName, {
11
30
  env,
12
31
  platform,
@@ -8,11 +8,12 @@ import { promisify } from "node:util";
8
8
  import { discoverChromeExecutable } from "./chrome-discovery.js";
9
9
  import { resolveLaunchPlan } from "./command-launcher.js";
10
10
  import { inspectCdpProfileState } from "../browser/cdp-state.js";
11
+ import { getPackageInfo } from "../shared/package-info.js";
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
 
14
15
  function parseMajorMinorPatch(version) {
15
- const match = String(version ?? "").replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
16
+ const match = String(version ?? "").replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)$/);
16
17
  if (!match) {
17
18
  return null;
18
19
  }
@@ -24,20 +25,12 @@ export function isSupportedNodeVersion(version = process.version) {
24
25
  if (!parsed) {
25
26
  return false;
26
27
  }
27
- const [major, minor, patch] = parsed;
28
- if (major > 20) {
29
- return true;
30
- }
31
- if (major < 20) {
32
- return false;
33
- }
34
- if (minor > 17) {
35
- return true;
36
- }
37
- if (minor < 17) {
38
- return false;
39
- }
40
- return patch >= 0;
28
+ const [major, minor] = parsed;
29
+ // Keep aligned with package.json and Inquirer's supported Node releases.
30
+ return (major === 20 && minor >= 17)
31
+ || (major === 22 && minor >= 13)
32
+ || (major === 23 && minor >= 5)
33
+ || major >= 24;
41
34
  }
42
35
 
43
36
  export function detectWsl({
@@ -126,7 +119,7 @@ export function assertNativeRuntimeSupported(context = {}) {
126
119
  const version = context.version ?? process.version;
127
120
  if (!isSupportedNodeVersion(version)) {
128
121
  throw new Error(
129
- `WTAgent requires Node.js 20.17.0 or newer; current runtime is ${version}.`,
122
+ `WTAgent requires Node.js ${getPackageInfo().engines.node}; current runtime is ${version}.`,
130
123
  );
131
124
  }
132
125