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.
Files changed (3) hide show
  1. package/open-tab.mjs +165 -0
  2. package/package.json +2 -1
  3. 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.63.0",
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, WITHOUT ANYBODY HAVING TO REMEMBER IT.
1324
+ * GUESSING THE END OF A RUN — OFF BY DEFAULT SINCE 1.64.0, AND THE REASON IS WORTH KEEPING.
1319
1325
  *
1320
- * `qa_done` is the verb that makes the tab pulse and the sound play, and the agent is asked to
1321
- * call it in the instructions handed over at connect. An instruction is a request, though, not a
1322
- * mechanism: some clients follow it, some do not, and every agent already installed has the old
1323
- * habit. So the person updates, everything is switched on by default — and hears nothing. That is
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
- * What this process CAN observe is the same thing the voice already watches: it timestamps every
1327
- * call and knows when the last one finished. A run that did real work and then went quiet past a
1328
- * threshold is over in every sense that matters to somebody in the next room.
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
- * IT IS AN ESTIMATE, AND IT IS BUILT TO BE WRONG SAFELY. Three things make a premature signal
1331
- * cheap rather than annoying, and none of them are new — they all shipped with the verb:
1332
- * · the extension refuses to sound when no work has been recorded;
1333
- * · it refuses to sound twice within half a minute;
1334
- * · ANY later call stops the pulse, so an agent that was merely thinking cancels the signal by
1335
- * carrying on — the mistake undoes itself without anybody touching anything.
1336
- * The exact path — a Stop hook running `npx qiksy-mcp done` — stays available for clients that
1337
- * have one, and is what `init` writes. This is the floor under it, not a replacement.
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 read as a mistake and the default is used instead. */
1345
- return Number.isFinite(n) && n >= 20 ? n * 1000 : 90_000;
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
- ? { stayHere: 'Ask the human to attach the tab (right-click → "Qiksy — test this tab"). Do not reach around it with another driver.' }
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: 'qa_tabs — see what IS attached; a tab opened BY an attached tab joins automatically.',
1551
- sayThis: 'Ask them to attach it: the popup switch "Agent drives this tab", or right-click on the page → "Qiksy — test this tab".',
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