qiksy-mcp 1.45.0 → 1.47.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/connect.mjs +122 -3
- package/package.json +1 -1
- package/server.mjs +127 -8
package/connect.mjs
CHANGED
|
@@ -122,6 +122,68 @@ export function portInPlace(targets) {
|
|
|
122
122
|
return 0;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* THE PAIRING, FOR A COMMAND THAT HAS NO ENVIRONMENT — i.e. the finish hook.
|
|
127
|
+
*
|
|
128
|
+
* The server proper is started BY the agent, which hands it the token in the entry's `env` block.
|
|
129
|
+
* A Stop hook is not started that way: it is a bare command in a bare shell, so QIKSY_MCP_TOKEN is
|
|
130
|
+
* empty, the hub closes the unauthenticated join — correctly — and the hook then fails in the one
|
|
131
|
+
* way nobody notices: silently, once per run, into a log no one reads. Measured on a real setup
|
|
132
|
+
* (21.09.2026): `npx qiksy-mcp done` answered «the bridge closed the connection» and the browser
|
|
133
|
+
* never heard a thing, while the same command with the token in front of it rang through.
|
|
134
|
+
*
|
|
135
|
+
* So the hook reads the same pairing the agent itself uses. `~/.claude.json` is in the search
|
|
136
|
+
* because that is where `claude mcp add` puts an entry — top level, or under projects/<cwd> — and
|
|
137
|
+
* it is READ ONLY here: connect writes `.mcp.json` instead, and a command that fires on every stop
|
|
138
|
+
* must never rewrite the file that holds every project's servers.
|
|
139
|
+
*/
|
|
140
|
+
export function pairingInPlace({ dir = process.cwd(), home = homedir(), platform = osPlatform() } = {}) {
|
|
141
|
+
const targets = agentTargets({ dir, home, platform });
|
|
142
|
+
const found = { token: tokenInPlace(targets), port: portInPlace(targets) };
|
|
143
|
+
if (found.token && found.port) return found;
|
|
144
|
+
|
|
145
|
+
for (const entry of claudeJsonEntries(join(home, '.claude.json'), dir)) {
|
|
146
|
+
const tok = entry?.env?.QIKSY_MCP_TOKEN;
|
|
147
|
+
if (!found.token && typeof tok === 'string' && tok.length >= 16) found.token = tok;
|
|
148
|
+
if (!found.port) {
|
|
149
|
+
const args = Array.isArray(entry?.args) ? entry.args : [];
|
|
150
|
+
const i = args.indexOf('--port');
|
|
151
|
+
const p = i !== -1 ? Number(args[i + 1]) : NaN;
|
|
152
|
+
if (Number.isInteger(p) && p > 0) found.port = p;
|
|
153
|
+
}
|
|
154
|
+
if (found.token && found.port) break;
|
|
155
|
+
}
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Every qiksy entry in `~/.claude.json`, THE ONE FOR THIS FOLDER FIRST.
|
|
161
|
+
*
|
|
162
|
+
* Order is the whole point: the token is the same everywhere (the popup has one field), but the
|
|
163
|
+
* PORT is per project, and a hook that picks a neighbouring project's port rings a bridge that
|
|
164
|
+
* belongs to somebody else's run. Deepest matching project path wins, then the global entry, then
|
|
165
|
+
* whatever else is there — the last one so a folder that was never connected still finds the
|
|
166
|
+
* pairing instead of going silent.
|
|
167
|
+
*/
|
|
168
|
+
function claudeJsonEntries(file, dir) {
|
|
169
|
+
let json;
|
|
170
|
+
try {
|
|
171
|
+
json = JSON.parse(readFileSync(file, 'utf8'));
|
|
172
|
+
} catch {
|
|
173
|
+
return []; // missing or hand-broken: not our file to fix, and not a reason to fail
|
|
174
|
+
}
|
|
175
|
+
const projects = json?.projects && typeof json.projects === 'object' ? json.projects : {};
|
|
176
|
+
const mine = Object.keys(projects)
|
|
177
|
+
.filter((p) => dir === p || dir.startsWith(`${p}/`))
|
|
178
|
+
.sort((a, b) => b.length - a.length);
|
|
179
|
+
const rest = Object.keys(projects).filter((p) => !mine.includes(p));
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const p of mine) if (projects[p]?.mcpServers?.qiksy) out.push(projects[p].mcpServers.qiksy);
|
|
182
|
+
if (json?.mcpServers?.qiksy) out.push(json.mcpServers.qiksy);
|
|
183
|
+
for (const p of rest) if (projects[p]?.mcpServers?.qiksy) out.push(projects[p].mcpServers.qiksy);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
125
187
|
/** Write our one entry, keep everything else in the file exactly as it was. */
|
|
126
188
|
export function writeEntry(target, entry) {
|
|
127
189
|
const { json, existed } = readConfig(target.file);
|
|
@@ -131,6 +193,43 @@ export function writeEntry(target, entry) {
|
|
|
131
193
|
return existed ? 'updated' : 'created';
|
|
132
194
|
}
|
|
133
195
|
|
|
196
|
+
/**
|
|
197
|
+
* THE EXACT FINISH SIGNAL, for the one client that can give it.
|
|
198
|
+
*
|
|
199
|
+
* `qa_done` makes the tab pulse and the sound play, and the agent is asked to call it. The server
|
|
200
|
+
* also says it by itself after a long enough silence — but silence is an estimate, and Claude Code
|
|
201
|
+
* knows the real thing: it fires a `Stop` hook the moment the agent actually stops. One line in
|
|
202
|
+
* the project's settings turns the estimate into a fact.
|
|
203
|
+
*
|
|
204
|
+
* THREE RULES, and they are what makes writing into somebody's settings acceptable at all:
|
|
205
|
+
* · everything already in the file survives, including other Stop hooks;
|
|
206
|
+
* · running it twice adds nothing — the same command is recognised and left alone;
|
|
207
|
+
* · it is reported out loud by the caller, because a command that will run on every stop is not
|
|
208
|
+
* something to install quietly.
|
|
209
|
+
*/
|
|
210
|
+
export const STOP_COMMAND = 'npx qiksy-mcp done';
|
|
211
|
+
|
|
212
|
+
export function stopHookFile(dir) {
|
|
213
|
+
return join(dir, '.claude', 'settings.json');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function writeStopHook(file, command = STOP_COMMAND) {
|
|
217
|
+
const { json, existed } = readConfig(file);
|
|
218
|
+
const hooks = { ...(json.hooks || {}) };
|
|
219
|
+
const stop = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
|
|
220
|
+
/* Already there — from a previous run, or written by hand. Adding a second copy would make the
|
|
221
|
+
browser say «done» twice for one stop. */
|
|
222
|
+
const has = stop.some((g) =>
|
|
223
|
+
Array.isArray(g?.hooks) && g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('qiksy-mcp done')),
|
|
224
|
+
);
|
|
225
|
+
if (has) return 'already there';
|
|
226
|
+
stop.push({ hooks: [{ type: 'command', command }] });
|
|
227
|
+
hooks.Stop = stop;
|
|
228
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
229
|
+
writeFileSync(file, `${JSON.stringify({ ...json, hooks }, null, 2)}\n`, 'utf8');
|
|
230
|
+
return existed ? 'updated' : 'created';
|
|
231
|
+
}
|
|
232
|
+
|
|
134
233
|
/** A port nothing is listening on, starting where the docs say and walking up a short way. */
|
|
135
234
|
export async function freePort(from = 7333, tries = 12) {
|
|
136
235
|
for (let p = from; p < from + tries; p++) {
|
|
@@ -173,11 +272,18 @@ export const mintToken = () => randomBytes(18).toString('base64url');
|
|
|
173
272
|
* Do the whole thing and report what happened. No printing here — the caller owns the voice, and
|
|
174
273
|
* a function that both decides and narrates cannot be tested without reading its output.
|
|
175
274
|
*/
|
|
176
|
-
export async function connectAgents({ dir = process.cwd(), home = homedir(), platform = osPlatform(), port, token } = {}) {
|
|
275
|
+
export async function connectAgents({ dir = process.cwd(), home = homedir(), platform = osPlatform(), port, token, hook = true } = {}) {
|
|
177
276
|
const targets = agentTargets({ home, platform, dir });
|
|
178
277
|
const installed = targets.filter((t) => existsSync(t.needs));
|
|
179
|
-
|
|
180
|
-
|
|
278
|
+
/* THE SEARCH FOR AN EXISTING PAIRING IS WIDER THAN THE LIST WE WRITE INTO, and that is the whole
|
|
279
|
+
point of it. `claude mcp add` puts the entry in ~/.claude.json, which is not a file connect
|
|
280
|
+
writes; looking only at its own targets, a re-run found nothing, minted a FRESH token and wrote
|
|
281
|
+
it everywhere — leaving the browser paired with a secret nobody holds any more. It costs the
|
|
282
|
+
person a «Token rejected» on the next restart and a trip to the popup, for a command they ran
|
|
283
|
+
to fix something else. Seen live on the owner's own machine, 21.09.2026. */
|
|
284
|
+
const inPlace = pairingInPlace({ dir, home, platform });
|
|
285
|
+
const keptToken = token || inPlace.token;
|
|
286
|
+
const keptPort = port || inPlace.port;
|
|
181
287
|
const usePort = keptPort || (await freePort());
|
|
182
288
|
const useToken = keptToken || mintToken();
|
|
183
289
|
const entry = serverEntry({ port: usePort, token: useToken, platform });
|
|
@@ -191,6 +297,18 @@ export async function connectAgents({ dir = process.cwd(), home = homedir(), pla
|
|
|
191
297
|
failed.push({ ...t, error: e.message });
|
|
192
298
|
}
|
|
193
299
|
}
|
|
300
|
+
/* Only where Claude Code actually is: the hook lives in the same project this just wrote a
|
|
301
|
+
`.mcp.json` into, and writing one into a folder with no agent in it is litter. */
|
|
302
|
+
let stopHook = null;
|
|
303
|
+
if (hook && installed.some((t) => t.id === 'claude-code')) {
|
|
304
|
+
const file = stopHookFile(dir);
|
|
305
|
+
try {
|
|
306
|
+
stopHook = { file, action: writeStopHook(file), command: STOP_COMMAND };
|
|
307
|
+
} catch (e) {
|
|
308
|
+
stopHook = { file, error: e.message };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
194
312
|
return {
|
|
195
313
|
port: usePort,
|
|
196
314
|
token: useToken,
|
|
@@ -198,6 +316,7 @@ export async function connectAgents({ dir = process.cwd(), home = homedir(), pla
|
|
|
198
316
|
entry,
|
|
199
317
|
wrote,
|
|
200
318
|
failed,
|
|
319
|
+
stopHook,
|
|
201
320
|
skipped: targets.filter((t) => !installed.includes(t)),
|
|
202
321
|
};
|
|
203
322
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.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
|
@@ -35,7 +35,7 @@ import { project } from './project.mjs';
|
|
|
35
35
|
import { recordGap, listGaps, gapInvite, GAPS_FILE } from './gaps.mjs';
|
|
36
36
|
import { STATES, STATE_KEYS, renderGallery } from './gallery.mjs';
|
|
37
37
|
import { renderSearchableSvg } from './svg-shot.mjs';
|
|
38
|
-
import { connectAgents, toClipboard } from './connect.mjs';
|
|
38
|
+
import { connectAgents, toClipboard, pairingInPlace } from './connect.mjs';
|
|
39
39
|
|
|
40
40
|
const log = (...a) => console.error('[qiksy-mcp]', ...a);
|
|
41
41
|
|
|
@@ -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(`
|
|
@@ -844,17 +855,26 @@ if (process.argv[2] === 'done') {
|
|
|
844
855
|
* loudly because the browser is closed would be a hook people remove.
|
|
845
856
|
*/
|
|
846
857
|
const bad = process.argv.includes('--problem');
|
|
847
|
-
|
|
858
|
+
|
|
859
|
+
/* ONE ATTEMPT WITH ONE PAIRING. Resolves to the sentence to print, plus `refused` when the hub
|
|
860
|
+
shut the door on us — which is the only failure worth trying a second key on. */
|
|
861
|
+
const ring = (token, port) => new Promise((res) => {
|
|
848
862
|
let ws;
|
|
849
|
-
|
|
863
|
+
let answered = false;
|
|
864
|
+
const give = (said, refused = false) => {
|
|
865
|
+
if (answered) return;
|
|
866
|
+
answered = true;
|
|
867
|
+
try { ws?.close(); } catch { /* already gone */ }
|
|
868
|
+
res({ said, refused });
|
|
869
|
+
};
|
|
850
870
|
const timer = setTimeout(() => give('no answer from the bridge'), 6000);
|
|
851
871
|
try {
|
|
852
|
-
ws = new WebSocket(`ws://${HOST}:${
|
|
872
|
+
ws = new WebSocket(`ws://${HOST}:${port}`);
|
|
853
873
|
} catch {
|
|
854
874
|
clearTimeout(timer);
|
|
855
|
-
return
|
|
875
|
+
return give('could not reach the bridge');
|
|
856
876
|
}
|
|
857
|
-
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', v: PROTOCOL_VERSION, token
|
|
877
|
+
ws.on('open', () => ws.send(JSON.stringify({ t: 'join', v: PROTOCOL_VERSION, token, pid: process.pid })));
|
|
858
878
|
ws.on('message', (raw) => {
|
|
859
879
|
let m;
|
|
860
880
|
try { m = JSON.parse(raw.toString()); } catch { return; }
|
|
@@ -862,9 +882,45 @@ if (process.argv[2] === 'done') {
|
|
|
862
882
|
if (m.t === 'res') { clearTimeout(timer); give(m.result?.soundNote || m.error || 'done'); }
|
|
863
883
|
});
|
|
864
884
|
ws.on('error', () => { clearTimeout(timer); give('no bridge on this port'); });
|
|
865
|
-
|
|
885
|
+
/* Closed before an answer = the token was refused. The hub says nothing more, by design: it
|
|
886
|
+
is an authentication gate, and a gate that explains itself is a gate that helps a stranger. */
|
|
887
|
+
ws.on('close', () => { clearTimeout(timer); give('the bridge closed the connection — the token it holds is a different one', true); });
|
|
866
888
|
});
|
|
867
|
-
|
|
889
|
+
|
|
890
|
+
/* TWO PLACES A TOKEN CAN COME FROM, AND THE ENVIRONMENT IS NOT THE TRUSTWORTHY ONE.
|
|
891
|
+
*
|
|
892
|
+
* A hook runs in a bare shell: no `env` block from the agent, so usually nothing — and sometimes
|
|
893
|
+
* WORSE than nothing, because an old QIKSY_MCP_TOKEN left in a .zshenv from a previous setup is
|
|
894
|
+
* exported into every shell on the machine and looks exactly like configuration. Found on the
|
|
895
|
+
* owner's own machine 21.09.2026: the shell carried a token three generations old, the hub
|
|
896
|
+
* refused it, and the command reported «the bridge closed the connection» — which reads as a
|
|
897
|
+
* browser problem, so the search starts in the popup, which was never wrong.
|
|
898
|
+
*
|
|
899
|
+
* So the environment is tried FIRST (an explicit export must still win when it is right), and a
|
|
900
|
+
* refusal falls through to the pairing the agent itself is using. Two attempts, never more. */
|
|
901
|
+
const tries = [];
|
|
902
|
+
if (TOKEN) tries.push({ token: TOKEN, port: PORT, from: 'the environment' });
|
|
903
|
+
const p = pairingInPlace({ dir: process.cwd() });
|
|
904
|
+
if (p.token && p.token !== TOKEN) {
|
|
905
|
+
const own = portArg === -1 && !process.env.QIKSY_MCP_PORT && p.port ? p.port : PORT;
|
|
906
|
+
tries.push({ token: p.token, port: own, from: 'your agent config' });
|
|
907
|
+
}
|
|
908
|
+
if (!tries.length) {
|
|
909
|
+
/* Naming the missing half, because «the bridge closed the connection» sent the last person
|
|
910
|
+
looking at the browser — the one part that was working. */
|
|
911
|
+
log('no token: set QIKSY_MCP_TOKEN, or run `npx qiksy-mcp connect` in this project first');
|
|
912
|
+
process.exit(0);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
let last = '';
|
|
916
|
+
for (const t of tries) {
|
|
917
|
+
const r = await ring(t.token, t.port);
|
|
918
|
+
last = r.said;
|
|
919
|
+
if (!r.refused) break;
|
|
920
|
+
/* Said out loud only when there IS a second key to try — otherwise it is noise on every stop. */
|
|
921
|
+
if (t !== tries[tries.length - 1]) log(`the token in ${t.from} was refused — trying the one in your agent config`);
|
|
922
|
+
}
|
|
923
|
+
log(last);
|
|
868
924
|
process.exit(0);
|
|
869
925
|
}
|
|
870
926
|
|
|
@@ -1158,6 +1214,65 @@ function onConnection(ws, req) {
|
|
|
1158
1214
|
/** What the person set the panel to — filled in by the first reply that carries it. */
|
|
1159
1215
|
let uiLanguage = '';
|
|
1160
1216
|
|
|
1217
|
+
/**
|
|
1218
|
+
* THE END OF A RUN, WITHOUT ANYBODY HAVING TO REMEMBER IT.
|
|
1219
|
+
*
|
|
1220
|
+
* `qa_done` is the verb that makes the tab pulse and the sound play, and the agent is asked to
|
|
1221
|
+
* call it in the instructions handed over at connect. An instruction is a request, though, not a
|
|
1222
|
+
* mechanism: some clients follow it, some do not, and every agent already installed has the old
|
|
1223
|
+
* habit. So the person updates, everything is switched on by default — and hears nothing. That is
|
|
1224
|
+
* the state this exists to prevent (owner, 21.09.2026).
|
|
1225
|
+
*
|
|
1226
|
+
* What this process CAN observe is the same thing the voice already watches: it timestamps every
|
|
1227
|
+
* call and knows when the last one finished. A run that did real work and then went quiet past a
|
|
1228
|
+
* threshold is over in every sense that matters to somebody in the next room.
|
|
1229
|
+
*
|
|
1230
|
+
* IT IS AN ESTIMATE, AND IT IS BUILT TO BE WRONG SAFELY. Three things make a premature signal
|
|
1231
|
+
* cheap rather than annoying, and none of them are new — they all shipped with the verb:
|
|
1232
|
+
* · the extension refuses to sound when no work has been recorded;
|
|
1233
|
+
* · it refuses to sound twice within half a minute;
|
|
1234
|
+
* · ANY later call stops the pulse, so an agent that was merely thinking cancels the signal by
|
|
1235
|
+
* carrying on — the mistake undoes itself without anybody touching anything.
|
|
1236
|
+
* The exact path — a Stop hook running `npx qiksy-mcp done` — stays available for clients that
|
|
1237
|
+
* have one, and is what `init` writes. This is the floor under it, not a replacement.
|
|
1238
|
+
*/
|
|
1239
|
+
const DONE_AFTER_MS = (() => {
|
|
1240
|
+
const raw = process.env.QIKSY_MCP_DONE_AFTER;
|
|
1241
|
+
if (raw === '0' || /^(off|no|false)$/i.test(raw || '')) return 0;
|
|
1242
|
+
const n = Number(raw);
|
|
1243
|
+
/* Below twenty seconds this would fire inside an ordinary pause for thought, so a smaller
|
|
1244
|
+
number is read as a mistake and the default is used instead. */
|
|
1245
|
+
return Number.isFinite(n) && n >= 20 ? n * 1000 : 90_000;
|
|
1246
|
+
})();
|
|
1247
|
+
let doneTimer = null;
|
|
1248
|
+
let workSinceDone = false;
|
|
1249
|
+
|
|
1250
|
+
function fireDone() {
|
|
1251
|
+
doneTimer = null;
|
|
1252
|
+
if (!workSinceDone) return;
|
|
1253
|
+
workSinceDone = false;
|
|
1254
|
+
log(`▸ quiet for ${Math.round(DONE_AFTER_MS / 1000)}s after a run — saying it has finished`);
|
|
1255
|
+
/* Failure here is nothing: the browser may have gone, the tab may have closed. The pulse is a
|
|
1256
|
+
courtesy, and a courtesy that throws is worse than one that does not happen. */
|
|
1257
|
+
callExtension('qa_done', { verdict: 'ok' }, 8_000).catch(() => {});
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** Called when a call FINISHES, not when it starts: a slow verb must not look like silence. */
|
|
1261
|
+
function armDone(tool) {
|
|
1262
|
+
if (!DONE_AFTER_MS) return;
|
|
1263
|
+
if (doneTimer) clearTimeout(doneTimer);
|
|
1264
|
+
doneTimer = null;
|
|
1265
|
+
if (tool === 'qa_done') {
|
|
1266
|
+
/* Said out loud already — nothing left to announce until there is new work. */
|
|
1267
|
+
workSinceDone = false;
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
workSinceDone = true;
|
|
1271
|
+
doneTimer = setTimeout(fireDone, DONE_AFTER_MS);
|
|
1272
|
+
/* A pending timer must never be the reason this process stays alive. */
|
|
1273
|
+
doneTimer.unref?.();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1161
1276
|
function callExtension(tool, args, timeoutMs = 15_000) {
|
|
1162
1277
|
// The same choke point is where the voice watches the session: one place, both roles.
|
|
1163
1278
|
noticedCall(tool);
|
|
@@ -1165,6 +1280,7 @@ function callExtension(tool, args, timeoutMs = 15_000) {
|
|
|
1165
1280
|
p.then(
|
|
1166
1281
|
(r) => {
|
|
1167
1282
|
noticedResult(tool, r);
|
|
1283
|
+
armDone(tool);
|
|
1168
1284
|
/* THE HUMAN'S LANGUAGE, learned rather than guessed. This process never sees the
|
|
1169
1285
|
conversation, so it cannot know what language they write in — but they told the panel
|
|
1170
1286
|
directly by choosing one, and the extension sends that along with every bundle. One
|
|
@@ -1175,6 +1291,9 @@ function callExtension(tool, args, timeoutMs = 15_000) {
|
|
|
1175
1291
|
},
|
|
1176
1292
|
(e) => {
|
|
1177
1293
|
noticedResult(tool, null);
|
|
1294
|
+
/* A call that FAILED is still the agent having been here — the run is just as over when
|
|
1295
|
+
the last thing it did did not work. */
|
|
1296
|
+
armDone(tool);
|
|
1178
1297
|
throw e;
|
|
1179
1298
|
},
|
|
1180
1299
|
);
|