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.
- package/README.md +2 -2
- package/docs/technical-design.md +18 -11
- package/package.json +5 -5
- package/src/browser/base-web-adapter.js +2081 -168
- package/src/browser/cdp-browser.js +223 -25
- package/src/browser/cdp-state.js +8 -5
- package/src/browser/chatgpt-web-adapter.js +799 -1
- package/src/browser/claude-web-adapter.js +13 -0
- package/src/browser/fake-web-model-adapter.js +137 -3
- package/src/browser/gemini-web-adapter.js +10 -0
- package/src/browser/glm-web-adapter.js +2 -2
- package/src/cli/i18n.js +10 -10
- package/src/cli/main.js +94 -39
- package/src/platform/command-launcher.js +1 -1
- package/src/platform/paths.js +19 -0
- package/src/platform/windows-diagnostics.js +9 -16
- package/src/policy/path-guard.js +5 -1
- package/src/runtime/agent-runtime.js +1056 -191
- package/src/session/agent-session.js +975 -120
- package/src/tools/default-tools.js +1 -1
|
@@ -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
|
-
|
|
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(
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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, чтобы
|
|
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
|
|
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
|
-
|
|
549
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
+
}
|
package/src/platform/paths.js
CHANGED
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
122
|
+
`WTAgent requires Node.js ${getPackageInfo().engines.node}; current runtime is ${version}.`,
|
|
130
123
|
);
|
|
131
124
|
}
|
|
132
125
|
|