portal-agent-cli 1.0.1 → 3.0.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/lib/paste.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { sleep } from "./wait.mjs";
2
2
 
3
+ // DeepSeek shows a Cancel/Send confirmation bar when a large paste is
4
+ // detected. Find the Cancel button, locate its Send sibling, click that.
3
5
  export async function dismissPaste(page) {
4
6
  const btns = page.locator("button, [role=button]");
5
7
  let total = 0;
@@ -33,7 +35,7 @@ export async function dismissPaste(page) {
33
35
  try {
34
36
  await sib.click({ timeout: 1500 });
35
37
  await sleep(200);
36
- return;
38
+ return true;
37
39
  } catch (e) {
38
40
  // fall through
39
41
  }
@@ -46,10 +48,13 @@ export async function dismissPaste(page) {
46
48
  } catch (e) {
47
49
  // ignore
48
50
  }
49
- return;
51
+ return true;
50
52
  }
53
+ return false;
51
54
  }
52
55
 
56
+ // Poll the composer for emptiness. A thrown inputValue() means the composer
57
+ // is a contenteditable with no inputValue method, so treat that as sent.
53
58
  export async function composerEmpty(input, ms) {
54
59
  const end = Date.now() + ms;
55
60
  while (Date.now() < end) {
@@ -57,7 +62,6 @@ export async function composerEmpty(input, ms) {
57
62
  try {
58
63
  v = await input.inputValue();
59
64
  } catch (e) {
60
- // contenteditable composers have no inputValue. Treat as sent.
61
65
  return true;
62
66
  }
63
67
  if (!v || v.length === 0) return true;
package/lib/providers.mjs CHANGED
@@ -3,19 +3,41 @@ export const PROVIDERS = {
3
3
  id: "deepseek",
4
4
  label: "DeepSeek",
5
5
  url: "https://chat.deepseek.com/",
6
- input: "textarea",
7
- submit: "button[aria-label=Send]",
8
- response: "div.ds-markdown",
9
- paste: true
6
+ input: ["textarea#chat-input", "textarea", "[contenteditable=true]"],
7
+ submit: ["button[aria-label=Send]", "button[data-testid=send-button]"],
8
+ response: ["div.ds-markdown", "div[class*=message]"],
9
+ paste: true,
10
+ notes: "DeepSeek renders the user prompt with the same class as the reply."
10
11
  },
11
12
  chatgpt: {
12
13
  id: "chatgpt",
13
14
  label: "ChatGPT",
14
15
  url: "https://chatgpt.com/",
15
- input: "#prompt-textarea",
16
- submit: "button[data-testid=send-button]",
17
- response: "[data-message-author-role=assistant]",
18
- paste: false
16
+ input: ["#prompt-textarea", "div[contenteditable=true][id=prompt-textarea]"],
17
+ submit: ["button[data-testid=send-button]", "button[aria-label*=Send]"],
18
+ response: ["[data-message-author-role=assistant]", "article[data-testid^=conversation-turn]"],
19
+ paste: false,
20
+ notes: "ChatGPT does not show a paste dialog."
21
+ },
22
+ gemini: {
23
+ id: "gemini",
24
+ label: "Gemini",
25
+ url: "https://gemini.google.com/app",
26
+ input: ["div[contenteditable=true][role=textbox]", "rich-textarea div[contenteditable=true]"],
27
+ submit: ["button[aria-label*=Send]", "button[aria-label=Send message]"],
28
+ response: ["message-content", "div[class*=model-response-text]"],
29
+ paste: false,
30
+ notes: "Gemini uses a rich-textarea web component."
31
+ },
32
+ grok: {
33
+ id: "grok",
34
+ label: "Grok",
35
+ url: "https://grok.com/",
36
+ input: ["textarea", "div[contenteditable=true]"],
37
+ submit: ["button[type=submit]", "button[aria-label*=Send]"],
38
+ response: ["div[class*=message]", "div[class*=response]"],
39
+ paste: false,
40
+ notes: "Grok selectors are best-effort and may need updating."
19
41
  }
20
42
  };
21
43
 
@@ -23,7 +45,11 @@ export function getProvider(id) {
23
45
  const p = PROVIDERS[id];
24
46
  if (!p) {
25
47
  const names = Object.keys(PROVIDERS).join(", ");
26
- throw new Error("unknown provider: " + id + ". Available: " + names);
48
+ throw new Error("unknown provider: " + id + "\nAvailable: " + names);
27
49
  }
28
50
  return p;
29
51
  }
52
+
53
+ export function listProviders() {
54
+ return Object.keys(PROVIDERS).map(function (k) { return PROVIDERS[k]; });
55
+ }
package/lib/repl.mjs CHANGED
@@ -2,118 +2,285 @@ import { createInterface } from "node:readline";
2
2
  import { existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { launch } from "./browser.mjs";
5
- import { getProvider } from "./providers.mjs";
5
+ import { getProvider, listProviders } from "./providers.mjs";
6
6
  import { waitForComposer } from "./wait.mjs";
7
7
  import { runTurn } from "./turn.mjs";
8
- import { setWs, getWs } from "./tools.mjs";
8
+ import { setWorkspace, getWorkspace } from "./tools/path.mjs";
9
+ import { setIgnore } from "./tools/walk.mjs";
10
+ import { loadInstructions, activeInstructionFile } from "./instructions.mjs";
11
+ import {
12
+ newSessionId,
13
+ saveSession,
14
+ loadSession,
15
+ listSessions
16
+ } from "./sessions.mjs";
17
+ import { theme } from "./theme.mjs";
18
+ import { info, warn, error, ok } from "./log.mjs";
9
19
 
10
- export async function runRepl(opts) {
11
- const provider = getProvider(opts.provider);
12
- setWs(opts.ws);
20
+ export async function runRepl(config) {
21
+ const provider = getProvider(config.provider);
22
+ setWorkspace(config.ws);
23
+ setIgnore(config.ignored);
13
24
 
14
- console.log("");
15
- console.log("Portal -- web AI in your terminal");
16
- console.log("workspace: " + getWs());
17
- console.log("provider: " + provider.label);
18
- console.log("");
25
+ const instructionsText = await loadInstructions(config.ws);
26
+ config.instructionsText = instructionsText;
27
+
28
+ // Session handling
29
+ let session;
30
+ if (config.resume) {
31
+ session = await loadSession(config.resume);
32
+ if (!session) {
33
+ warn("session not found: " + config.resume + ". starting fresh.");
34
+ session = newSession(config);
35
+ }
36
+ } else {
37
+ session = newSession(config);
38
+ }
39
+
40
+ // Banner
41
+ process.stdout.write("\n");
42
+ process.stdout.write(theme.bold(theme.cyan("Portal")) + theme.dim(" v2.0.0") + "\n");
43
+ process.stdout.write(theme.dim("workspace: ") + theme.accent(getWorkspace()) + "\n");
44
+ process.stdout.write(theme.dim("provider: ") + theme.accent(provider.label) + "\n");
45
+ process.stdout.write(theme.dim("session: ") + theme.accent(session.id) + "\n");
46
+ if (instructionsText) {
47
+ process.stdout.write(theme.dim("instructions: ") +
48
+ theme.accent(activeInstructionFile() || "?") + "\n");
49
+ }
50
+ if (config.yolo) {
51
+ process.stdout.write(theme.warn("yolo mode: all tool calls auto-approved") + "\n");
52
+ }
53
+ process.stdout.write("\n");
19
54
 
20
55
  const ctx = await launch();
21
56
  const page = await ctx.newPage();
22
57
  page.setDefaultTimeout(30000);
23
58
 
24
- console.log("opening " + provider.label + "...");
59
+ info("opening " + provider.label + "...");
25
60
  await page.goto(provider.url);
26
- console.log("");
27
- console.log("Log in to " + provider.label + " in the Chrome window.");
28
- console.log("Waiting up to " + Math.round(opts.loginTimeout / 1000) + "s.");
29
- console.log("");
30
61
 
31
- await waitForComposer(page, provider, opts.loginTimeout);
62
+ process.stdout.write("\n");
63
+ process.stdout.write(theme.yellow("Log in to " + provider.label +
64
+ " in the Chrome window.") + "\n");
65
+ process.stdout.write(theme.dim("Waiting up to " +
66
+ Math.round(config.loginTimeout / 1000) + "s.") + "\n");
67
+ process.stdout.write("\n");
68
+
69
+ await waitForComposer(page, provider, config.loginTimeout);
32
70
 
33
- console.log("+ " + provider.label + " ready");
34
- console.log("type /help for commands");
35
- console.log("");
71
+ ok(provider.label + " ready");
72
+ process.stdout.write(theme.dim("type /help for commands") + "\n");
73
+ process.stdout.write("\n");
36
74
 
75
+ // REPL
37
76
  const rl = createInterface({
38
77
  input: process.stdin,
39
78
  output: process.stdout,
40
- prompt: "> "
79
+ prompt: theme.user("> ")
41
80
  });
42
81
  rl.prompt();
43
82
 
44
83
  let busy = false;
84
+ const queue = [];
85
+
86
+ async function drain() {
87
+ if (busy) return;
88
+ const next = queue.shift();
89
+ if (!next) return;
90
+ busy = true;
91
+ try {
92
+ await runTurn(page, provider, next, config, session);
93
+ await saveSession(session.id, session);
94
+ } catch (e) {
95
+ error(e.message || String(e));
96
+ }
97
+ busy = false;
98
+ process.stdout.write("\n");
99
+ rl.prompt();
100
+ if (queue.length > 0) drain();
101
+ }
45
102
 
46
103
  rl.on("line", async function (line) {
47
104
  const t = line.trim();
105
+ if (!t) { rl.prompt(); return; }
48
106
 
49
- if (busy) {
50
- rl.prompt();
51
- return;
52
- }
53
- if (!t) {
107
+ // Slash commands
108
+ if (t === "/quit" || t === "/exit") { rl.close(); return; }
109
+ if (t === "/help" || t === "/?") { showHelp(); rl.prompt(); return; }
110
+ if (t === "/pwd") { process.stdout.write(getWorkspace() + "\n"); rl.prompt(); return; }
111
+ if (t === "/queue") { showQueue(queue); rl.prompt(); return; }
112
+ if (t === "/status") { showStatus(config, session, queue, busy); rl.prompt(); return; }
113
+ if (t === "/history") { showHistory(session); rl.prompt(); return; }
114
+ if (t === "/clear") { clearScreen(); rl.prompt(); return; }
115
+ if (t === "/sessions") { await showSessions(); rl.prompt(); return; }
116
+ if (t === "/providers") { showProviders(); rl.prompt(); return; }
117
+ if (t.slice(0, 4) === "/cd ") { changeDir(t.slice(4).trim()); rl.prompt(); return; }
118
+ if (t.slice(0, 10) === "/provider ") {
119
+ await changeProvider(t.slice(10).trim(), page, config, provider);
54
120
  rl.prompt();
55
121
  return;
56
122
  }
57
- if (t === "/quit" || t === "/exit") {
58
- rl.close();
59
- return;
60
- }
61
- if (t === "/help") {
62
- help();
63
- rl.prompt();
64
- return;
65
- }
66
- if (t === "/pwd") {
67
- console.log(getWs());
123
+
124
+ // Queue or execute
125
+ if (busy) {
126
+ queue.push(t);
127
+ process.stdout.write(theme.dim("queued (" + queue.length + " waiting)") + "\n");
68
128
  rl.prompt();
69
129
  return;
70
130
  }
71
- if (t.slice(0, 4) === "/cd ") {
72
- changeDir(t.slice(4).trim(), rl);
73
- return;
74
- }
75
-
76
- busy = true;
77
- try {
78
- await runTurn(page, provider, t, opts);
79
- } catch (e) {
80
- console.log("x " + (e.message || e));
81
- }
82
- busy = false;
83
- console.log("");
84
- rl.prompt();
131
+ queue.push(t);
132
+ drain();
85
133
  });
86
134
 
87
135
  rl.on("close", async function () {
88
- console.log("");
89
- console.log("closing browser...");
136
+ process.stdout.write("\n");
137
+ process.stdout.write(theme.dim("saving session...") + "\n");
138
+ await saveSession(session.id, session).catch(function () {});
139
+ process.stdout.write(theme.dim("closing browser...") + "\n");
90
140
  await ctx.close().catch(function () {});
141
+ process.stdout.write(theme.ok("+ done") + "\n");
91
142
  process.exit(0);
92
143
  });
93
144
  }
94
145
 
95
- function help() {
96
- console.log(" /cd <folder> change workspace");
97
- console.log(" /pwd show current workspace");
98
- console.log(" /help show this help");
99
- console.log(" /quit exit");
146
+ function newSession(config) {
147
+ return {
148
+ id: newSessionId(),
149
+ ws: config.ws,
150
+ provider: config.provider,
151
+ turns: [],
152
+ approvedAlways: [],
153
+ startedAt: Date.now(),
154
+ updatedAt: Date.now()
155
+ };
100
156
  }
101
157
 
102
- function changeDir(target, rl) {
158
+ function showHelp() {
159
+ const lines = [
160
+ "COMMANDS",
161
+ " /help show this",
162
+ " /cd <folder> change workspace",
163
+ " /pwd show current workspace",
164
+ " /provider <name> switch provider",
165
+ " /providers list providers",
166
+ " /sessions list saved sessions",
167
+ " /history show recent turns",
168
+ " /queue list tasks waiting to run",
169
+ " /status show session status",
170
+ " /clear clear the screen",
171
+ " /quit exit",
172
+ "",
173
+ "Anything else is sent to the model as a task.",
174
+ "Tasks typed while the model is busy are queued."
175
+ ];
176
+ for (const l of lines) {
177
+ process.stdout.write((l.startsWith(" ") ? theme.dim(l) : theme.bold(l)) + "\n");
178
+ }
179
+ }
180
+
181
+ function showQueue(queue) {
182
+ if (!queue.length) {
183
+ process.stdout.write(theme.dim("(queue empty)") + "\n");
184
+ return;
185
+ }
186
+ process.stdout.write(theme.bold(queue.length + " task(s) waiting:") + "\n");
187
+ queue.forEach(function (x, i) {
188
+ process.stdout.write(theme.dim(" " + (i + 1) + ". ") + x + "\n");
189
+ });
190
+ }
191
+
192
+ function showStatus(config, session, queue, busy) {
193
+ process.stdout.write(theme.bold("session status") + "\n");
194
+ process.stdout.write(theme.dim(" id: ") + session.id + "\n");
195
+ process.stdout.write(theme.dim(" workspace: ") + getWorkspace() + "\n");
196
+ process.stdout.write(theme.dim(" provider: ") + session.provider + "\n");
197
+ process.stdout.write(theme.dim(" turns: ") + session.turns.length + "\n");
198
+ process.stdout.write(theme.dim(" busy: ") + (busy ? "yes" : "no") + "\n");
199
+ process.stdout.write(theme.dim(" queued: ") + queue.length + "\n");
200
+ process.stdout.write(theme.dim(" yolo: ") + (config.yolo ? "on" : "off") + "\n");
201
+ }
202
+
203
+ function showHistory(session) {
204
+ if (!session.turns.length) {
205
+ process.stdout.write(theme.dim("(no turns yet)") + "\n");
206
+ return;
207
+ }
208
+ const recent = session.turns.slice(-10);
209
+ for (const t of recent) {
210
+ const tag = t.role === "user" ? theme.user("you") : theme.cyan("ai");
211
+ const text = String(t.text || "").slice(0, 200);
212
+ process.stdout.write(tag + ": " + text + "\n");
213
+ }
214
+ }
215
+
216
+ async function showSessions() {
217
+ const list = await listSessions();
218
+ if (!list.length) {
219
+ process.stdout.write(theme.dim("(no saved sessions)") + "\n");
220
+ return;
221
+ }
222
+ process.stdout.write(theme.bold("saved sessions:") + "\n");
223
+ for (const s of list.slice(0, 15)) {
224
+ const when = timeAgo(s.updatedAt);
225
+ process.stdout.write(theme.dim(" " + s.id + " " + when + " " +
226
+ s.provider + " " + s.turns + " turns") + "\n");
227
+ }
228
+ }
229
+
230
+ function showProviders() {
231
+ const list = listProviders();
232
+ process.stdout.write(theme.bold("providers:") + "\n");
233
+ for (const p of list) {
234
+ process.stdout.write(theme.dim(" " + p.id.padEnd(10)) + " " + p.label + "\n");
235
+ }
236
+ }
237
+
238
+ function changeDir(target) {
103
239
  if (!target) {
104
- console.log("usage: /cd <folder>");
105
- console.log("current: " + getWs());
106
- rl.prompt();
240
+ process.stdout.write(theme.dim("usage: /cd <folder>") + "\n");
241
+ process.stdout.write(theme.dim("current: ") + getWorkspace() + "\n");
107
242
  return;
108
243
  }
109
244
  const clean = target.replace(/^"|"$/g, "");
110
245
  const abs = resolve(clean);
111
246
  if (!existsSync(abs)) {
112
- console.log("not found: " + abs);
113
- rl.prompt();
247
+ warn("not found: " + abs);
114
248
  return;
115
249
  }
116
- setWs(abs);
117
- console.log("workspace: " + getWs());
118
- rl.prompt();
250
+ setWorkspace(abs);
251
+ ok("workspace: " + getWorkspace());
252
+ }
253
+
254
+ async function changeProvider(name, page, config, current) {
255
+ if (name === current.id) {
256
+ process.stdout.write(theme.dim("already on " + name) + "\n");
257
+ return;
258
+ }
259
+ let next;
260
+ try {
261
+ next = getProvider(name);
262
+ } catch (e) {
263
+ error(e.message);
264
+ return;
265
+ }
266
+ info("switching to " + next.label + "...");
267
+ await page.goto(next.url);
268
+ await waitForComposer(page, next, 60000);
269
+ config.provider = name;
270
+ ok("switched to " + next.label);
271
+ }
272
+
273
+ function clearScreen() {
274
+ process.stdout.write(String.fromCharCode(27) + "[2J");
275
+ process.stdout.write(String.fromCharCode(27) + "[H");
276
+ }
277
+
278
+ function timeAgo(ms) {
279
+ const s = Math.floor((Date.now() - ms) / 1000);
280
+ if (s < 60) return s + "s ago";
281
+ const m = Math.floor(s / 60);
282
+ if (m < 60) return m + "m ago";
283
+ const h = Math.floor(m / 60);
284
+ if (h < 24) return h + "h ago";
285
+ return Math.floor(h / 24) + "d ago";
119
286
  }
package/lib/safety.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import { createInterface } from "node:readline";
2
+ import { theme } from "./theme.mjs";
3
+
4
+ // Tools that touch the filesystem destructively or execute code. These
5
+ // require explicit approval unless --yolo is set.
6
+ export const SENSITIVE = new Set([
7
+ "shell",
8
+ "delete_file",
9
+ "move_file",
10
+ "write_file",
11
+ "edit_file"
12
+ ]);
13
+
14
+ // Read-only tools. Never prompt.
15
+ export const SAFE = new Set([
16
+ "read_file",
17
+ "list_files",
18
+ "find_files",
19
+ "grep_search",
20
+ "stat_file",
21
+ "git_status",
22
+ "git_diff"
23
+ ]);
24
+
25
+ export function isSensitive(name) {
26
+ return SENSITIVE.has(name);
27
+ }
28
+
29
+ export function isSafe(name) {
30
+ return SAFE.has(name);
31
+ }
32
+
33
+ // Approval prompt. Reads a single key from stdin.
34
+ export async function askApproval(toolName, args) {
35
+ const preview = JSON.stringify(args || {}, null, 2).slice(0, 500);
36
+ process.stdout.write("\n");
37
+ process.stdout.write(theme.warn("+ approval required") + "\n");
38
+ process.stdout.write(theme.dim(" tool: ") + theme.tool(toolName) + "\n");
39
+ const lines = preview.split("\n").slice(0, 12);
40
+ for (const l of lines) {
41
+ process.stdout.write(theme.dim(" " + l) + "\n");
42
+ }
43
+ if (preview.split("\n").length > 12) {
44
+ process.stdout.write(theme.dim(" ...") + "\n");
45
+ }
46
+
47
+ return new Promise(function (resolve) {
48
+ const rl = createInterface({
49
+ input: process.stdin,
50
+ output: process.stdout
51
+ });
52
+ rl.question(
53
+ theme.bold(" allow? ") + theme.ok("y") + "es / " +
54
+ theme.warn("a") + "lways / " + theme.error("n") + "o: ",
55
+ function (answer) {
56
+ rl.close();
57
+ const a = answer.trim().toLowerCase();
58
+ if (a === "y" || a === "yes") resolve("yes");
59
+ else if (a === "a" || a === "always") resolve("always");
60
+ else resolve("no");
61
+ }
62
+ );
63
+ });
64
+ }
package/lib/send.mjs CHANGED
@@ -1,88 +1,92 @@
1
- import { sleep, norm } from "./wait.mjs";
1
+ import { sleep, norm, findFirstVisible, countMatching } from "./wait.mjs";
2
2
  import { dismissPaste, composerEmpty } from "./paste.mjs";
3
3
 
4
+ const SETTLE_MS = 2000;
5
+
4
6
  export async function send(page, provider, prompt, timeoutMs) {
5
- const input = page.locator(provider.input).last();
6
- await input.waitFor({ state: "visible", timeout: 30000 });
7
- const before = await page.locator(provider.response).count();
7
+ const input = await findFirstVisible(page, provider.input, 30000);
8
+ if (!input) throw new Error("composer not found");
9
+
10
+ const before = await countMatching(page, provider.response);
11
+ const want = norm(prompt);
8
12
 
9
13
  await input.focus();
10
14
  try {
11
15
  await page.keyboard.press("Control+A");
12
16
  await page.keyboard.press("Delete");
13
17
  } catch (e) {
14
- // composer may be empty
18
+ // empty composer
15
19
  }
16
20
 
17
21
  await page.keyboard.insertText(prompt);
18
22
  await sleep(300);
19
-
20
23
  if (provider.paste) await dismissPaste(page);
21
24
 
22
25
  try {
23
26
  await input.press("Enter");
24
27
  } catch (e) {
25
- // fall through to button
28
+ // fall through
26
29
  }
27
30
 
28
31
  let ok = await composerEmpty(input, 5000);
29
-
30
32
  if (!ok) {
31
- const btn = page.locator(provider.submit).last();
32
- try {
33
- if (await btn.isVisible({ timeout: 1500 })) await btn.click();
34
- } catch (e) {
35
- // no button
33
+ const btn = await findFirstVisible(page, provider.submit, 1500);
34
+ if (btn) {
35
+ try { await btn.click({ timeout: 1500 }); } catch (e) {}
36
36
  }
37
37
  ok = await composerEmpty(input, 3000);
38
38
  }
39
39
 
40
40
  if (!ok) {
41
41
  throw new Error(
42
- "Message stayed in composer. Look at the Chrome window: if a " +
43
- "Cancel/Send bar is showing, click Send manually once, then resend."
42
+ "Message stayed in composer.\n" +
43
+ "Check the Chrome window. If a Cancel/Send bar is showing, click Send " +
44
+ "once manually, then resend."
44
45
  );
45
46
  }
46
47
 
47
- const want = norm(prompt);
48
+ return waitForReply(page, provider, want, timeoutMs);
49
+ }
50
+
51
+ async function waitForReply(page, provider, want, timeoutMs) {
48
52
  const end = Date.now() + timeoutMs;
49
- let appeared = false;
53
+ let last = "";
54
+ let stab = Date.now();
55
+ let sawAnything = false;
56
+ let lastCount = 0;
50
57
 
51
58
  while (Date.now() < end) {
52
- const c = await page.locator(provider.response).count();
53
- if (c > before) {
54
- appeared = true;
55
- break;
56
- }
57
- await sleep(300);
58
- }
59
+ const count = await countMatching(page, provider.response);
60
+ if (count > lastCount) lastCount = count;
59
61
 
60
- if (!appeared) throw new Error("No response appeared within the timeout.");
62
+ const node = await findFirstVisible(page, provider.response, 1000);
63
+ if (node) {
64
+ let tx = "";
65
+ try { tx = (await node.textContent()) || ""; } catch (e) { tx = ""; }
66
+ const n = norm(tx);
61
67
 
62
- let last = "";
63
- let stab = Date.now();
68
+ // Skip empty nodes and the echoed prompt.
69
+ if (!n || n === want) {
70
+ await sleep(300);
71
+ continue;
72
+ }
64
73
 
65
- while (Date.now() < end) {
66
- const node = page.locator(provider.response).last();
67
- let tx = "";
68
- try {
69
- tx = (await node.textContent()) || "";
70
- } catch (e) {
71
- tx = "";
72
- }
73
- const n = norm(tx);
74
- if (!n || n === want) {
75
- await sleep(300);
76
- continue;
77
- }
78
- if (tx !== last) {
79
- last = tx;
80
- stab = Date.now();
81
- } else if (Date.now() - stab > 1500) {
82
- return tx;
74
+ if (tx !== last) {
75
+ last = tx;
76
+ stab = Date.now();
77
+ sawAnything = true;
78
+ } else if (sawAnything && Date.now() - stab > SETTLE_MS) {
79
+ return tx;
80
+ }
83
81
  }
82
+
84
83
  await sleep(300);
85
84
  }
86
85
 
87
- return last || "(timeout)";
86
+ if (last) return last;
87
+ throw new Error(
88
+ "Timed out waiting for a reply.\n" +
89
+ "Check the Chrome window. If the model answered but Portal missed it, " +
90
+ "send the task again."
91
+ );
88
92
  }