qiksy-mcp 1.63.0 → 1.65.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/open-tab.mjs +165 -0
- package/package.json +2 -1
- package/server.mjs +86 -26
package/open-tab.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPENING A TAB IN THE BROWSER THE BRIDGE IS ACTUALLY CONNECTED TO.
|
|
3
|
+
*
|
|
4
|
+
* The bridge refuses to navigate a tab onto a foreign domain, and that refusal is correct: it is
|
|
5
|
+
* the thing that stops an agent from walking somebody's browser wherever it likes. But the way
|
|
6
|
+
* OUT of that dead end lived nowhere near the product — it was a shell trick written down in one
|
|
7
|
+
* person's instructions, so every fresh agent rediscovered the dead end and stopped there
|
|
8
|
+
* (owner, 30.09.2026: «постоянно возникает вопрос, он говорит, что я не нахожу таб»).
|
|
9
|
+
*
|
|
10
|
+
* THE PROFILE IS THE WHOLE DIFFICULTY, AND ANCHORING IS WHAT REMOVES IT. A machine has several
|
|
11
|
+
* Chrome profiles; the extension lives in exactly one, and a tab opened anywhere else is invisible
|
|
12
|
+
* to the bridge — every reading afterwards is then about a tab nobody can see. Naming the profile
|
|
13
|
+
* does not work: a running Chrome silently ignores `--profile-directory`, the command still exits
|
|
14
|
+
* 0, and the tab still lands elsewhere. There is no error to diagnose.
|
|
15
|
+
*
|
|
16
|
+
* So this never names a profile. It takes a tab the bridge ALREADY SEES, finds the window holding
|
|
17
|
+
* it, and inserts the new tab beside it. Same window, therefore same profile, therefore visible —
|
|
18
|
+
* by construction rather than by care.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ONLY http AND https. `file://` hands an agent the local disk through a browser and
|
|
24
|
+
* `javascript:` hands it the page it lands on; neither is «open a site», and a verb that accepts
|
|
25
|
+
* them is a different, much larger verb wearing this one's name.
|
|
26
|
+
*/
|
|
27
|
+
export function safeUrl(raw) {
|
|
28
|
+
let u;
|
|
29
|
+
try {
|
|
30
|
+
u = new URL(String(raw ?? '').trim());
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The anchor: the ORIGIN of a tab the bridge can see. An origin rather than a whole address
|
|
39
|
+
* because the address moves — a single-page app rewrites its path as the person clicks, and an
|
|
40
|
+
* anchor that has to match what was there a second ago is an anchor that misses.
|
|
41
|
+
*/
|
|
42
|
+
export function anchorFrom(tabs) {
|
|
43
|
+
for (const t of Array.isArray(tabs) ? tabs : []) {
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(String(t?.url || ''));
|
|
46
|
+
if (u.protocol === 'http:' || u.protocol === 'https:') return u.origin;
|
|
47
|
+
} catch {
|
|
48
|
+
/* a tab with no readable address is simply not an anchor */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* AppleScript string literals take the same two escapes as C. The script goes in on stdin rather
|
|
55
|
+
than through `-e`, so the SHELL never sees the address: an OAuth redirect full of & and % is
|
|
56
|
+
exactly the kind of URL this is for, and quoting it for a shell is where that goes wrong. */
|
|
57
|
+
const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
58
|
+
|
|
59
|
+
export function appleScript({ anchor, url, app = 'Google Chrome' }) {
|
|
60
|
+
return `tell application "${esc(app)}"
|
|
61
|
+
set wi to 0
|
|
62
|
+
repeat with w in windows
|
|
63
|
+
set wi to wi + 1
|
|
64
|
+
repeat with t in tabs of w
|
|
65
|
+
if (URL of t) contains "${esc(anchor)}" then
|
|
66
|
+
make new tab at end of tabs of w with properties {URL:"${esc(url)}"}
|
|
67
|
+
set active tab index of w to (count of tabs of w)
|
|
68
|
+
return "opened in window " & wi
|
|
69
|
+
end if
|
|
70
|
+
end repeat
|
|
71
|
+
end repeat
|
|
72
|
+
return "no window holds the anchor"
|
|
73
|
+
end tell`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Runs the script. Separated so the gate can drive every branch without a browser on the machine. */
|
|
77
|
+
export function runAppleScript(script) {
|
|
78
|
+
const r = spawnSync('osascript', ['-'], { input: script, encoding: 'utf8', timeout: 15_000 });
|
|
79
|
+
if (r.error) return { ok: false, said: String(r.error.message || r.error) };
|
|
80
|
+
const said = String(r.stdout || '').trim() || String(r.stderr || '').trim();
|
|
81
|
+
return { ok: r.status === 0 && /^opened in window/.test(said), said };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The whole verb, with the platform check and both ways it can legitimately fail.
|
|
86
|
+
*
|
|
87
|
+
* `tabsNow` returns the bridge's current tab list; it is asked TWICE on purpose — once for the
|
|
88
|
+
* anchor, once to find out what actually happened. A command that exits 0 proves the script ran,
|
|
89
|
+
* not that a tab the bridge can see now exists, and those are the two facts that get confused.
|
|
90
|
+
*/
|
|
91
|
+
export async function openTabHere({
|
|
92
|
+
url,
|
|
93
|
+
tabsNow,
|
|
94
|
+
platform = process.platform,
|
|
95
|
+
run = runAppleScript,
|
|
96
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
97
|
+
tries = 8,
|
|
98
|
+
}) {
|
|
99
|
+
const clean = safeUrl(url);
|
|
100
|
+
if (!clean) return { ok: false, error: 'bad-url', note: 'Only http:// and https:// addresses can be opened this way.' };
|
|
101
|
+
|
|
102
|
+
const before = await tabsNow();
|
|
103
|
+
const anchor = anchorFrom(before);
|
|
104
|
+
if (!anchor) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: 'no-anchor',
|
|
108
|
+
note:
|
|
109
|
+
'The bridge can see no tab at all, so there is no window to open this beside — and opening it anywhere else would put it in a Chrome profile the extension does not live in, where nothing you do next is visible. Ask the person to open any page in the browser that has Qiksy in it, then call this again.',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (platform !== 'darwin') {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: 'not-on-this-platform',
|
|
117
|
+
anchor,
|
|
118
|
+
note: `Opening a tab this way is macOS-only for now. Ask the person to open ${clean} themselves in the browser that has Qiksy in it — any other browser or profile is invisible to the bridge.`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const said = run(appleScript({ anchor, url: clean }));
|
|
123
|
+
if (!said.ok) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: 'could-not-open',
|
|
127
|
+
anchor,
|
|
128
|
+
said: said.said,
|
|
129
|
+
note: 'The browser did not take the new tab. If this says the anchor was not found, the window holding it was probably closed between the two steps.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* WAIT FOR THE TAB TO BE VISIBLE TO THE BRIDGE, not for a number of milliseconds: a page that
|
|
134
|
+
is still loading has no address the extension reports yet, and answering before it does would
|
|
135
|
+
hand back «opened, and I cannot see it», which reads as a failure. */
|
|
136
|
+
const wantOrigin = new URL(clean).origin;
|
|
137
|
+
const knownIds = new Set((before || []).map((t) => t.tabId));
|
|
138
|
+
let fresh = null;
|
|
139
|
+
for (let i = 0; i < tries && !fresh; i++) {
|
|
140
|
+
await sleep(600);
|
|
141
|
+
const now = await tabsNow();
|
|
142
|
+
fresh = (now || []).find((t) => !knownIds.has(t.tabId)) || null;
|
|
143
|
+
if (!fresh) fresh = (now || []).find((t) => String(t.url || '').startsWith(wantOrigin) && !knownIds.has(t.tabId)) || null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (fresh) {
|
|
147
|
+
return { ok: true, tabId: fresh.tabId, url: fresh.url, inSession: true, said: said.said, note: 'The tab is open and the bridge can act on it. Pass this tabId to the other verbs.' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* OPENED, BUT NOT IN THE SESSION — and this is the ordinary case rather than a fault.
|
|
152
|
+
*
|
|
153
|
+
* The active tab counts as the session only while NOTHING has been attached; the moment a person
|
|
154
|
+
* has switched «Agent drives this tab» on anywhere, the session is that explicit set and a new
|
|
155
|
+
* tab is not in it. Reporting «opened» and stopping would leave the agent measuring a tab it
|
|
156
|
+
* cannot see and blaming the bridge, which is the exact failure this verb was built to end.
|
|
157
|
+
*/
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
tabId: null,
|
|
161
|
+
inSession: false,
|
|
162
|
+
said: said.said,
|
|
163
|
+
note: `The tab is open in the right browser, but it is not in the agent's session: once any tab has been switched on for the agent, the session is that explicit set and a new tab does not join it by itself. Ask the person to switch «Agent drives this tab» on for ${wantOrigin} — reading and driving both start working the moment they do.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.65.0",
|
|
4
4
|
"description": "Browser MCP server for the Chrome tab you already have open — your session, your logins. Gives Claude Code, Cursor, Codex and VS Code the live page: findings, forms, failed requests with server bodies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"project.mjs",
|
|
35
35
|
"gaps.mjs",
|
|
36
36
|
"reports.mjs",
|
|
37
|
+
"open-tab.mjs",
|
|
37
38
|
"gallery.mjs",
|
|
38
39
|
"svg-shot.mjs",
|
|
39
40
|
"connect.mjs",
|
package/server.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { LIBRARIES, datePlan, hintsFor, recipeFor, classifyNode, classifySnapsho
|
|
|
35
35
|
import { project } from './project.mjs';
|
|
36
36
|
import { recordGap, listGaps, gapInvite, GAPS_FILE } from './gaps.mjs';
|
|
37
37
|
import { reportsDir, reportFileName } from './reports.mjs';
|
|
38
|
+
import { openTabHere } from './open-tab.mjs';
|
|
38
39
|
import { STATES, STATE_KEYS, renderGallery } from './gallery.mjs';
|
|
39
40
|
import { renderSearchableSvg } from './svg-shot.mjs';
|
|
40
41
|
import { connectAgents, toClipboard, pairingInPlace } from './connect.mjs';
|
|
@@ -1292,6 +1293,11 @@ function onConnection(ws, req) {
|
|
|
1292
1293
|
// answer back under the follower's own id. Ours is a fresh randomUUID, so the two
|
|
1293
1294
|
// id spaces cannot collide.
|
|
1294
1295
|
if (msg.t === 'req' && isFollower) {
|
|
1296
|
+
/* A REAL full stop from a sibling window or from `npx qiksy-mcp done` — the Stop hook's
|
|
1297
|
+
route into this process. Our OWN estimate travels the same way when a follower guesses,
|
|
1298
|
+
which is why the flag it carries is checked here: a guess must not teach the hub that
|
|
1299
|
+
this setup reports its endings. */
|
|
1300
|
+
if (msg.tool === 'qa_done' && !msg.args?.guessed) noteRealDone();
|
|
1295
1301
|
callExtension(msg.tool, msg.args, msg.timeoutMs)
|
|
1296
1302
|
.then((result) => safeSend(ws, { t: 'res', id: msg.id, ok: true, result }))
|
|
1297
1303
|
.catch((err) => safeSend(ws, { t: 'res', id: msg.id, ok: false, error: err?.message || String(err) }));
|
|
@@ -1315,51 +1321,69 @@ function onConnection(ws, req) {
|
|
|
1315
1321
|
let uiLanguage = '';
|
|
1316
1322
|
|
|
1317
1323
|
/**
|
|
1318
|
-
* THE END OF A RUN,
|
|
1324
|
+
* GUESSING THE END OF A RUN — OFF BY DEFAULT SINCE 1.64.0, AND THE REASON IS WORTH KEEPING.
|
|
1319
1325
|
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
* the state this exists to prevent (owner, 21.09.2026).
|
|
1326
|
+
* It was added on 21.09.2026 to cover clients that never call `qa_done`: the person updates,
|
|
1327
|
+
* everything is on, and they hear nothing. The guess is silence — no bridge call for a while means
|
|
1328
|
+
* the run is over — and it was defended as safe to be wrong, because a later call cancels the
|
|
1329
|
+
* pulse and the extension refuses to sound twice in half a minute.
|
|
1325
1330
|
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1331
|
+
* THAT DEFENCE ONLY COVERED THE PULSE. A sound cannot be cancelled after it has been heard, and
|
|
1332
|
+
* the silence it keys on is not silence at all: an agent that walked away from the browser to read
|
|
1333
|
+
* files, edit code or think makes no bridge call for MINUTES while working flat out. So the signal
|
|
1334
|
+
* arrived mid-run, and again on the next lull, and again — which is exactly what the owner
|
|
1335
|
+
* reported hearing (28.09.2026: «звук отрабатывает, а агент продолжает маслать… QADone несколько
|
|
1336
|
+
* раз отрабатывает, то есть он не по окончанию»). His ruling covers the mark as well as the
|
|
1337
|
+
* sound: both announce the END of a task, so neither may fire on a guess.
|
|
1329
1338
|
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1334
|
-
*
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1339
|
+
* A signal that is right most of the time is worse than no signal, because it is learned as noise
|
|
1340
|
+
* and then switched off — taking the true ones with it.
|
|
1341
|
+
*
|
|
1342
|
+
* WHAT REPLACES IT IS NOT NOTHING. Two routes report a real full stop, and `connect` writes the
|
|
1343
|
+
* second one into the client's settings, so an ordinary install has it:
|
|
1344
|
+
* · the agent calling `qa_done` itself, which every client is told about at `initialize`;
|
|
1345
|
+
* · a Stop hook running `npx qiksy-mcp done`, which fires whether or not the model remembered.
|
|
1346
|
+
* The guess survives as an opt-in for a client that has neither: `QIKSY_MCP_DONE_AFTER=<seconds>`.
|
|
1338
1347
|
*/
|
|
1339
1348
|
const DONE_AFTER_MS = (() => {
|
|
1340
1349
|
const raw = process.env.QIKSY_MCP_DONE_AFTER;
|
|
1341
|
-
if (raw === '0' || /^(off|no|false)$/i.test(raw || '')) return 0;
|
|
1342
1350
|
const n = Number(raw);
|
|
1343
1351
|
/* Below twenty seconds this would fire inside an ordinary pause for thought, so a smaller
|
|
1344
|
-
number is
|
|
1345
|
-
return Number.isFinite(n) && n >= 20 ? n * 1000 :
|
|
1352
|
+
number is refused rather than honoured. Anything else, including nothing at all, is off. */
|
|
1353
|
+
return Number.isFinite(n) && n >= 20 ? n * 1000 : 0;
|
|
1346
1354
|
})();
|
|
1347
1355
|
let doneTimer = null;
|
|
1348
1356
|
let workSinceDone = false;
|
|
1357
|
+
/**
|
|
1358
|
+
* A SETUP THAT REPORTS ITS OWN FULL STOP NEEDS NO GUESS, and the guess is the part that can be
|
|
1359
|
+
* wrong. One real `qa_done` proves this client has a working route, so the estimate stands down
|
|
1360
|
+
* for the rest of the process — nobody has to notice that they opted into both.
|
|
1361
|
+
*/
|
|
1362
|
+
let realDoneSeen = false;
|
|
1363
|
+
|
|
1364
|
+
/** Called where a REAL full stop enters this process, and nowhere else — never from `fireDone`. */
|
|
1365
|
+
function noteRealDone() {
|
|
1366
|
+
if (realDoneSeen) return;
|
|
1367
|
+
realDoneSeen = true;
|
|
1368
|
+
if (doneTimer) clearTimeout(doneTimer);
|
|
1369
|
+
doneTimer = null;
|
|
1370
|
+
workSinceDone = false;
|
|
1371
|
+
if (DONE_AFTER_MS) log('▸ this setup announces the end of a run itself — the quiet-timer guess is off from here on');
|
|
1372
|
+
}
|
|
1349
1373
|
|
|
1350
1374
|
function fireDone() {
|
|
1351
1375
|
doneTimer = null;
|
|
1352
|
-
if (!workSinceDone) return;
|
|
1376
|
+
if (!workSinceDone || realDoneSeen) return;
|
|
1353
1377
|
workSinceDone = false;
|
|
1354
1378
|
log(`▸ quiet for ${Math.round(DONE_AFTER_MS / 1000)}s after a run — saying it has finished`);
|
|
1355
1379
|
/* Failure here is nothing: the browser may have gone, the tab may have closed. The pulse is a
|
|
1356
1380
|
courtesy, and a courtesy that throws is worse than one that does not happen. */
|
|
1357
|
-
callExtension('qa_done', { verdict: 'ok' }, 8_000).catch(() => {});
|
|
1381
|
+
callExtension('qa_done', { verdict: 'ok', guessed: true }, 8_000).catch(() => {});
|
|
1358
1382
|
}
|
|
1359
1383
|
|
|
1360
1384
|
/** Called when a call FINISHES, not when it starts: a slow verb must not look like silence. */
|
|
1361
1385
|
function armDone(tool) {
|
|
1362
|
-
if (!DONE_AFTER_MS) return;
|
|
1386
|
+
if (!DONE_AFTER_MS || realDoneSeen) return;
|
|
1363
1387
|
if (doneTimer) clearTimeout(doneTimer);
|
|
1364
1388
|
doneTimer = null;
|
|
1365
1389
|
if (tool === 'qa_done') {
|
|
@@ -1543,12 +1567,20 @@ function stuckHelp(text) {
|
|
|
1543
1567
|
};
|
|
1544
1568
|
}
|
|
1545
1569
|
if (kind === 'scope') {
|
|
1570
|
+
/* THE DEAD END HAS TO NAME ITS OWN WAY OUT. This refusal used to end at «open it in a tab
|
|
1571
|
+
yourself», and the only reliable way to do that — anchoring on a window the bridge can
|
|
1572
|
+
already see — lived in one person's notes rather than in the product. So every fresh agent
|
|
1573
|
+
rediscovered the dead end and stopped there, and the person watching saw the bridge give up
|
|
1574
|
+
(owner, 30.09.2026). `qa_open_tab` is that way out, and it belongs in the answer that
|
|
1575
|
+
refuses, not two hundred lines up in a manual read once at initialize. */
|
|
1546
1576
|
return brief
|
|
1547
|
-
? {
|
|
1577
|
+
? { nextCall: 'qa_open_tab { url } — a NEW tab in their own browser, beside the ones already open. Do not reach around this with another driver.' }
|
|
1548
1578
|
: {
|
|
1549
1579
|
whyThisHappened: 'The agent session is a SET of tabs the human opted into. This one is not in it — by design, so nothing you do can wander into their mail or bank.',
|
|
1550
|
-
nextCall:
|
|
1551
|
-
|
|
1580
|
+
nextCall:
|
|
1581
|
+
'qa_open_tab { url } opens the address as a NEW tab in the very browser this bridge is connected to, losing nothing. Or qa_tabs to see what IS attached; a tab opened BY an attached tab joins automatically.',
|
|
1582
|
+
sayThis:
|
|
1583
|
+
'If the new tab comes back with inSession:false, ask them to attach it: the popup switch "Agent drives this tab", or right-click on the page → "Qiksy — test this tab".',
|
|
1552
1584
|
stayHere: 'Do not open the page in another driver to get around the boundary — that browser has none of their sessions in it anyway.',
|
|
1553
1585
|
};
|
|
1554
1586
|
}
|
|
@@ -4844,6 +4876,32 @@ server.registerTool(
|
|
|
4844
4876
|
* A shot is addressed by NAME («checkout»), never by «the last one»: there is no implicit current
|
|
4845
4877
|
* shot, so marking the wrong picture is not a mistake that can be made by default.
|
|
4846
4878
|
*/
|
|
4879
|
+
server.registerTool(
|
|
4880
|
+
'qa_open_tab',
|
|
4881
|
+
{
|
|
4882
|
+
title: 'Open a page in the browser the bridge is connected to',
|
|
4883
|
+
description:
|
|
4884
|
+
'OPEN AN ADDRESS THE BRIDGE WILL NOT NAVIGATE TO. The driving verbs stay inside the site under test on purpose — that refusal is what stops an agent from walking somebody\'s browser onto an arbitrary domain, and it must not be worked around. This is the sanctioned way through it: a NEW tab, beside the ones already open, losing nothing. ' +
|
|
4885
|
+
'It goes into the RIGHT BROWSER by construction, not by care: the machine has several Chrome profiles, the extension lives in exactly one, and a tab opened into any other is invisible here — so this finds the window holding a tab the bridge can already see and inserts the new one beside it. Never start a browser yourself instead; a second browser is a DIFFERENT, EMPTY browser with none of the person\'s logins, and everything measured in it stops being the run that was asked for. ' +
|
|
4886
|
+
'Read the answer rather than assuming: `inSession: true` means the tab is yours to drive and carries the tabId to pass to the other verbs; `inSession: false` means it is open and the person has to switch «Agent drives this tab» on for it, because once any tab has been switched on the session is that explicit set. macOS for now; elsewhere it says so and names what to ask for. Free.',
|
|
4887
|
+
inputSchema: {
|
|
4888
|
+
url: z.string().describe('The full http(s) address to open. Query strings are fine — nothing here goes through a shell.'),
|
|
4889
|
+
},
|
|
4890
|
+
},
|
|
4891
|
+
async ({ url }, extra) => {
|
|
4892
|
+
const bar = loader(extra, 'opening a tab');
|
|
4893
|
+
try {
|
|
4894
|
+
const tabsNow = async () => {
|
|
4895
|
+
const r = await callExtension('qa_tabs', {}, 15_000).catch(() => null);
|
|
4896
|
+
return Array.isArray(r?.tabs) ? r.tabs : [];
|
|
4897
|
+
};
|
|
4898
|
+
return asText(await openTabHere({ url, tabsNow }));
|
|
4899
|
+
} finally {
|
|
4900
|
+
bar.stop();
|
|
4901
|
+
}
|
|
4902
|
+
},
|
|
4903
|
+
);
|
|
4904
|
+
|
|
4847
4905
|
server.registerTool(
|
|
4848
4906
|
'qa_shot_list',
|
|
4849
4907
|
{
|
|
@@ -7147,6 +7205,8 @@ server.registerTool(
|
|
|
7147
7205
|
},
|
|
7148
7206
|
async ({ tabId, verdict }) => {
|
|
7149
7207
|
try {
|
|
7208
|
+
/* The agent said it itself — the one route that needs no guessing behind it. */
|
|
7209
|
+
noteRealDone();
|
|
7150
7210
|
return asText(await callExtension('qa_done', { tabId, verdict }, 15_000));
|
|
7151
7211
|
} catch (e) {
|
|
7152
7212
|
/* The verb lives in the EXTENSION, which updates on the Web Store's clock while this server
|