qiksy-mcp 1.46.0 → 1.48.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/AGENT-GUIDE.md +1 -1
- package/README.md +7 -7
- package/connect.mjs +81 -4
- package/package.json +1 -1
- package/server.mjs +159 -21
package/AGENT-GUIDE.md
CHANGED
package/README.md
CHANGED
|
@@ -223,7 +223,7 @@ Only worth doing for the two gaps above — screenshots and `evaluate`. Playwrig
|
|
|
223
223
|
},
|
|
224
224
|
"qiksy": {
|
|
225
225
|
"command": "npx",
|
|
226
|
-
"args": ["qiksy-mcp", "--port", "7333"],
|
|
226
|
+
"args": ["-y", "qiksy-mcp", "--port", "7333"],
|
|
227
227
|
"env": { "QIKSY_MCP_TOKEN": "YOUR_TOKEN" }
|
|
228
228
|
}
|
|
229
229
|
}
|
|
@@ -294,8 +294,8 @@ One bridge is **one port + one token = one browser**. Pointing a second browser
|
|
|
294
294
|
|
|
295
295
|
```json
|
|
296
296
|
{ "mcpServers": {
|
|
297
|
-
"qiksy": { "command": "npx", "args": ["qiksy-mcp", "--port", "7333"], "env": { "QIKSY_MCP_TOKEN": "TOKEN_A" } },
|
|
298
|
-
"qiksy-2": { "command": "npx", "args": ["qiksy-mcp", "--port", "7334"], "env": { "QIKSY_MCP_TOKEN": "TOKEN_B" } }
|
|
297
|
+
"qiksy": { "command": "npx", "args": ["-y", "qiksy-mcp", "--port", "7333"], "env": { "QIKSY_MCP_TOKEN": "TOKEN_A" } },
|
|
298
|
+
"qiksy-2": { "command": "npx", "args": ["-y", "qiksy-mcp", "--port", "7334"], "env": { "QIKSY_MCP_TOKEN": "TOKEN_B" } }
|
|
299
299
|
} }
|
|
300
300
|
```
|
|
301
301
|
|
|
@@ -323,7 +323,7 @@ or `.mcp.json` at the project root:
|
|
|
323
323
|
```json
|
|
324
324
|
{ "mcpServers": { "qiksy": {
|
|
325
325
|
"type": "stdio",
|
|
326
|
-
"command": "npx", "args": ["qiksy-mcp", "--port", "7333"],
|
|
326
|
+
"command": "npx", "args": ["-y", "qiksy-mcp", "--port", "7333"],
|
|
327
327
|
"env": { "QIKSY_MCP_TOKEN": "YOUR_TOKEN" }
|
|
328
328
|
} } }
|
|
329
329
|
```
|
|
@@ -334,7 +334,7 @@ Same canonical shape:
|
|
|
334
334
|
|
|
335
335
|
```json
|
|
336
336
|
{ "mcpServers": { "qiksy": {
|
|
337
|
-
"command": "npx", "args": ["qiksy-mcp", "--port", "7333"],
|
|
337
|
+
"command": "npx", "args": ["-y", "qiksy-mcp", "--port", "7333"],
|
|
338
338
|
"env": { "QIKSY_MCP_TOKEN": "YOUR_TOKEN" }
|
|
339
339
|
} } }
|
|
340
340
|
```
|
|
@@ -344,7 +344,7 @@ Same canonical shape:
|
|
|
344
344
|
```json
|
|
345
345
|
{ "context_servers": { "qiksy": {
|
|
346
346
|
"source": "custom",
|
|
347
|
-
"command": "npx", "args": ["qiksy-mcp", "--port", "7333"],
|
|
347
|
+
"command": "npx", "args": ["-y", "qiksy-mcp", "--port", "7333"],
|
|
348
348
|
"env": { "QIKSY_MCP_TOKEN": "YOUR_TOKEN" }
|
|
349
349
|
} } }
|
|
350
350
|
```
|
|
@@ -356,7 +356,7 @@ Same canonical shape:
|
|
|
356
356
|
"inputs": [{ "type": "promptString", "id": "qac-token", "description": "Qiksy MCP token", "password": true }],
|
|
357
357
|
"servers": { "qiksy": {
|
|
358
358
|
"type": "stdio",
|
|
359
|
-
"command": "npx", "args": ["qiksy-mcp", "--port", "7333"],
|
|
359
|
+
"command": "npx", "args": ["-y", "qiksy-mcp", "--port", "7333"],
|
|
360
360
|
"env": { "QIKSY_MCP_TOKEN": "${input:qac-token}" }
|
|
361
361
|
} }
|
|
362
362
|
}
|
package/connect.mjs
CHANGED
|
@@ -72,9 +72,17 @@ export function agentTargets({ home = homedir(), platform = osPlatform(), dir =
|
|
|
72
72
|
* would have coped anyway.
|
|
73
73
|
*/
|
|
74
74
|
export function serverEntry({ port, token, platform = osPlatform() }) {
|
|
75
|
+
/* `-y` IS NOT A CONVENIENCE — IT IS WHAT STOPS THE START HANGING.
|
|
76
|
+
*
|
|
77
|
+
* Without it, an npx that does not already hold the package ASKS: «Need to install the following
|
|
78
|
+
* packages… Ok to proceed?» — and it asks on stdin, which in an MCP server IS the protocol. The
|
|
79
|
+
* client's `initialize` frame becomes the answer to a yes/no question, nothing replies, and the
|
|
80
|
+
* agent reports `MCP server qiksy connection timed out after 30000ms` with no server-side trace
|
|
81
|
+
* at all, because the server never ran. It bites hardest right after a release, when the cached
|
|
82
|
+
* version is no longer the latest one — which is exactly when it was reported (21.09.2026). */
|
|
75
83
|
const base = platform === 'win32'
|
|
76
|
-
? { command: 'cmd', args: ['/c', 'npx', 'qiksy-mcp', '--port', String(port)] }
|
|
77
|
-
: { command: 'npx', args: ['qiksy-mcp', '--port', String(port)] };
|
|
84
|
+
? { command: 'cmd', args: ['/c', 'npx', '-y', 'qiksy-mcp', '--port', String(port)] }
|
|
85
|
+
: { command: 'npx', args: ['-y', 'qiksy-mcp', '--port', String(port)] };
|
|
78
86
|
return { ...base, env: { QIKSY_MCP_TOKEN: token } };
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -122,6 +130,68 @@ export function portInPlace(targets) {
|
|
|
122
130
|
return 0;
|
|
123
131
|
}
|
|
124
132
|
|
|
133
|
+
/**
|
|
134
|
+
* THE PAIRING, FOR A COMMAND THAT HAS NO ENVIRONMENT — i.e. the finish hook.
|
|
135
|
+
*
|
|
136
|
+
* The server proper is started BY the agent, which hands it the token in the entry's `env` block.
|
|
137
|
+
* A Stop hook is not started that way: it is a bare command in a bare shell, so QIKSY_MCP_TOKEN is
|
|
138
|
+
* empty, the hub closes the unauthenticated join — correctly — and the hook then fails in the one
|
|
139
|
+
* way nobody notices: silently, once per run, into a log no one reads. Measured on a real setup
|
|
140
|
+
* (21.09.2026): `npx qiksy-mcp done` answered «the bridge closed the connection» and the browser
|
|
141
|
+
* never heard a thing, while the same command with the token in front of it rang through.
|
|
142
|
+
*
|
|
143
|
+
* So the hook reads the same pairing the agent itself uses. `~/.claude.json` is in the search
|
|
144
|
+
* because that is where `claude mcp add` puts an entry — top level, or under projects/<cwd> — and
|
|
145
|
+
* it is READ ONLY here: connect writes `.mcp.json` instead, and a command that fires on every stop
|
|
146
|
+
* must never rewrite the file that holds every project's servers.
|
|
147
|
+
*/
|
|
148
|
+
export function pairingInPlace({ dir = process.cwd(), home = homedir(), platform = osPlatform() } = {}) {
|
|
149
|
+
const targets = agentTargets({ dir, home, platform });
|
|
150
|
+
const found = { token: tokenInPlace(targets), port: portInPlace(targets) };
|
|
151
|
+
if (found.token && found.port) return found;
|
|
152
|
+
|
|
153
|
+
for (const entry of claudeJsonEntries(join(home, '.claude.json'), dir)) {
|
|
154
|
+
const tok = entry?.env?.QIKSY_MCP_TOKEN;
|
|
155
|
+
if (!found.token && typeof tok === 'string' && tok.length >= 16) found.token = tok;
|
|
156
|
+
if (!found.port) {
|
|
157
|
+
const args = Array.isArray(entry?.args) ? entry.args : [];
|
|
158
|
+
const i = args.indexOf('--port');
|
|
159
|
+
const p = i !== -1 ? Number(args[i + 1]) : NaN;
|
|
160
|
+
if (Number.isInteger(p) && p > 0) found.port = p;
|
|
161
|
+
}
|
|
162
|
+
if (found.token && found.port) break;
|
|
163
|
+
}
|
|
164
|
+
return found;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Every qiksy entry in `~/.claude.json`, THE ONE FOR THIS FOLDER FIRST.
|
|
169
|
+
*
|
|
170
|
+
* Order is the whole point: the token is the same everywhere (the popup has one field), but the
|
|
171
|
+
* PORT is per project, and a hook that picks a neighbouring project's port rings a bridge that
|
|
172
|
+
* belongs to somebody else's run. Deepest matching project path wins, then the global entry, then
|
|
173
|
+
* whatever else is there — the last one so a folder that was never connected still finds the
|
|
174
|
+
* pairing instead of going silent.
|
|
175
|
+
*/
|
|
176
|
+
function claudeJsonEntries(file, dir) {
|
|
177
|
+
let json;
|
|
178
|
+
try {
|
|
179
|
+
json = JSON.parse(readFileSync(file, 'utf8'));
|
|
180
|
+
} catch {
|
|
181
|
+
return []; // missing or hand-broken: not our file to fix, and not a reason to fail
|
|
182
|
+
}
|
|
183
|
+
const projects = json?.projects && typeof json.projects === 'object' ? json.projects : {};
|
|
184
|
+
const mine = Object.keys(projects)
|
|
185
|
+
.filter((p) => dir === p || dir.startsWith(`${p}/`))
|
|
186
|
+
.sort((a, b) => b.length - a.length);
|
|
187
|
+
const rest = Object.keys(projects).filter((p) => !mine.includes(p));
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const p of mine) if (projects[p]?.mcpServers?.qiksy) out.push(projects[p].mcpServers.qiksy);
|
|
190
|
+
if (json?.mcpServers?.qiksy) out.push(json.mcpServers.qiksy);
|
|
191
|
+
for (const p of rest) if (projects[p]?.mcpServers?.qiksy) out.push(projects[p].mcpServers.qiksy);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
125
195
|
/** Write our one entry, keep everything else in the file exactly as it was. */
|
|
126
196
|
export function writeEntry(target, entry) {
|
|
127
197
|
const { json, existed } = readConfig(target.file);
|
|
@@ -213,8 +283,15 @@ export const mintToken = () => randomBytes(18).toString('base64url');
|
|
|
213
283
|
export async function connectAgents({ dir = process.cwd(), home = homedir(), platform = osPlatform(), port, token, hook = true } = {}) {
|
|
214
284
|
const targets = agentTargets({ home, platform, dir });
|
|
215
285
|
const installed = targets.filter((t) => existsSync(t.needs));
|
|
216
|
-
|
|
217
|
-
|
|
286
|
+
/* THE SEARCH FOR AN EXISTING PAIRING IS WIDER THAN THE LIST WE WRITE INTO, and that is the whole
|
|
287
|
+
point of it. `claude mcp add` puts the entry in ~/.claude.json, which is not a file connect
|
|
288
|
+
writes; looking only at its own targets, a re-run found nothing, minted a FRESH token and wrote
|
|
289
|
+
it everywhere — leaving the browser paired with a secret nobody holds any more. It costs the
|
|
290
|
+
person a «Token rejected» on the next restart and a trip to the popup, for a command they ran
|
|
291
|
+
to fix something else. Seen live on the owner's own machine, 21.09.2026. */
|
|
292
|
+
const inPlace = pairingInPlace({ dir, home, platform });
|
|
293
|
+
const keptToken = token || inPlace.token;
|
|
294
|
+
const keptPort = port || inPlace.port;
|
|
218
295
|
const usePort = keptPort || (await freePort());
|
|
219
296
|
const useToken = keptToken || mintToken();
|
|
220
297
|
const entry = serverEntry({ port: usePort, token: useToken, platform });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.48.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",
|
package/server.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
26
26
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
27
27
|
import { z } from 'zod';
|
|
28
28
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
29
30
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
|
30
31
|
import { basename, extname, dirname, isAbsolute, resolve, join } from 'node:path';
|
|
31
32
|
import { homedir } from 'node:os';
|
|
@@ -35,7 +36,7 @@ import { project } from './project.mjs';
|
|
|
35
36
|
import { recordGap, listGaps, gapInvite, GAPS_FILE } from './gaps.mjs';
|
|
36
37
|
import { STATES, STATE_KEYS, renderGallery } from './gallery.mjs';
|
|
37
38
|
import { renderSearchableSvg } from './svg-shot.mjs';
|
|
38
|
-
import { connectAgents, toClipboard } from './connect.mjs';
|
|
39
|
+
import { connectAgents, toClipboard, pairingInPlace } from './connect.mjs';
|
|
39
40
|
|
|
40
41
|
const log = (...a) => console.error('[qiksy-mcp]', ...a);
|
|
41
42
|
|
|
@@ -855,17 +856,26 @@ if (process.argv[2] === 'done') {
|
|
|
855
856
|
* loudly because the browser is closed would be a hook people remove.
|
|
856
857
|
*/
|
|
857
858
|
const bad = process.argv.includes('--problem');
|
|
858
|
-
|
|
859
|
+
|
|
860
|
+
/* ONE ATTEMPT WITH ONE PAIRING. Resolves to the sentence to print, plus `refused` when the hub
|
|
861
|
+
shut the door on us — which is the only failure worth trying a second key on. */
|
|
862
|
+
const ring = (token, port) => new Promise((res) => {
|
|
859
863
|
let ws;
|
|
860
|
-
|
|
864
|
+
let answered = false;
|
|
865
|
+
const give = (said, refused = false) => {
|
|
866
|
+
if (answered) return;
|
|
867
|
+
answered = true;
|
|
868
|
+
try { ws?.close(); } catch { /* already gone */ }
|
|
869
|
+
res({ said, refused });
|
|
870
|
+
};
|
|
861
871
|
const timer = setTimeout(() => give('no answer from the bridge'), 6000);
|
|
862
872
|
try {
|
|
863
|
-
ws = new WebSocket(`ws://${HOST}:${
|
|
873
|
+
ws = new WebSocket(`ws://${HOST}:${port}`);
|
|
864
874
|
} catch {
|
|
865
875
|
clearTimeout(timer);
|
|
866
|
-
return
|
|
876
|
+
return give('could not reach the bridge');
|
|
867
877
|
}
|
|
868
|
-
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', v: PROTOCOL_VERSION, token
|
|
878
|
+
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', v: PROTOCOL_VERSION, token, pid: process.pid })));
|
|
869
879
|
ws.on('message', (raw) => {
|
|
870
880
|
let m;
|
|
871
881
|
try { m = JSON.parse(raw.toString()); } catch { return; }
|
|
@@ -873,9 +883,45 @@ if (process.argv[2] === 'done') {
|
|
|
873
883
|
if (m.t === 'res') { clearTimeout(timer); give(m.result?.soundNote || m.error || 'done'); }
|
|
874
884
|
});
|
|
875
885
|
ws.on('error', () => { clearTimeout(timer); give('no bridge on this port'); });
|
|
876
|
-
|
|
886
|
+
/* Closed before an answer = the token was refused. The hub says nothing more, by design: it
|
|
887
|
+
is an authentication gate, and a gate that explains itself is a gate that helps a stranger. */
|
|
888
|
+
ws.on('close', () => { clearTimeout(timer); give('the bridge closed the connection — the token it holds is a different one', true); });
|
|
877
889
|
});
|
|
878
|
-
|
|
890
|
+
|
|
891
|
+
/* TWO PLACES A TOKEN CAN COME FROM, AND THE ENVIRONMENT IS NOT THE TRUSTWORTHY ONE.
|
|
892
|
+
*
|
|
893
|
+
* A hook runs in a bare shell: no `env` block from the agent, so usually nothing — and sometimes
|
|
894
|
+
* WORSE than nothing, because an old QIKSY_MCP_TOKEN left in a .zshenv from a previous setup is
|
|
895
|
+
* exported into every shell on the machine and looks exactly like configuration. Found on the
|
|
896
|
+
* owner's own machine 21.09.2026: the shell carried a token three generations old, the hub
|
|
897
|
+
* refused it, and the command reported «the bridge closed the connection» — which reads as a
|
|
898
|
+
* browser problem, so the search starts in the popup, which was never wrong.
|
|
899
|
+
*
|
|
900
|
+
* So the environment is tried FIRST (an explicit export must still win when it is right), and a
|
|
901
|
+
* refusal falls through to the pairing the agent itself is using. Two attempts, never more. */
|
|
902
|
+
const tries = [];
|
|
903
|
+
if (TOKEN) tries.push({ token: TOKEN, port: PORT, from: 'the environment' });
|
|
904
|
+
const p = pairingInPlace({ dir: process.cwd() });
|
|
905
|
+
if (p.token && p.token !== TOKEN) {
|
|
906
|
+
const own = portArg === -1 && !process.env.QIKSY_MCP_PORT && p.port ? p.port : PORT;
|
|
907
|
+
tries.push({ token: p.token, port: own, from: 'your agent config' });
|
|
908
|
+
}
|
|
909
|
+
if (!tries.length) {
|
|
910
|
+
/* Naming the missing half, because «the bridge closed the connection» sent the last person
|
|
911
|
+
looking at the browser — the one part that was working. */
|
|
912
|
+
log('no token: set QIKSY_MCP_TOKEN, or run `npx qiksy-mcp connect` in this project first');
|
|
913
|
+
process.exit(0);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
let last = '';
|
|
917
|
+
for (const t of tries) {
|
|
918
|
+
const r = await ring(t.token, t.port);
|
|
919
|
+
last = r.said;
|
|
920
|
+
if (!r.refused) break;
|
|
921
|
+
/* Said out loud only when there IS a second key to try — otherwise it is noise on every stop. */
|
|
922
|
+
if (t !== tries[tries.length - 1]) log(`the token in ${t.from} was refused — trying the one in your agent config`);
|
|
923
|
+
}
|
|
924
|
+
log(last);
|
|
879
925
|
process.exit(0);
|
|
880
926
|
}
|
|
881
927
|
|
|
@@ -918,6 +964,48 @@ let role = 'starting'; // 'hub' | 'follower'
|
|
|
918
964
|
let hubSocket = null; // follower → its socket to the hub
|
|
919
965
|
const followers = new Set(); // hub → the sibling agent windows riding on it
|
|
920
966
|
let foreignPort = false; // the port is held by something that is not a Qiksy hub
|
|
967
|
+
/* Set when the process on the port answered like a Qiksy bridge and REFUSED us (close 4001).
|
|
968
|
+
«Refused» and «not ours at all» are different problems with different repairs, and printing
|
|
969
|
+
one list of three guesses for both is what cost a tester «very much time» (report, 21.09.2026). */
|
|
970
|
+
let portRefusedUs = false;
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* WHO IS ON THE PORT — asked of the operating system, not guessed.
|
|
974
|
+
*
|
|
975
|
+
* The old message named three suspects at once («another process, a bridge running a different
|
|
976
|
+
* token, or an OLDER qiksy-mcp») and left the reader to work out which. The holder's pid and
|
|
977
|
+
* command line are one cheap call away on every machine this runs on, and a name ends the search
|
|
978
|
+
* instead of starting it. Cached: this is a diagnostic, not a heartbeat.
|
|
979
|
+
*/
|
|
980
|
+
let holderCache = null;
|
|
981
|
+
function portHolder(port) {
|
|
982
|
+
if (holderCache && Date.now() - holderCache.at < 5_000) return holderCache.text;
|
|
983
|
+
let text = '';
|
|
984
|
+
try {
|
|
985
|
+
if (process.platform === 'win32') {
|
|
986
|
+
const net = spawnSync('netstat', ['-ano'], { encoding: 'utf8', timeout: 2000 });
|
|
987
|
+
const line = (net.stdout || '').split('\n').find((l) => l.includes(`:${port}`) && /LISTENING/i.test(l));
|
|
988
|
+
const pid = line?.trim().split(/\s+/).pop();
|
|
989
|
+
if (pid) {
|
|
990
|
+
const task = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', timeout: 2000 });
|
|
991
|
+
const name = (task.stdout || '').split(',')[0]?.replace(/"/g, '').trim();
|
|
992
|
+
text = name ? `pid ${pid} (${name})` : `pid ${pid}`;
|
|
993
|
+
}
|
|
994
|
+
} else {
|
|
995
|
+
const ls = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8', timeout: 2000 });
|
|
996
|
+
const pid = (ls.stdout || '').trim().split('\n')[0];
|
|
997
|
+
if (pid) {
|
|
998
|
+
const ps = spawnSync('ps', ['-o', 'command=', '-p', pid], { encoding: 'utf8', timeout: 2000 });
|
|
999
|
+
const cmd = (ps.stdout || '').trim().slice(0, 120);
|
|
1000
|
+
text = cmd ? `pid ${pid} — ${cmd}` : `pid ${pid}`;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
} catch {
|
|
1004
|
+
/* no lsof, no permission, a locked-down box: the message is still better without this line */
|
|
1005
|
+
}
|
|
1006
|
+
holderCache = { at: Date.now(), text };
|
|
1007
|
+
return text;
|
|
1008
|
+
}
|
|
921
1009
|
let wss = null;
|
|
922
1010
|
let takeoverTimer = null;
|
|
923
1011
|
let takeoverDelay = 400;
|
|
@@ -993,12 +1081,16 @@ function becomeFollower() {
|
|
|
993
1081
|
}
|
|
994
1082
|
if (msg.t === 'res' && msg.id != null) settle(msg);
|
|
995
1083
|
});
|
|
996
|
-
ws.on('close', () => {
|
|
1084
|
+
ws.on('close', (code) => {
|
|
997
1085
|
if (hubSocket === ws) hubSocket = null;
|
|
998
1086
|
// A "hub" that never welcomed us is not one: either a foreign process holds the
|
|
999
1087
|
// port or it runs a different token. Say THAT — blaming the extension for this
|
|
1000
1088
|
// is the bug that started all of it.
|
|
1001
1089
|
foreignPort = !welcomed;
|
|
1090
|
+
/* 4001 is OUR OWN refusal code: whatever is there speaks this protocol and turned us away,
|
|
1091
|
+
which can only be the token. Anything else — a silent close, a foreign server, a reset —
|
|
1092
|
+
is a different problem and must not borrow that sentence. */
|
|
1093
|
+
portRefusedUs = !welcomed && code === 4001;
|
|
1002
1094
|
failPending(welcomed ? 'the agent window that owned the bridge closed mid-call' : bridgeUnavailableMessage());
|
|
1003
1095
|
scheduleTakeover();
|
|
1004
1096
|
});
|
|
@@ -1055,11 +1147,31 @@ function bridgeUnavailableMessage() {
|
|
|
1055
1147
|
);
|
|
1056
1148
|
}
|
|
1057
1149
|
if (foreignPort) {
|
|
1150
|
+
const who = portHolder(PORT);
|
|
1151
|
+
const holder = who ? ` The process holding it: ${who}.` : '';
|
|
1152
|
+
/* THE ONE FACT THIS PROCESS ALWAYS KNOWS ABOUT ITSELF comes first. A window started with no
|
|
1153
|
+
token is refused by every bridge there is, and no amount of looking at the port explains
|
|
1154
|
+
that — the answer is in this window's own environment (report, 21.09.2026: «as soon as the
|
|
1155
|
+
token matched — connected instantly», after a long hunt through the port). */
|
|
1156
|
+
if (!TOKEN) {
|
|
1157
|
+
return (
|
|
1158
|
+
`This window has no QIKSY_MCP_TOKEN, so the bridge already on port ${PORT} refuses it — the token is the reason, ` +
|
|
1159
|
+
`not the port.${holder} Put the same token in this window's MCP config (env QIKSY_MCP_TOKEN) as the one in the ` +
|
|
1160
|
+
'Qiksy popup — `npx qiksy-mcp connect` writes it for you and keeps the pairing you already have.'
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
if (portRefusedUs) {
|
|
1164
|
+
return (
|
|
1165
|
+
`Port ${PORT} is held by another Qiksy bridge, and it REFUSED this window's token — so the two are running ` +
|
|
1166
|
+
`different tokens.${holder} Nothing here is fixed in the popup. Give both windows the same token (\`npx qiksy-mcp connect\` ` +
|
|
1167
|
+
'in each project writes the pairing you already have), or give this window its own port (--port / QIKSY_MCP_PORT).'
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1058
1170
|
return (
|
|
1059
|
-
`Port ${PORT} is held by
|
|
1060
|
-
|
|
1061
|
-
'
|
|
1062
|
-
'
|
|
1171
|
+
`Port ${PORT} is held by a process that is not a Qiksy bridge this one can share with — either something else ` +
|
|
1172
|
+
`entirely, or an OLDER qiksy-mcp (pre-1.1) that cannot host sibling agent windows.${holder} The extension itself is ` +
|
|
1173
|
+
'probably fine — this is NOT fixed in the popup. Restart or update whatever owns the port, or give this window its ' +
|
|
1174
|
+
'own port (--port / QIKSY_MCP_PORT).'
|
|
1063
1175
|
);
|
|
1064
1176
|
}
|
|
1065
1177
|
if (role === 'follower') {
|
|
@@ -4533,15 +4645,22 @@ server.registerTool(
|
|
|
4533
4645
|
take: z
|
|
4534
4646
|
.array(z.string())
|
|
4535
4647
|
.optional()
|
|
4536
|
-
.describe('Which properties, e.g. ["name","value","state"] or ["text","attr:data-id","style:color"]. Default ["name","text"].'),
|
|
4648
|
+
.describe('Which properties, e.g. ["name","value","state"] or ["text","attr:data-id","style:color"]. Default ["name","text"]. Alias: `properties`.'),
|
|
4649
|
+
/* THE NAME THE DESCRIPTION INVITES. This tool's own sentence is «which of their PROPERTIES
|
|
4650
|
+
to take», so an agent reasonably sends `properties` — and zod strips an unknown key
|
|
4651
|
+
silently, leaving the default ["name","text"]. The columns then look unsupported: a
|
|
4652
|
+
tester reported exactly that («properties were silently ignored», 21.09.2026) about a
|
|
4653
|
+
feature that works. A synonym costs one line; a silent default costs a session. */
|
|
4654
|
+
properties: z.array(z.string()).optional().describe('Same as `take`, for when that is the word that came to mind.'),
|
|
4537
4655
|
limit: z.number().optional().describe('Cap the rows (max 300). A capped answer says so rather than looking complete.'),
|
|
4538
4656
|
select: selectArg,
|
|
4539
4657
|
omit: omitArg,
|
|
4540
4658
|
},
|
|
4541
4659
|
},
|
|
4542
|
-
async ({ tabId, nodes, take, limit, select, omit }) => {
|
|
4660
|
+
async ({ tabId, nodes, take, properties, limit, select, omit }) => {
|
|
4543
4661
|
try {
|
|
4544
|
-
|
|
4662
|
+
const want = take?.length ? take : properties?.length ? properties : take;
|
|
4663
|
+
return asText(project(await callExtension('qa_collect', { tabId, nodes, take: want, limit }, 25_000), { select, omit }));
|
|
4545
4664
|
} catch (e) {
|
|
4546
4665
|
return asError(e);
|
|
4547
4666
|
}
|
|
@@ -7218,12 +7337,30 @@ const SETTLED_BY_SHAPE = new Set(['date-sections', 'popup-date-input', 'file', '
|
|
|
7218
7337
|
*
|
|
7219
7338
|
* Then Escape, so the probe leaves the form as it found it.
|
|
7220
7339
|
*/
|
|
7221
|
-
async function probeWidget(tabId, { name, selector, close = true }) {
|
|
7340
|
+
async function probeWidget(tabId, { name, selector, ref, close = true }) {
|
|
7341
|
+
/* A REF IS RESOLVED BEFORE THE FRESH SNAPSHOT, NEVER AGAINST IT. `ref` is the identifier every
|
|
7342
|
+
other verb takes, so refusing it here with «pass name or selector» reads as an inconsistency
|
|
7343
|
+
(report, 21.09.2026) — but a ref belongs to ONE snapshot and the one taken below RENUMBERS
|
|
7344
|
+
them, so the number is looked up in the snapshot it came from and the node is then re-found
|
|
7345
|
+
by what it WAS: selector first, its name second. */
|
|
7346
|
+
const was = ref != null ? recallNode(tabId, ref) : null;
|
|
7347
|
+
if (ref != null && !was) {
|
|
7348
|
+
return {
|
|
7349
|
+
ok: false,
|
|
7350
|
+
error: `ref ${ref} is not in the last snapshot of this tab — take a qa_snapshot first, or pass \`name\`/\`selector\``,
|
|
7351
|
+
};
|
|
7352
|
+
}
|
|
7222
7353
|
const snap = await callExtension('qa_snapshot', { tabId }, 30_000);
|
|
7223
7354
|
rememberSnapshot(tabId, snap);
|
|
7224
7355
|
const nodes = Array.isArray(snap?.nodes) ? snap.nodes : [];
|
|
7225
7356
|
let node = null;
|
|
7226
|
-
if (
|
|
7357
|
+
if (was) {
|
|
7358
|
+
node = (was.selector && nodes.find((n) => n.selector === was.selector)) || (was.name ? pickNode(nodes, { name: was.name }, new Set())?.node : null) || null;
|
|
7359
|
+
if (!node) {
|
|
7360
|
+
return { ok: false, error: `the control behind ref ${ref} ("${was.name || was.selector}") is no longer on the page` };
|
|
7361
|
+
}
|
|
7362
|
+
selector = selector || was.selector;
|
|
7363
|
+
} else if (name) {
|
|
7227
7364
|
node = pickNode(nodes, { name }, new Set())?.node || null;
|
|
7228
7365
|
if (!node) {
|
|
7229
7366
|
return {
|
|
@@ -7235,7 +7372,7 @@ async function probeWidget(tabId, { name, selector, close = true }) {
|
|
|
7235
7372
|
} else if (selector) {
|
|
7236
7373
|
node = nodes.find((n) => n.selector === selector) || null;
|
|
7237
7374
|
} else {
|
|
7238
|
-
return { ok: false, error: 'pass `name` or `selector` — there is nothing to probe otherwise' };
|
|
7375
|
+
return { ok: false, error: 'pass `ref`, `name` or `selector` — there is nothing to probe otherwise' };
|
|
7239
7376
|
}
|
|
7240
7377
|
|
|
7241
7378
|
const structural = node ? classifyNode(node, nodes) : null;
|
|
@@ -7286,13 +7423,14 @@ server.registerTool(
|
|
|
7286
7423
|
tabId: tabIdArg,
|
|
7287
7424
|
name: z.string().optional().describe("The control's accessible name, as a human reads it on screen"),
|
|
7288
7425
|
selector: z.string().optional().describe('CSS selector — for a control with no accessible name at all, which is the case this tool exists for'),
|
|
7426
|
+
ref: z.number().optional().describe('A ref from the last qa_snapshot of this tab. Resolved against THAT snapshot and re-found on the fresh one, so it survives the renumbering.'),
|
|
7289
7427
|
close: z.boolean().optional().describe('Press Escape afterwards to close whatever opened. Default true; pass false when you want to keep the popup open and read it'),
|
|
7290
7428
|
},
|
|
7291
7429
|
},
|
|
7292
|
-
async ({ tabId, name, selector, close }, extra) => {
|
|
7430
|
+
async ({ tabId, name, selector, ref, close }, extra) => {
|
|
7293
7431
|
const bar = loader(extra, 'working out what that widget is');
|
|
7294
7432
|
try {
|
|
7295
|
-
return asText(await probeWidget(tabId, { name, selector, close: close !== false }));
|
|
7433
|
+
return asText(await probeWidget(tabId, { name, selector, ref, close: close !== false }));
|
|
7296
7434
|
} catch (e) {
|
|
7297
7435
|
return asError(e);
|
|
7298
7436
|
} finally {
|