qiksy-mcp 1.45.0 → 1.46.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/connect.mjs +51 -1
  2. package/package.json +1 -1
  3. package/server.mjs +74 -0
package/connect.mjs CHANGED
@@ -131,6 +131,43 @@ export function writeEntry(target, entry) {
131
131
  return existed ? 'updated' : 'created';
132
132
  }
133
133
 
134
+ /**
135
+ * THE EXACT FINISH SIGNAL, for the one client that can give it.
136
+ *
137
+ * `qa_done` makes the tab pulse and the sound play, and the agent is asked to call it. The server
138
+ * also says it by itself after a long enough silence — but silence is an estimate, and Claude Code
139
+ * knows the real thing: it fires a `Stop` hook the moment the agent actually stops. One line in
140
+ * the project's settings turns the estimate into a fact.
141
+ *
142
+ * THREE RULES, and they are what makes writing into somebody's settings acceptable at all:
143
+ * · everything already in the file survives, including other Stop hooks;
144
+ * · running it twice adds nothing — the same command is recognised and left alone;
145
+ * · it is reported out loud by the caller, because a command that will run on every stop is not
146
+ * something to install quietly.
147
+ */
148
+ export const STOP_COMMAND = 'npx qiksy-mcp done';
149
+
150
+ export function stopHookFile(dir) {
151
+ return join(dir, '.claude', 'settings.json');
152
+ }
153
+
154
+ export function writeStopHook(file, command = STOP_COMMAND) {
155
+ const { json, existed } = readConfig(file);
156
+ const hooks = { ...(json.hooks || {}) };
157
+ const stop = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
158
+ /* Already there — from a previous run, or written by hand. Adding a second copy would make the
159
+ browser say «done» twice for one stop. */
160
+ const has = stop.some((g) =>
161
+ Array.isArray(g?.hooks) && g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('qiksy-mcp done')),
162
+ );
163
+ if (has) return 'already there';
164
+ stop.push({ hooks: [{ type: 'command', command }] });
165
+ hooks.Stop = stop;
166
+ mkdirSync(join(file, '..'), { recursive: true });
167
+ writeFileSync(file, `${JSON.stringify({ ...json, hooks }, null, 2)}\n`, 'utf8');
168
+ return existed ? 'updated' : 'created';
169
+ }
170
+
134
171
  /** A port nothing is listening on, starting where the docs say and walking up a short way. */
135
172
  export async function freePort(from = 7333, tries = 12) {
136
173
  for (let p = from; p < from + tries; p++) {
@@ -173,7 +210,7 @@ export const mintToken = () => randomBytes(18).toString('base64url');
173
210
  * Do the whole thing and report what happened. No printing here — the caller owns the voice, and
174
211
  * a function that both decides and narrates cannot be tested without reading its output.
175
212
  */
176
- export async function connectAgents({ dir = process.cwd(), home = homedir(), platform = osPlatform(), port, token } = {}) {
213
+ export async function connectAgents({ dir = process.cwd(), home = homedir(), platform = osPlatform(), port, token, hook = true } = {}) {
177
214
  const targets = agentTargets({ home, platform, dir });
178
215
  const installed = targets.filter((t) => existsSync(t.needs));
179
216
  const keptToken = token || tokenInPlace(targets);
@@ -191,6 +228,18 @@ export async function connectAgents({ dir = process.cwd(), home = homedir(), pla
191
228
  failed.push({ ...t, error: e.message });
192
229
  }
193
230
  }
231
+ /* Only where Claude Code actually is: the hook lives in the same project this just wrote a
232
+ `.mcp.json` into, and writing one into a folder with no agent in it is litter. */
233
+ let stopHook = null;
234
+ if (hook && installed.some((t) => t.id === 'claude-code')) {
235
+ const file = stopHookFile(dir);
236
+ try {
237
+ stopHook = { file, action: writeStopHook(file), command: STOP_COMMAND };
238
+ } catch (e) {
239
+ stopHook = { file, error: e.message };
240
+ }
241
+ }
242
+
194
243
  return {
195
244
  port: usePort,
196
245
  token: useToken,
@@ -198,6 +247,7 @@ export async function connectAgents({ dir = process.cwd(), home = homedir(), pla
198
247
  entry,
199
248
  wrote,
200
249
  failed,
250
+ stopHook,
201
251
  skipped: targets.filter((t) => !installed.includes(t)),
202
252
  };
203
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "1.45.0",
3
+ "version": "1.46.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
@@ -723,6 +723,9 @@ async function runConnect(argv, { after = false } = {}) {
723
723
  dir: resolve(flag('--dir') || process.cwd()),
724
724
  port: Number(flag('--port')) || 0,
725
725
  token: flag('--token') || '',
726
+ /* A hook RUNS A COMMAND every time the agent stops, which is more than an MCP entry does —
727
+ so there is a way to say no without giving up the rest of the setup. */
728
+ hook: !argv.includes('--no-hook'),
726
729
  });
727
730
  } catch (e) {
728
731
  console.error(`qiksy-mcp connect: ${e.message}`);
@@ -738,6 +741,14 @@ async function runConnect(argv, { after = false } = {}) {
738
741
  }
739
742
  for (const w of r.wrote) say(`✓ ${w.action} the qiksy server in ${w.label} → ${w.file}`);
740
743
  for (const f of r.failed) console.error(` could not write ${f.label} → ${f.file}: ${f.error}`);
744
+ /* SAID OUT LOUD, ALWAYS. This one installs a command that will run on every stop — quietly
745
+ doing that is how a helpful tool becomes one people distrust. */
746
+ if (r.stopHook?.error) console.error(` could not add the finish hook → ${r.stopHook.file}: ${r.stopHook.error}`);
747
+ else if (r.stopHook) {
748
+ say(`✓ ${r.stopHook.action} a finish hook in Claude Code → ${r.stopHook.file}`);
749
+ say(` It runs \`${r.stopHook.command}\` when the agent stops, so the tab pulses and the sound`);
750
+ say(' plays the moment a run really ends. Remove that line, or re-run with --no-hook, to skip it.');
751
+ }
741
752
 
742
753
  const clip = toClipboard(r.token);
743
754
  say(`
@@ -1158,6 +1169,65 @@ function onConnection(ws, req) {
1158
1169
  /** What the person set the panel to — filled in by the first reply that carries it. */
1159
1170
  let uiLanguage = '';
1160
1171
 
1172
+ /**
1173
+ * THE END OF A RUN, WITHOUT ANYBODY HAVING TO REMEMBER IT.
1174
+ *
1175
+ * `qa_done` is the verb that makes the tab pulse and the sound play, and the agent is asked to
1176
+ * call it in the instructions handed over at connect. An instruction is a request, though, not a
1177
+ * mechanism: some clients follow it, some do not, and every agent already installed has the old
1178
+ * habit. So the person updates, everything is switched on by default — and hears nothing. That is
1179
+ * the state this exists to prevent (owner, 21.09.2026).
1180
+ *
1181
+ * What this process CAN observe is the same thing the voice already watches: it timestamps every
1182
+ * call and knows when the last one finished. A run that did real work and then went quiet past a
1183
+ * threshold is over in every sense that matters to somebody in the next room.
1184
+ *
1185
+ * IT IS AN ESTIMATE, AND IT IS BUILT TO BE WRONG SAFELY. Three things make a premature signal
1186
+ * cheap rather than annoying, and none of them are new — they all shipped with the verb:
1187
+ * · the extension refuses to sound when no work has been recorded;
1188
+ * · it refuses to sound twice within half a minute;
1189
+ * · ANY later call stops the pulse, so an agent that was merely thinking cancels the signal by
1190
+ * carrying on — the mistake undoes itself without anybody touching anything.
1191
+ * The exact path — a Stop hook running `npx qiksy-mcp done` — stays available for clients that
1192
+ * have one, and is what `init` writes. This is the floor under it, not a replacement.
1193
+ */
1194
+ const DONE_AFTER_MS = (() => {
1195
+ const raw = process.env.QIKSY_MCP_DONE_AFTER;
1196
+ if (raw === '0' || /^(off|no|false)$/i.test(raw || '')) return 0;
1197
+ const n = Number(raw);
1198
+ /* Below twenty seconds this would fire inside an ordinary pause for thought, so a smaller
1199
+ number is read as a mistake and the default is used instead. */
1200
+ return Number.isFinite(n) && n >= 20 ? n * 1000 : 90_000;
1201
+ })();
1202
+ let doneTimer = null;
1203
+ let workSinceDone = false;
1204
+
1205
+ function fireDone() {
1206
+ doneTimer = null;
1207
+ if (!workSinceDone) return;
1208
+ workSinceDone = false;
1209
+ log(`▸ quiet for ${Math.round(DONE_AFTER_MS / 1000)}s after a run — saying it has finished`);
1210
+ /* Failure here is nothing: the browser may have gone, the tab may have closed. The pulse is a
1211
+ courtesy, and a courtesy that throws is worse than one that does not happen. */
1212
+ callExtension('qa_done', { verdict: 'ok' }, 8_000).catch(() => {});
1213
+ }
1214
+
1215
+ /** Called when a call FINISHES, not when it starts: a slow verb must not look like silence. */
1216
+ function armDone(tool) {
1217
+ if (!DONE_AFTER_MS) return;
1218
+ if (doneTimer) clearTimeout(doneTimer);
1219
+ doneTimer = null;
1220
+ if (tool === 'qa_done') {
1221
+ /* Said out loud already — nothing left to announce until there is new work. */
1222
+ workSinceDone = false;
1223
+ return;
1224
+ }
1225
+ workSinceDone = true;
1226
+ doneTimer = setTimeout(fireDone, DONE_AFTER_MS);
1227
+ /* A pending timer must never be the reason this process stays alive. */
1228
+ doneTimer.unref?.();
1229
+ }
1230
+
1161
1231
  function callExtension(tool, args, timeoutMs = 15_000) {
1162
1232
  // The same choke point is where the voice watches the session: one place, both roles.
1163
1233
  noticedCall(tool);
@@ -1165,6 +1235,7 @@ function callExtension(tool, args, timeoutMs = 15_000) {
1165
1235
  p.then(
1166
1236
  (r) => {
1167
1237
  noticedResult(tool, r);
1238
+ armDone(tool);
1168
1239
  /* THE HUMAN'S LANGUAGE, learned rather than guessed. This process never sees the
1169
1240
  conversation, so it cannot know what language they write in — but they told the panel
1170
1241
  directly by choosing one, and the extension sends that along with every bundle. One
@@ -1175,6 +1246,9 @@ function callExtension(tool, args, timeoutMs = 15_000) {
1175
1246
  },
1176
1247
  (e) => {
1177
1248
  noticedResult(tool, null);
1249
+ /* A call that FAILED is still the agent having been here — the run is just as over when
1250
+ the last thing it did did not work. */
1251
+ armDone(tool);
1178
1252
  throw e;
1179
1253
  },
1180
1254
  );