qiksy-mcp 1.43.0 → 1.45.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/README.md +1 -1
  2. package/package.json +1 -1
  3. package/server.mjs +226 -13
package/README.md CHANGED
@@ -22,7 +22,7 @@ unless the licence (or the 28-day trial) is valid, so once a trial lapses *every
22
22
  tool fails — `qa_export` included — with a message saying so. Do not plan a
23
23
  free-tier integration around read-only tools.
24
24
 
25
- **npm carries `qiksy-mcp@1.43.0`** — the current build, with all sixty-two tools
25
+ **npm carries `qiksy-mcp@1.44.0`** — the current build, with all sixty-two tools
26
26
  including a filterable full-page `qa_snapshot`, `qa_fill_json` (a whole wizard step —
27
27
  custom dropdowns, calendars, cascaders, trees, tags and time included — in one call),
28
28
  the `qa_pick_*` verbs for the composites, `qa_probe` for the one control that refuses,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "1.43.0",
3
+ "version": "1.45.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
@@ -825,6 +825,48 @@ if (process.argv[2] === 'connect') {
825
825
  await runConnect(process.argv.slice(3));
826
826
  process.exit(0);
827
827
  }
828
+ if (process.argv[2] === 'done') {
829
+ /**
830
+ * «THE RUN IS OVER» FROM OUTSIDE THE MODEL — for any agent whose client can run a command when
831
+ * it finishes a turn (Claude Code's Stop hook, and the equivalent in the others).
832
+ *
833
+ * The verb `qa_done` is the ordinary route and every MCP client is told about it in the
834
+ * instructions. This is the route that does not depend on a model remembering: a hook fires
835
+ * whether or not the agent thought to say anything, so the person in the next room hears the
836
+ * end of the run either way.
837
+ *
838
+ * npx qiksy-mcp done → the run ended well
839
+ * npx qiksy-mcp done --problem → it ended badly
840
+ *
841
+ * It joins the bridge the SAME WAY a sibling agent window does, as a follower of whoever owns
842
+ * the port — so it never takes the port, never disturbs a running session, and costs one
843
+ * socket for under a second. If nothing is listening it says so and exits 0: a hook that fails
844
+ * loudly because the browser is closed would be a hook people remove.
845
+ */
846
+ const bad = process.argv.includes('--problem');
847
+ const said = await new Promise((res) => {
848
+ let ws;
849
+ const give = (msg) => { try { ws?.close(); } catch { /* already gone */ } res(msg); };
850
+ const timer = setTimeout(() => give('no answer from the bridge'), 6000);
851
+ try {
852
+ ws = new WebSocket(`ws://${HOST}:${PORT}`);
853
+ } catch {
854
+ clearTimeout(timer);
855
+ return res('could not reach the bridge');
856
+ }
857
+ ws.on('open', () => ws.send(JSON.stringify({ t: 'join', v: PROTOCOL_VERSION, token: TOKEN, pid: process.pid })));
858
+ ws.on('message', (raw) => {
859
+ let m;
860
+ try { m = JSON.parse(raw.toString()); } catch { return; }
861
+ if (m.t === 'welcome') return ws.send(JSON.stringify({ t: 'req', id: 'done', tool: 'qa_done', args: { verdict: bad ? 'problem' : 'ok' }, timeoutMs: 5000 }));
862
+ if (m.t === 'res') { clearTimeout(timer); give(m.result?.soundNote || m.error || 'done'); }
863
+ });
864
+ ws.on('error', () => { clearTimeout(timer); give('no bridge on this port'); });
865
+ ws.on('close', () => { clearTimeout(timer); give('the bridge closed the connection'); });
866
+ });
867
+ log(said);
868
+ process.exit(0);
869
+ }
828
870
 
829
871
  if (!TOKEN) {
830
872
  log('WARNING: QIKSY_MCP_TOKEN is not set. Set the same token here and in the Qiksy popup, or connections are refused.');
@@ -4344,6 +4386,16 @@ that rides with it: re-say it in that language rather than translating it word f
4344
4386
  the tone it names. It is rate-limited, so it will not become a tic. QIKSY_MCP_VOICE=nice softens
4345
4387
  it, =off removes it.
4346
4388
 
4389
+ SAY WHEN YOU HAVE FINISHED — qa_done, one call, at the end of the run. The person who set you off
4390
+ is usually not watching: they are in another window, another screen, another room. That call makes
4391
+ the mark on every driven tab they are NOT looking at pulse slowly, and plays a short sound, so the
4392
+ end of the work reaches them where they actually are. It stops by itself the moment they look at
4393
+ one of those tabs; there is nothing for them to dismiss.
4394
+ It is the END, not a progress beat. A full stop after nothing happened is refused and says so, and
4395
+ a second one within half a minute stays silent — both on purpose, because a signal that cries wolf
4396
+ is one people switch off in a week. Call it once, when the work is actually over, and still write
4397
+ what you did in your own words: this is a nudge towards the screen, never the report itself.
4398
+
4347
4399
  Two failures are NOT yours to fix, and each names its own cause: "extension is not connected"
4348
4400
  means the MCP bridge toggle in the popup is off (or its token/port differ) — only the user can
4349
4401
  turn it on. "Port … is held by a process that is not a Qiksy bridge" means something else owns
@@ -4601,7 +4653,9 @@ server.registerTool(
4601
4653
  description:
4602
4654
  'ADD MARKS TO A SHOT ON THE SHELF. They are stored beside the clean image, never burned into it, so the tester can move them, resize them and delete them afterwards — and so you can read them back later. ' +
4603
4655
  'Coordinates are in IMAGE pixels; qa_screenshot returns a map of the page in exactly those pixels, so a box from the map can be passed straight through. ' +
4604
- 'Shapes: `box` round a region, `arrow` from one point to another, `text` for a caption with its own words. Weight is `k`; colour is one of the five the panel offers. ' +
4656
+ 'Shapes: `box` round a region, `arrow` from one point to another, `text` for a caption with its own words, `blur` for a pane that makes what is under it unreadable. Weight is `k`; colour is one of the five the panel offers. ' +
4657
+ 'A caption carries an optional `to` — the point it is ABOUT — and a leader line is then drawn from the plate to that point, in both the editor and the exported file. Give a box a caption with `to` set to the middle of that box and you get the callout a person would draw by hand; the tester can then drag the words anywhere and the line follows. ' +
4658
+ 'A `blur` pane hides an address, a customer name or a staging token. It is applied when the picture is EXPORTED, copied or built into a report — the clean shot stays clean on the shelf, which is what lets the pane be moved and removed afterwards. ' +
4605
4659
  'Adds by default; pass `replace: true` to put your marks in place of everything on the picture — which is the destructive one, and it is opt-in for that reason. ' +
4606
4660
  'The reply is the STATE — how many marks the picture now carries and all of them in full — rather than the word «done».',
4607
4661
  inputSchema: {
@@ -4627,6 +4681,15 @@ server.registerTool(
4627
4681
  x: z.number(), y: z.number(),
4628
4682
  s: z.string().describe('The caption itself'),
4629
4683
  c: z.string(), k: z.number().optional().describe('Type size, 8–96'),
4684
+ to: z
4685
+ .object({ x: z.number(), y: z.number() })
4686
+ .optional()
4687
+ .describe('The point this caption is about — a leader line is drawn from the plate to it, and it follows the words if the tester moves them'),
4688
+ }),
4689
+ z.object({
4690
+ t: z.literal('blur'),
4691
+ x: z.number(), y: z.number(), w: z.number(), h: z.number(),
4692
+ k: z.number().optional().describe('How hard it hides, 4–60 (default 14). No colour: a pane is not a remark about the page, it is a piece of the page taken away.'),
4630
4693
  }),
4631
4694
  ]),
4632
4695
  )
@@ -4731,19 +4794,30 @@ server.registerTool(
4731
4794
  title: 'Assemble a report by naming blocks — no template, no markup',
4732
4795
  description:
4733
4796
  'A SELF-CONTAINED HTML REPORT, built by ENUMERATION. You pass an ordered list of blocks with plain text fields; every scrap of markup, styling, escaping and picture-embedding happens on our side. No template is accepted and none can be — a verb here names an element or a value from a closed list, never a program. ' +
4797
+ 'IT IS NOT ONLY A BUG REPORT, and the block set is what makes that true. `finding` says something is wrong; `point` makes the same card with a tone of good · info · warn · bad and NO «expected» line, which is how a check that passed, a design note or a description of how a screen behaves gets written down; `section` gives a long document headings. A sign-off, a walkthrough for a developer, a page of observations for a designer and a defect list are the same paper here. ' +
4734
4798
  'Pictures come from the shelf BY NAME and are flattened on the way in: the marks and the crop have been data up to that moment precisely so they could still be changed. ' +
4735
4799
  'Print the result to get a PDF — the stylesheet is written for paper as well as for screen. ' +
4800
+ 'PASS `save` TO KEEP IT IN THE EXTENSION: the panel’s Shots tab then lists it under Reports with Open and Save, and qa_reports / qa_report_get read it back by name later. Without it the document exists only in this reply and in whatever file you write. ' +
4736
4801
  'IT REFUSES, and the refusals are the point. No blocks, a block naming a shot that is not on the shelf, or NOT ONE PICTURE anywhere in the document — each fails the whole build rather than quietly dropping a block: a page of assertions about an application with nothing showing any of it is the artefact this product exists to replace. ' +
4737
4802
  'EXPECTED IS NEVER INVENTED. Leave `expected` out and the field says «not stated — a person fills this in». Filling it from your own guess turns a report into a hypothesis wearing the clothes of a protocol. ' +
4738
- 'The reply is an INVENTORY — blocks, pictures embedded, findings, bytes, warnings — not the word «done».',
4803
+ 'The reply is an INVENTORY — blocks, pictures embedded, findings, points, bytes, warnings — not the word «done».',
4739
4804
  inputSchema: {
4740
4805
  tabId: tabIdArg,
4741
4806
  out: z.string().optional().describe('Write the HTML here. Without it the document comes back inline, which for a report with pictures in it is usually the wrong trade.'),
4807
+ save: z
4808
+ .union([z.boolean(), z.string()])
4809
+ .optional()
4810
+ .describe('Keep it in the extension so a person can find it later in the panel. `true` names it after the page; a STRING names it yourself, and building again under the same name REPLACES that document rather than leaving a stale twin above it.'),
4811
+ kind: z
4812
+ .enum(['issues', 'verified', 'notes'])
4813
+ .optional()
4814
+ .describe('What this document IS, for the list in the panel: `issues` (things that are wrong — the default), `verified` (checked and it holds), `notes` (a description, no verdict). It changes no layout — the blocks do that.'),
4742
4815
  blocks: z
4743
4816
  .array(
4744
4817
  z.union([
4745
4818
  z.object({ block: z.literal('title'), text: z.string(), subtitle: z.string().optional() }),
4746
- z.object({ block: z.literal('summary'), text: z.string().describe('One paragraph of plain words: what is wrong.') }),
4819
+ z.object({ block: z.literal('summary'), text: z.string().describe('One paragraph of plain words: what this document is about.') }),
4820
+ z.object({ block: z.literal('section'), text: z.string().describe('A heading, so a document longer than one thought has a shape'), note: z.string().optional() }),
4747
4821
  z.object({ block: z.literal('shot'), shot: z.string().describe('A name from qa_shot_list'), caption: z.string().optional() }),
4748
4822
  z.object({
4749
4823
  block: z.literal('finding'),
@@ -4754,6 +4828,14 @@ server.registerTool(
4754
4828
  expected: z.string().optional().describe('Only if a PERSON said so. No verb here knows the requirements.'),
4755
4829
  where: z.string().optional().describe('Selector or place'),
4756
4830
  }),
4831
+ z.object({
4832
+ block: z.literal('point'),
4833
+ tone: z.enum(['good', 'info', 'warn', 'bad']).optional().describe('good = it holds · info = a neutral observation · warn · bad. Default info.'),
4834
+ title: z.string().describe('The line somebody will quote'),
4835
+ shot: z.string().optional().describe('The picture that shows it'),
4836
+ what: z.string().optional().describe('What is the case'),
4837
+ where: z.string().optional().describe('Selector or place'),
4838
+ }),
4757
4839
  z.object({ block: z.literal('steps'), items: z.array(z.string()).describe('How to reproduce, short and with real values') }),
4758
4840
  z.object({ block: z.literal('facts'), pairs: z.array(z.object({ name: z.string(), value: z.string() })) }),
4759
4841
  z.object({ block: z.literal('note'), text: z.string(), tone: z.enum(['info', 'warn']).optional() }),
@@ -4762,26 +4844,107 @@ server.registerTool(
4762
4844
  .describe('In document order. The conditions of the run — active mocks, suppressed dialogs — are added automatically and cannot be omitted by the caller.'),
4763
4845
  },
4764
4846
  },
4765
- async ({ tabId, blocks, out }, extra) => {
4847
+ async ({ tabId, blocks, out, save, kind }, extra) => {
4766
4848
  const bar = loader(extra, 'assembling the report');
4767
4849
  try {
4768
- const r = await callExtension('qa_report_build', { tabId, blocks }, 120_000);
4850
+ const r = await callExtension('qa_report_build', { tabId, blocks, save, kind }, 120_000);
4769
4851
  if (!r || r.ok === false || typeof r.html !== 'string') return asText(r);
4852
+ const kept = r.saved
4853
+ ? { saved: r.saved, savedNote: 'kept in the extension: the panel’s Shots tab lists it under Reports, and qa_reports reads it back by that name' }
4854
+ : {};
4770
4855
  if (out) {
4771
4856
  const full = resolve(out);
4772
4857
  mkdirSync(dirname(full), { recursive: true });
4773
4858
  writeFileSync(full, r.html, 'utf8');
4774
4859
  /* The funnel's question has been answered by the act itself; it must not ask again. */
4775
4860
  for (const [host, v] of reportFunnel) reportFunnel.set(host, { ...v, built: true });
4776
- return asText({ ok: true, path: full, blocks: r.blocks, shotsEmbedded: r.shotsEmbedded, findings: r.findings, bytes: r.bytes, warnings: r.warnings, note: 'Open it and print to PDF; the structure is the same on paper.' });
4861
+ return asText({ ok: true, path: full, blocks: r.blocks, shotsEmbedded: r.shotsEmbedded, findings: r.findings, points: r.points, bytes: r.bytes, warnings: r.warnings, ...kept, note: 'Open it and print to PDF; the structure is the same on paper.' });
4777
4862
  }
4778
- return asText({ ok: true, blocks: r.blocks, shotsEmbedded: r.shotsEmbedded, findings: r.findings, bytes: r.bytes, warnings: r.warnings, html: r.html });
4863
+ return asText({ ok: true, blocks: r.blocks, shotsEmbedded: r.shotsEmbedded, findings: r.findings, points: r.points, bytes: r.bytes, warnings: r.warnings, ...kept, html: r.html });
4779
4864
  } finally {
4780
4865
  bar.stop();
4781
4866
  }
4782
4867
  },
4783
4868
  );
4784
4869
 
4870
+ /**
4871
+ * THE REPORTS THE EXTENSION IS HOLDING.
4872
+ *
4873
+ * Three verbs and no fourth: list them, take one back, drop one. Writing is `qa_report_build`'s
4874
+ * `save` — a store with two ways in is a store whose two ways drift.
4875
+ *
4876
+ * The point of them is the hour AFTER the report was written. Until now a document left the
4877
+ * extension the moment it was built and Qiksy knew nothing about it afterwards, so «send me the
4878
+ * one from this morning» had no answer but «look in your Downloads». Now the panel lists it and
4879
+ * so does this, under the same name.
4880
+ */
4881
+ server.registerTool(
4882
+ 'qa_reports',
4883
+ {
4884
+ title: 'The reports the extension is keeping',
4885
+ description:
4886
+ 'EVERY SAVED REPORT, BY NAME, with its title, what kind it is, when it was built, how many blocks and pictures went in and how big it is. ' +
4887
+ 'These are the documents `qa_report_build { save }` kept, and they are the same ones the person sees in the panel — so «open the checkout one» means the same thing on both sides. ' +
4888
+ 'It deliberately does NOT return the HTML: a report with pictures inside is megabytes, and the question here is which ones exist. Use qa_report_get for one. Read-only.',
4889
+ inputSchema: { tabId: tabIdArg },
4890
+ },
4891
+ async ({ tabId }) => {
4892
+ try {
4893
+ return asText(await callExtension('qa_reports', { tabId }, 20_000));
4894
+ } catch (e) {
4895
+ return asError(e);
4896
+ }
4897
+ },
4898
+ );
4899
+
4900
+ server.registerTool(
4901
+ 'qa_report_get',
4902
+ {
4903
+ title: 'Take one saved report back out',
4904
+ description:
4905
+ 'THE DOCUMENT ITSELF, by the name qa_reports lists. Pass `out` to write it to a file — which is almost always what you want, because a self-contained report carries its pictures as data and spending that through this conversation buys nothing. ' +
4906
+ 'Without `out` the HTML comes back inline. Read-only: it takes a copy and leaves the extension’s own untouched.',
4907
+ inputSchema: {
4908
+ tabId: tabIdArg,
4909
+ name: z.string().describe('A name from qa_reports'),
4910
+ out: z.string().optional().describe('Write the HTML here instead of returning it inline'),
4911
+ },
4912
+ },
4913
+ async ({ tabId, name, out }) => {
4914
+ try {
4915
+ const r = await callExtension('qa_report_get', { tabId, name }, 30_000);
4916
+ if (!r || r.ok === false || typeof r.html !== 'string') return asText(r);
4917
+ if (out) {
4918
+ const full = resolve(out);
4919
+ mkdirSync(dirname(full), { recursive: true });
4920
+ writeFileSync(full, r.html, 'utf8');
4921
+ return asText({ ok: true, path: full, name: r.name, title: r.title, kind: r.kind, bytes: r.bytes, note: 'Open it and print to PDF; the structure is the same on paper.' });
4922
+ }
4923
+ return asText(r);
4924
+ } catch (e) {
4925
+ return asError(e);
4926
+ }
4927
+ },
4928
+ );
4929
+
4930
+ server.registerTool(
4931
+ 'qa_report_drop',
4932
+ {
4933
+ title: 'Forget one saved report',
4934
+ description:
4935
+ 'REMOVES A SAVED REPORT FROM THE EXTENSION — the row and the document together, because an index that forgets an entry while the megabytes stay is a leak nobody finds. ' +
4936
+ 'It touches nothing on the page and nothing on disk: a file you already wrote with `out` is yours and stays. The reply lists what is left.',
4937
+ inputSchema: { tabId: tabIdArg, name: z.string().describe('A name from qa_reports') },
4938
+ },
4939
+ async ({ tabId, name }) => {
4940
+ try {
4941
+ return asText(await callExtension('qa_report_drop', { tabId, name }, 20_000));
4942
+ } catch (e) {
4943
+ return asError(e);
4944
+ }
4945
+ },
4946
+ );
4947
+
4785
4948
  server.registerTool(
4786
4949
  'qa_wiring',
4787
4950
  {
@@ -4894,9 +5057,10 @@ server.registerTool(
4894
5057
  'A MARK THAT KNOWS WHAT IT POINTS AT. Name the element — a CSS selector — and the box is looked up from the page itself at capture time, so the same instruction draws the correct picture today, after a fix, and at another screen width. ' +
4895
5058
  'An arrow drawn on an ordinary screenshot points at a coordinate: right when it was drawn, wrong the moment anything re-renders. That is why annotated screenshots are disposable, and it is the whole difference here. ' +
4896
5059
  'It is also how a picture marks ITSELF: a finding already carries a selector, so «draw the contrast failures» needs no aiming — pass their selectors straight through. ' +
4897
- 'Shapes: `box` (outline it), `pin` (a numbered dot at its corner — for ordered things like a tab route), `arrow` and `line` (from one element to another, which is how you show an error message wired to the wrong field). Colours are named, not free: default · bad · good · info · warn. ' +
5060
+ 'Shapes: `box` (outline it), `pin` (a numbered dot at its corner — for ordered things like a tab route), `arrow` and `line` (from one element to another, which is how you show an error message wired to the wrong field), and `blur` (a pane that makes the element unreadable — an address, a customer\'s name, a token in a staging header). Colours are named, not free: default · bad · good · info · warn. ' +
5061
+ 'A LABEL IS A CALLOUT, not a sticker: it is placed in free space beside its element, clear of every other mark, and joined to the outline by a leader line ending in a dot. So «outline the payment block and say what is wrong with it» comes back as one readable statement instead of words stamped over the thing they are about. ' +
4898
5062
  'A mark whose element is not on the page is REPORTED in `missed`, never silently dropped — a reader who sees three arrows must be able to trust that there were three things to point at. ' +
4899
- 'Read-only: it photographs and draws, and touches nothing.',
5063
+ 'Read-only: it photographs and draws, and touches nothing — a blur hides the data in THIS picture, never on the page, so the next capture shows it again.',
4900
5064
  inputSchema: {
4901
5065
  tabId: tabIdArg,
4902
5066
  fromFindings: z
@@ -4906,13 +5070,15 @@ server.registerTool(
4906
5070
  marks: z
4907
5071
  .array(
4908
5072
  z.object({
4909
- kind: z.enum(['box', 'pin', 'arrow', 'line']).optional().describe("Default 'box'"),
5073
+ kind: z.enum(['box', 'pin', 'arrow', 'line', 'blur']).optional().describe("Default 'box'"),
4910
5074
  selector: z.string().optional().describe('The element to mark'),
4911
5075
  box: z.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() }).optional().describe('Or a box in IMAGE pixels, if you already have one from a shot map'),
4912
5076
  toSelector: z.string().optional().describe('For arrow/line: the element at the other end'),
4913
5077
  toBox: z.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() }).optional(),
4914
- label: z.string().optional().describe('Short text on the mark (three characters inside a pin, up to sixty beside a box)'),
5078
+ label: z.string().optional().describe('Short text on the mark — three characters inside a pin, up to sixty in a callout placed beside the element and joined to it by a leader line'),
4915
5079
  colour: z.enum(['default', 'bad', 'good', 'info', 'warn']).optional(),
5080
+ strength: z.number().optional().describe("For 'blur': how hard it hides, 4–60 (default 14)"),
5081
+ pad: z.number().optional().describe("For 'blur': extra margin round the element, in CSS pixels (default 2) — a pane flush with the text tends to leave the tops of the letters showing"),
4916
5082
  }),
4917
5083
  )
4918
5084
  .optional()
@@ -4963,12 +5129,12 @@ server.registerTool(
4963
5129
  const { resolve } = await import('node:path');
4964
5130
  const full = resolve(outPath);
4965
5131
  writeFileSync(full, Buffer.from(b64, 'base64'));
4966
- return asText({ ok: true, path: full, drawn: r.drawn, missed: r.missed, ...(skipped ? { findingsWithoutAPlace: skipped, findingsNote: `${skipped} finding(s) have no selector — a console error or a failed request is not AT a place on the page. They are in qa_findings, not in this picture.` } : {}), width: r.width, height: r.height, note: r.note });
5132
+ return asText({ ok: true, path: full, drawn: r.drawn, ...(r.hidden ? { hidden: r.hidden } : {}), missed: r.missed, ...(skipped ? { findingsWithoutAPlace: skipped, findingsNote: `${skipped} finding(s) have no selector — a console error or a failed request is not AT a place on the page. They are in qa_findings, not in this picture.` } : {}), width: r.width, height: r.height, note: r.note });
4967
5133
  }
4968
5134
  return {
4969
5135
  content: [
4970
5136
  { type: 'image', data: b64, mimeType: 'image/png' },
4971
- { type: 'text', text: JSON.stringify({ ok: true, drawn: r.drawn, missed: r.missed, ...(skipped ? { findingsWithoutAPlace: skipped } : {}), width: r.width, height: r.height, note: r.note }, null, 2) },
5137
+ { type: 'text', text: JSON.stringify({ ok: true, drawn: r.drawn, ...(r.hidden ? { hidden: r.hidden } : {}), missed: r.missed, ...(skipped ? { findingsWithoutAPlace: skipped } : {}), width: r.width, height: r.height, note: r.note }, null, 2) },
4972
5138
  ],
4973
5139
  };
4974
5140
  } catch (e) {
@@ -6482,6 +6648,53 @@ server.registerTool(
6482
6648
  },
6483
6649
  );
6484
6650
 
6651
+ /**
6652
+ * THE FULL STOP — the one thing a browser cannot work out for itself.
6653
+ *
6654
+ * Somebody sets an agent off on a long run and goes to do something else: another tab, another
6655
+ * screen, another room. Until now nothing told them it had ended. «No calls for a while» is not
6656
+ * the answer — a model that stops to think looks exactly like a model that has finished, and a
6657
+ * signal that cries wolf every thirty seconds gets switched off within the hour.
6658
+ *
6659
+ * So the run says so itself, and the browser turns that into something visible from across a
6660
+ * room: the mark on the tab pulses slowly — and stops the moment they look at that tab, because
6661
+ * being looked at IS the acknowledgement. A tab already in front is never pulsed at all.
6662
+ */
6663
+ server.registerTool(
6664
+ 'qa_done',
6665
+ {
6666
+ title: 'Say the run has finished',
6667
+ description:
6668
+ 'CALL THIS WHEN THE WORK IS ACTUALLY OVER — the last check made, the last fix verified, the answer about to be written. The mark on every driven tab the person is NOT looking at then pulses slowly, so somebody who walked away from the screen can see from across the room that it ended. It stops by itself when they look at that tab; there is nothing to dismiss. ' +
6669
+ 'It is a nudge, not a report: still say what you did, in your own words. And it is not a progress beat — calling it between steps teaches people to ignore it, which costs the signal its only job. Any further call cancels it, so a run that continues quietly withdraws it. ' +
6670
+ 'A short sound plays as well, for somebody who is not looking at the browser at all — the person can switch the sound off in the popup, and the pulse stays either way. ' +
6671
+ 'Free, and it touches nothing on the page.',
6672
+ inputSchema: {
6673
+ tabId: tabIdArg,
6674
+ verdict: z
6675
+ .enum(['ok', 'problem'])
6676
+ .optional()
6677
+ .describe('How it ended. `ok` (default) rises; `problem` falls — the same sound in two directions, so the ear tells them apart without being taught. Say what you actually found; a run that discovered a defect ended FINE if you finished the work.'),
6678
+ },
6679
+ },
6680
+ async ({ tabId, verdict }) => {
6681
+ try {
6682
+ return asText(await callExtension('qa_done', { tabId, verdict }, 15_000));
6683
+ } catch (e) {
6684
+ /* The verb lives in the EXTENSION, which updates on the Web Store's clock while this server
6685
+ updates with npx — «unknown tool» is the shape of that gap, and raw it reads as a bug. */
6686
+ if (/Unknown tool/i.test(String(e?.message || e))) {
6687
+ return asText({
6688
+ ok: false,
6689
+ error: 'not-in-this-extension-build',
6690
+ note: 'qa_done needs a newer extension in the browser. Nothing is lost: say you have finished in your own words, which is what the person reads anyway.',
6691
+ });
6692
+ }
6693
+ return asError(e);
6694
+ }
6695
+ },
6696
+ );
6697
+
6485
6698
  server.registerTool(
6486
6699
  'qa_close_tab',
6487
6700
  {