wtagent 0.2.1 → 0.3.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.
@@ -8,6 +8,8 @@
8
8
  // A single Renderer instance is shared across every turn of a conversation so
9
9
  // spinner state and per-tool timing survive between the model's replies.
10
10
 
11
+ import { t } from "./i18n.js";
12
+
11
13
  const DIM = "\x1b[2m";
12
14
  const BOLD = "\x1b[1m";
13
15
  const CYAN = "\x1b[36m";
@@ -109,7 +111,7 @@ export class Renderer {
109
111
  }
110
112
  this.spinner = {
111
113
  kind: "processing",
112
- label: "processing request",
114
+ label: t("renderer.processing"),
113
115
  start: Date.now(),
114
116
  bytes: 0,
115
117
  };
@@ -119,7 +121,7 @@ export class Renderer {
119
121
  beginRunning(name) {
120
122
  this.spinner = {
121
123
  kind: "running",
122
- label: `running ${name}`,
124
+ label: t("renderer.running", { tool: name }),
123
125
  start: Date.now(),
124
126
  bytes: 0,
125
127
  };
@@ -235,7 +237,7 @@ export class Renderer {
235
237
  answer(message) {
236
238
  this.stopSpinner();
237
239
  this.println("");
238
- this.println(`${GREEN}${BOLD}⏺ assistant${RESET}`);
240
+ this.println(`${GREEN}${BOLD}⏺ ${t("renderer.assistant")}${RESET}`);
239
241
  for (const line of String(message ?? "").split(/\r?\n/)) {
240
242
  this.println(line);
241
243
  }
@@ -260,36 +262,25 @@ export class Renderer {
260
262
  break;
261
263
  }
262
264
  this.lifecycleShown.add(type);
263
- this.status("Chrome started.");
265
+ this.status(t("renderer.chromeStarted"));
264
266
  break;
265
267
  case "browser.auth_required":
266
268
  this.stopSpinner();
267
- this.println(`${YELLOW}Log in to ${this.providerLabel} in the opened Chrome window…${RESET}`);
269
+ this.println(
270
+ `${YELLOW}${t("renderer.loginRequired", { provider: this.providerLabel })}${RESET}`,
271
+ );
268
272
  break;
269
273
  case "browser.authenticated":
270
- this.println(`${GREEN}${this.providerLabel} login detected.${RESET}`);
271
- break;
272
- case "conversation.mode_selected": {
273
- const { requested, status, selectedLabel } = payload;
274
- if (status === "select" || status === "already") {
275
- this.status(`Mode: ${selectedLabel ?? requested}.`);
276
- } else if (status === "fallback") {
277
- this.note(`Mode: ${requested} unavailable; using ${selectedLabel}.`);
278
- } else {
279
- this.note(`Mode: could not select ${requested}; continuing on current mode.`);
280
- }
274
+ this.println(
275
+ `${GREEN}${t("renderer.loginDetected", { provider: this.providerLabel })}${RESET}`,
276
+ );
281
277
  break;
282
- }
283
278
  case "conversation.started":
284
279
  if (this.lifecycleShown.has(type)) {
285
280
  break;
286
281
  }
287
282
  this.lifecycleShown.add(type);
288
- if (payload.mode) {
289
- this.status(`Conversation ready (${payload.mode}).`);
290
- } else {
291
- this.status("Conversation ready (current mode).");
292
- }
283
+ this.status(t("renderer.conversationReady"));
293
284
  break;
294
285
  case "model.message_sent":
295
286
  this.beginProcessing();
@@ -305,18 +296,29 @@ export class Renderer {
305
296
  this.stopSpinner();
306
297
  this.note(
307
298
  payload.deadRequest
308
- ? `no reply from ${this.providerLabel}; asking it to continue (${payload.retry}/${payload.maxRetries})`
299
+ ? t("renderer.noReply", {
300
+ provider: this.providerLabel,
301
+ retry: payload.retry,
302
+ maxRetries: payload.maxRetries,
303
+ })
309
304
  : payload.generationFailed
310
- ? `${this.providerLabel} generation failed (server error); asking it to retry (${payload.retry}/${payload.maxRetries})`
311
- : `empty ${this.providerLabel} response; asking it to continue (${payload.retry}/${payload.maxRetries})`,
305
+ ? t("renderer.generationFailed", {
306
+ provider: this.providerLabel,
307
+ retry: payload.retry,
308
+ maxRetries: payload.maxRetries,
309
+ })
310
+ : t("renderer.emptyResponse", {
311
+ provider: this.providerLabel,
312
+ retry: payload.retry,
313
+ maxRetries: payload.maxRetries,
314
+ }),
312
315
  );
313
316
  break;
314
317
  case "model.limit_reached":
315
318
  this.stopSpinner();
316
- this.note(
317
- `${this.providerLabel} usage limit reached. Wait for the limit to reset, `
318
- + "try a different mode on resume, or change plans.",
319
- );
319
+ this.note(t("renderer.limitReached", {
320
+ provider: this.providerLabel,
321
+ }));
320
322
  break;
321
323
  case "model.progress":
322
324
  this.stopSpinner();
@@ -329,7 +331,7 @@ export class Renderer {
329
331
  break;
330
332
  case "protocol.plain_answer":
331
333
  this.stopSpinner();
332
- this.note("The model answered without the XML protocol; showing the reply as the final answer.");
334
+ this.note(t("renderer.plainAnswer"));
333
335
  break;
334
336
  case "tool.proposed":
335
337
  this.stopSpinner();
@@ -361,21 +363,25 @@ export class Renderer {
361
363
  this.stopSpinner();
362
364
  if (!payload.id || !this.handled.has(payload.id)) {
363
365
  if (payload.id) this.handled.add(payload.id);
364
- this.println(`${this.#hdr()} ${DIM}↺ reused prior result for ${payload.name}${RESET}`);
366
+ this.println(
367
+ `${this.#hdr()} ${DIM}↺ ${t("renderer.reused", { tool: payload.name })}${RESET}`,
368
+ );
365
369
  }
366
370
  break;
367
371
  case "tool.reused_unknown":
368
372
  this.stopSpinner();
369
373
  if (!payload.id || !this.handled.has(payload.id)) {
370
374
  if (payload.id) this.handled.add(payload.id);
371
- this.println(`${this.#hdr()} ${YELLOW}↺ reused (completion unknown) for ${payload.name}${RESET}`);
375
+ this.println(
376
+ `${this.#hdr()} ${YELLOW}↺ ${t("renderer.reusedUnknown", { tool: payload.name })}${RESET}`,
377
+ );
372
378
  }
373
379
  break;
374
380
  case "tool.conflict":
375
381
  this.stopSpinner();
376
382
  if (!payload.id || !this.handled.has(payload.id)) {
377
383
  if (payload.id) this.handled.add(payload.id);
378
- this.note(`conflicting reuse for ${payload.name}`);
384
+ this.note(t("renderer.conflictingReuse", { tool: payload.name }));
379
385
  }
380
386
  break;
381
387
  case "run.completed":
@@ -383,12 +389,16 @@ export class Renderer {
383
389
  break;
384
390
  case "run.interrupted":
385
391
  this.stopSpinner();
386
- this.println(`${YELLOW}Run interrupted: ${truncate(payload.message, 160)}${RESET}`);
392
+ this.println(`${YELLOW}${t("renderer.interrupted", {
393
+ message: truncate(payload.message, 160),
394
+ })}${RESET}`);
387
395
  break;
388
396
  case "run.recovery_required":
389
397
  this.stopSpinner();
390
398
  this.println(
391
- `${YELLOW}${truncate(payload.message, 180)} The session and Chrome window remain open.${RESET}`,
399
+ `${YELLOW}${t("renderer.recoveryOpen", {
400
+ message: truncate(payload.message, 180),
401
+ })}${RESET}`,
392
402
  );
393
403
  break;
394
404
  case "tool.result_sent":
@@ -6,12 +6,14 @@ import { getPackageName, getPackageVersion } from "../shared/package-info.js";
6
6
  export const UPDATE_CHECK_TIMEOUT_MS = NETWORK_TIMEOUT_MS;
7
7
  export const UPDATE_COMMAND_TIMEOUT_MS = 10_000;
8
8
  export const INSTALL_TIMEOUT_MS = 120_000;
9
- export const MANUAL_INSTALL_COMMAND = "npm install -g wtagent@latest";
9
+ export const OFFICIAL_NPM_REGISTRY = "https://registry.npmjs.org/";
10
+ export const MANUAL_INSTALL_COMMAND =
11
+ `npm install -g wtagent@latest --registry=${OFFICIAL_NPM_REGISTRY}`;
10
12
 
11
13
  const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
12
14
 
13
15
  export function npmRegistryLatestUrl(name = getPackageName()) {
14
- return `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
16
+ return `${OFFICIAL_NPM_REGISTRY}${encodeURIComponent(name)}/latest`;
15
17
  }
16
18
 
17
19
  export function parseVersion(value) {
@@ -76,7 +78,12 @@ export async function installLatest({
76
78
  timeoutMs = INSTALL_TIMEOUT_MS,
77
79
  stdio = "inherit",
78
80
  } = {}) {
79
- const plan = planCommandImpl("npm", ["install", "-g", "wtagent@latest"]);
81
+ const plan = planCommandImpl("npm", [
82
+ "install",
83
+ "-g",
84
+ "wtagent@latest",
85
+ `--registry=${OFFICIAL_NPM_REGISTRY}`,
86
+ ]);
80
87
  return await new Promise((resolve) => {
81
88
  let settled = false;
82
89
  let timer;
@@ -19,11 +19,7 @@ import {
19
19
  toolResultOutput,
20
20
  userMessage,
21
21
  } from "../session/canonical-transcript.js";
22
- import {
23
- DEFAULT_LIMITS,
24
- PRO_MODEL_TURN_TIMEOUT_MS,
25
- isProMode,
26
- } from "../shared/limits.js";
22
+ import { DEFAULT_LIMITS } from "../shared/limits.js";
27
23
  import { utf8ByteLength } from "../shared/text-budget.js";
28
24
  import {
29
25
  BrowserAdapterError,
@@ -148,6 +144,7 @@ export class AgentRuntime {
148
144
  session,
149
145
  approval,
150
146
  onEvent,
147
+ postAuthSetup = null,
151
148
  limits = DEFAULT_LIMITS,
152
149
  }) {
153
150
  this.adapter = adapter;
@@ -156,6 +153,7 @@ export class AgentRuntime {
156
153
  this.session = session;
157
154
  this.approval = approval;
158
155
  this.onEvent = onEvent;
156
+ this.postAuthSetup = postAuthSetup;
159
157
  this.limits = limits;
160
158
  }
161
159
 
@@ -239,12 +237,10 @@ export class AgentRuntime {
239
237
  instruction = null,
240
238
  files = [],
241
239
  inPlaceRecovery = false,
242
- mode = null,
243
240
  } = {}) {
244
241
  const {
245
242
  task,
246
243
  projectRoot,
247
- mode: sessionMode,
248
244
  } = this.session.state;
249
245
  const previousConversationUrl = this.session.state.conversationUrl;
250
246
  await this.session.recoverInterruptedSideEffects();
@@ -300,58 +296,25 @@ export class AgentRuntime {
300
296
  : null,
301
297
  },
302
298
  );
303
- // A fresh run uses the mode stored at session creation; a resume applies
304
- // an explicit mode override (--mode or the interactive choice) when given,
305
- // and otherwise keeps the mode the conversation is already on.
306
- const requestedMode = mode ?? sessionMode;
307
- // The actual mode may differ from the requested one (Pro limited, fallback,
308
- // or switcher not found), so report what was really selected.
309
- let activeMode = resume
310
- ? (this.session.state.activeMode ?? null)
311
- : null;
312
- const selectRequested = Boolean(
313
- requestedMode && (!resume || mode != null),
314
- );
315
- if (selectRequested) {
316
- const modeResult = await this.adapter.selectMode(requestedMode);
317
- if (modeResult) {
318
- await this.emit("conversation.mode_selected", {
319
- requested: requestedMode,
320
- status: modeResult.status,
321
- selectedLabel: modeResult.selectedLabel ?? null,
322
- attempts: modeResult.attempts ?? 0,
323
- reason: modeResult.reason ?? null,
324
- });
325
- if (modeResult.status === "select" || modeResult.status === "already") {
326
- activeMode = modeResult.selectedLabel ?? requestedMode;
327
- } else if (modeResult.status === "fallback") {
328
- activeMode = modeResult.selectedLabel ?? requestedMode;
329
- } else {
330
- // Pro not selected and no known fallback label — the real mode is
331
- // whatever ChatGPT already had, which we cannot name reliably.
332
- activeMode = null;
333
- }
334
- }
299
+
300
+ // Optional setup after authentication and the target conversation are
301
+ // ready, but before the first message is sent. Interactive CLI runs use
302
+ // this to let the user choose a model directly on the provider website.
303
+ // WTAgent never names, selects, or overrides a provider-specific model.
304
+ // Resumes skip this setup so an existing conversation is not interrupted.
305
+ if (!resume && this.postAuthSetup) {
306
+ await this.postAuthSetup({ adapter: this.adapter });
335
307
  }
336
- // ChatGPT Pro can think considerably longer before the first token;
337
- // every other provider/mode uses the default. An explicit
338
- // --model-turn-timeout-ms always wins (resolveLimits marks it). The
339
- // active mode may differ from the requested one (Pro limited, fallback),
340
- // so prefer the mode the conversation is actually on.
341
- const modelTurnTimeoutMs = !this.limits.modelTurnTimeoutExplicit
342
- && (isProMode(activeMode) || isProMode(requestedMode))
343
- ? PRO_MODEL_TURN_TIMEOUT_MS
344
- : this.limits.modelTurnTimeoutMs;
308
+
309
+ const modelTurnTimeoutMs = this.limits.modelTurnTimeoutMs;
345
310
 
346
311
  await this.session.update({
347
312
  phase: "running",
348
313
  conversationUrl: await this.adapter.getConversationUrl(),
349
- activeMode,
314
+ activeMode: null,
350
315
  });
351
316
  await this.emit("conversation.started", {
352
317
  url: this.session.state.conversationUrl,
353
- mode: activeMode,
354
- requestedMode,
355
318
  });
356
319
 
357
320
  let initialMessage;
@@ -19,15 +19,8 @@ export const DEFAULT_LIMITS = Object.freeze({
19
19
  maxSearchResults: 200,
20
20
  });
21
21
 
22
- // ChatGPT Pro plans can legitimately think longer than the default before the
23
- // first token (see the runtime's mode-aware timeout selection).
24
- export const PRO_MODEL_TURN_TIMEOUT_MS = 16 * 60_000;
25
-
26
- // The CLI's --model-turn-timeout-ms always wins over the mode-aware default.
27
- export function isProMode(mode) {
28
- return String(mode ?? "") === "Pro";
29
- }
30
-
22
+ // Model response timing is provider/model agnostic. Users who need a longer
23
+ // window can override it explicitly with --model-turn-timeout-ms.
31
24
  export function resolveLimits({ modelTurnTimeoutMs } = {}) {
32
25
  if (modelTurnTimeoutMs == null || modelTurnTimeoutMs === "") {
33
26
  return DEFAULT_LIMITS;
@@ -1,214 +0,0 @@
1
- // Language-independent model/mode selection logic for the ChatGPT web picker.
2
- //
3
- // Prefer language-independent option slugs derived from data-testid / data-* /
4
- // id. Current ChatGPT menus sometimes omit every such attribute, including on
5
- // the Pro item, so an exact normalized label match is kept as a conservative
6
- // fallback. We never use fuzzy label matching.
7
- //
8
- // This module is pure and DOM-free so it can be unit-tested without a browser.
9
- // The adapter supplies a small "port" of async DOM operations to
10
- // `runModeSelection`; all the retry / fallback policy lives here.
11
-
12
- export function slugTokens(slug) {
13
- return String(slug ?? "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
14
- }
15
-
16
- export function normalizeToken(value) {
17
- return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "");
18
- }
19
-
20
- // Does the option's stable slug encode the requested mode token?
21
- // `token` is a normalized (separator-free) string, e.g. "pro" or "extrahigh".
22
- // Short tokens (< 4 chars, e.g. "pro") require an exact whole-segment match so
23
- // "pro" does not match "provider". Longer tokens also allow a substring match
24
- // against the separator-free slug, so a multi-word mode like "Extra high"
25
- // (-> "extrahigh") matches a hyphenated slug like "o3-extra-high".
26
- export function slugMatchesToken(slug, token) {
27
- if (!token) {
28
- return false;
29
- }
30
- const tokens = slugTokens(slug);
31
- if (tokens.includes(token)) {
32
- return true;
33
- }
34
- if (token.length >= 4) {
35
- const joined = tokens.join("");
36
- return joined.includes(token) || tokens.some((candidate) => candidate.includes(token));
37
- }
38
- return false;
39
- }
40
-
41
- export function labelMatchesToken(label, token) {
42
- return String(label ?? "")
43
- .split(/\r?\n/)
44
- .some((line) => normalizeToken(line) === token);
45
- }
46
-
47
- // Given the enumerated menu options and the requested mode, decide what to do.
48
- // options: [{ index, slug, label, disabled, selected }] in DOM order.
49
- // Returns { status, targetIndex, selectedLabel, reason }.
50
- // status "select" -> requested mode is available; click it.
51
- // status "already" -> requested mode is already selected.
52
- // status "fallback" -> requested mode is limited/disabled; click
53
- // the option immediately before it.
54
- // status "unavailable" -> requested mode is not present in the menu.
55
- // status "unavailable_disabled"-> requested mode is disabled and the previous
56
- // option is not selectable either.
57
- export function chooseModeOption(options, requested) {
58
- const token = normalizeToken(requested);
59
- let proIndex = options.findIndex(
60
- (option) => slugMatchesToken(option.slug, token),
61
- );
62
- if (proIndex === -1) {
63
- proIndex = options.findIndex(
64
- (option) => labelMatchesToken(option.label, token),
65
- );
66
- }
67
-
68
- if (proIndex === -1) {
69
- return {
70
- status: "unavailable",
71
- targetIndex: null,
72
- selectedLabel: null,
73
- reason: `"${requested}" is not in the model menu.`,
74
- };
75
- }
76
-
77
- const pro = options[proIndex];
78
- if (pro.selected) {
79
- return {
80
- status: "already",
81
- targetIndex: null,
82
- selectedLabel: pro.label || requested,
83
- reason: `Already using ${requested}.`,
84
- };
85
- }
86
- if (!pro.disabled) {
87
- return {
88
- status: "select",
89
- targetIndex: proIndex,
90
- selectedLabel: pro.label || requested,
91
- reason: `Selecting ${requested}.`,
92
- };
93
- }
94
-
95
- // Pro is present but limited. Fall back to the option immediately before it.
96
- const previous = options[proIndex - 1];
97
- if (previous && !previous.disabled) {
98
- return {
99
- status: "fallback",
100
- targetIndex: proIndex - 1,
101
- selectedLabel: previous.label || `option ${proIndex}`,
102
- reason: `${requested} is limited; selecting the previous option `
103
- + `"${previous.label || proIndex - 1}" instead.`,
104
- };
105
- }
106
-
107
- return {
108
- status: "unavailable_disabled",
109
- targetIndex: null,
110
- selectedLabel: null,
111
- reason: previous
112
- ? `${requested} is limited and the previous option is also unavailable.`
113
- : `${requested} is limited and has no previous option to fall back to.`,
114
- };
115
- }
116
-
117
- // Orchestrates selection against a DOM "port". Never throws for mode issues:
118
- // it always resolves to a structured result the runtime can surface to the CLI,
119
- // so a limited/absent Pro degrades gracefully instead of aborting the run.
120
- //
121
- // port: {
122
- // alreadyOnMode(requested) -> bool,
123
- // hasSwitcher() -> bool,
124
- // openMenu(requested) -> void,
125
- // readOptions() -> [{index, slug, label, disabled, selected}],
126
- // clickOption(index) -> bool (click landed),
127
- // waitSelected(requested) -> bool (picker reflects the requested mode),
128
- // waitClosed() -> bool (menu closed after click),
129
- // closeMenu() -> void,
130
- // writeDiagnostics(label) -> void,
131
- // }
132
- export async function runModeSelection(port, requested, { maxAttempts = 2 } = {}) {
133
- if (!requested) {
134
- return { status: "skipped", requested, attempts: 0, reason: "No mode requested." };
135
- }
136
-
137
- if (await port.alreadyOnMode(requested)) {
138
- return {
139
- status: "already",
140
- requested,
141
- selectedLabel: requested,
142
- attempts: 0,
143
- reason: `Already using ${requested}.`,
144
- };
145
- }
146
-
147
- if (!await port.hasSwitcher()) {
148
- await port.writeDiagnostics("mode-switcher-not-found");
149
- return {
150
- status: "switcher_not_found",
151
- requested,
152
- attempts: 0,
153
- reason: "Model switcher was not found.",
154
- };
155
- }
156
-
157
- let lastReason = "";
158
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
159
- await port.openMenu(requested);
160
- const options = await port.readOptions();
161
-
162
- // The Radix menu populates asynchronously; an empty read is a race, retry.
163
- if (!options || options.length === 0) {
164
- lastReason = "Model menu did not populate in time.";
165
- await port.closeMenu();
166
- continue;
167
- }
168
-
169
- const choice = chooseModeOption(options, requested);
170
-
171
- if (choice.status === "already") {
172
- await port.closeMenu();
173
- return { ...choice, requested, attempts: attempt };
174
- }
175
-
176
- if (choice.status === "select" || choice.status === "fallback") {
177
- const clicked = await port.clickOption(choice.targetIndex);
178
- const expectedMode = choice.status === "fallback"
179
- ? choice.selectedLabel
180
- : requested;
181
- const selected = clicked && (
182
- typeof port.waitSelected === "function"
183
- ? await port.waitSelected(expectedMode)
184
- : await port.waitClosed()
185
- );
186
- if (selected) {
187
- return { ...choice, requested, attempts: attempt };
188
- }
189
- lastReason = "The option click did not change the selected mode.";
190
- await port.closeMenu();
191
- continue;
192
- }
193
-
194
- await port.closeMenu();
195
-
196
- // A genuinely absent Pro might still be populating on the first pass, so
197
- // retry once; a disabled Pro will not change, so report immediately.
198
- if (choice.status === "unavailable" && attempt < maxAttempts) {
199
- lastReason = choice.reason;
200
- continue;
201
- }
202
-
203
- await port.writeDiagnostics(`mode-${requested}-${choice.status}`);
204
- return { ...choice, requested, attempts: attempt };
205
- }
206
-
207
- await port.writeDiagnostics(`mode-${requested}-unresolved`);
208
- return {
209
- status: "unresolved",
210
- requested,
211
- attempts: maxAttempts,
212
- reason: lastReason || `Could not select ${requested}.`,
213
- };
214
- }
@@ -1,25 +0,0 @@
1
- export const CHATGPT_MODE_CHOICES = Object.freeze([
2
- Object.freeze({
3
- name: "Pro — select Pro for this conversation",
4
- value: "pro",
5
- }),
6
- Object.freeze({
7
- name: "Current — keep the current ChatGPT setting",
8
- value: "current",
9
- }),
10
- ]);
11
-
12
- export function normalizeConfiguredMode(value) {
13
- const configured = String(value ?? "").trim();
14
- if (/^current$/i.test(configured)) {
15
- return null;
16
- }
17
- if (/^pro$/i.test(configured)) {
18
- return "Pro";
19
- }
20
- throw new Error('ChatGPT mode must be either "Pro" or "Current".');
21
- }
22
-
23
- export function modeFromPromptChoice(choice) {
24
- return choice === "pro" ? "Pro" : null;
25
- }