@geometra/mcp 1.42.0 → 1.43.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.
@@ -71,6 +71,9 @@ vi.mock('../session.js', () => ({
71
71
  prewarmProxy: mockState.prewarmProxy,
72
72
  disconnect: vi.fn(),
73
73
  getSession: vi.fn(() => mockState.session),
74
+ resolveSession: vi.fn((id) => ({ kind: 'ok', session: mockState.session, ...(id ? { id } : {}) })),
75
+ listSessions: vi.fn(() => [{ id: 's1', url: 'https://jobs.example.com/application' }]),
76
+ getDefaultSessionId: vi.fn(() => 's1'),
74
77
  sendClick: mockState.sendClick,
75
78
  sendType: mockState.sendType,
76
79
  sendKey: mockState.sendKey,
@@ -78,6 +81,7 @@ vi.mock('../session.js', () => ({
78
81
  sendFieldText: mockState.sendFieldText,
79
82
  sendFieldChoice: mockState.sendFieldChoice,
80
83
  sendFillFields: mockState.sendFillFields,
84
+ sendFillOtp: vi.fn(async () => ({ status: 'updated', timeoutMs: 5000, result: { cellCount: 6, filledCount: 6 } })),
81
85
  sendListboxPick: mockState.sendListboxPick,
82
86
  sendSelectOption: mockState.sendSelectOption,
83
87
  sendSetChecked: mockState.sendSetChecked,
@@ -67,6 +67,9 @@ vi.mock('../session.js', () => ({
67
67
  prewarmProxy: mockState.prewarmProxy,
68
68
  disconnect: vi.fn(),
69
69
  getSession: vi.fn(() => mockState.session),
70
+ resolveSession: vi.fn((id) => ({ kind: 'ok', session: mockState.session, ...(id ? { id } : {}) })),
71
+ listSessions: vi.fn(() => [{ id: 's1', url: 'https://jobs.example.com/application' }]),
72
+ getDefaultSessionId: vi.fn(() => 's1'),
70
73
  sendClick: mockState.sendClick,
71
74
  sendType: mockState.sendType,
72
75
  sendKey: mockState.sendKey,
@@ -74,6 +77,7 @@ vi.mock('../session.js', () => ({
74
77
  sendFieldText: mockState.sendFieldText,
75
78
  sendFieldChoice: mockState.sendFieldChoice,
76
79
  sendFillFields: mockState.sendFillFields,
80
+ sendFillOtp: vi.fn(async () => ({ status: 'updated', timeoutMs: 5000, result: { cellCount: 6, filledCount: 6 } })),
77
81
  sendListboxPick: mockState.sendListboxPick,
78
82
  sendSelectOption: mockState.sendSelectOption,
79
83
  sendSetChecked: mockState.sendSetChecked,
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { afterEach, beforeAll, describe, expect, it } from 'vitest';
30
30
  import http from 'node:http';
31
- import { buildA11yTree, connectThroughProxy, disconnect } from '../session.js';
31
+ import { buildA11yTree, connectThroughProxy, disconnect, getDefaultSessionId, resolveSession, } from '../session.js';
32
32
  const PAGE_HTML = `<!doctype html>
33
33
  <html>
34
34
  <head><title>isolation-fixture</title></head>
@@ -190,4 +190,119 @@ describe('connectThroughProxy({ isolated: true })', () => {
190
190
  expect(sessionA.id).toBe(sessionB.id);
191
191
  disconnect({ sessionId: sessionA.id, closeProxy: true });
192
192
  }, 30_000);
193
+ // ── Bug #1 regression: parallel isolated sessions must never share a
194
+ // default pointer, and tool-call resolution must refuse to implicitly
195
+ // route to an isolated session when no sessionId is passed.
196
+ it('three parallel isolated sessions stay strictly independent — no shared default, strict id routing', async () => {
197
+ // Spawn three isolated sessions in parallel, each pinned to a
198
+ // different local URL. Each session gets its own Chromium instance
199
+ // (isolated: true forces a fresh proxy every time), and each tool
200
+ // call in real usage would address its session by the returned id.
201
+ const [sessionA, sessionB, sessionC] = await Promise.all([
202
+ connectThroughProxy({
203
+ pageUrl: `${baseUrl}/page-a`,
204
+ headless: true,
205
+ isolated: true,
206
+ }),
207
+ connectThroughProxy({
208
+ pageUrl: `${baseUrl}/page-b`,
209
+ headless: true,
210
+ isolated: true,
211
+ }),
212
+ connectThroughProxy({
213
+ pageUrl: `${baseUrl}/page-a`,
214
+ headless: true,
215
+ isolated: true,
216
+ }),
217
+ ]);
218
+ try {
219
+ // Invariant 1: every isolated session is flagged, and all three got
220
+ // distinct session ids (no collision).
221
+ expect(sessionA.isolated).toBe(true);
222
+ expect(sessionB.isolated).toBe(true);
223
+ expect(sessionC.isolated).toBe(true);
224
+ expect(new Set([sessionA.id, sessionB.id, sessionC.id]).size).toBe(3);
225
+ // Invariant 2: the implicit default-session pointer must not be set
226
+ // to any of these isolated sessions. This is the contamination fix —
227
+ // before v1.43, `connect()` unconditionally set defaultSessionId to
228
+ // whichever session most recently finished connecting, so parallel
229
+ // workers without an explicit sessionId would race onto each
230
+ // other's browsers.
231
+ const implicitDefault = getDefaultSessionId();
232
+ expect(implicitDefault).toBeNull();
233
+ // Invariant 3: resolveSession() with no id must refuse to pick any
234
+ // of these isolated sessions. Callers that omit sessionId get an
235
+ // ambiguous error and are forced to pass an explicit id.
236
+ const ambiguous = resolveSession();
237
+ expect(ambiguous.kind).toBe('ambiguous');
238
+ if (ambiguous.kind === 'ambiguous') {
239
+ expect(ambiguous.activeIds).toEqual(expect.arrayContaining([sessionA.id, sessionB.id, sessionC.id]));
240
+ expect(ambiguous.isolatedIds).toEqual(expect.arrayContaining([sessionA.id, sessionB.id, sessionC.id]));
241
+ }
242
+ // Invariant 4: explicit id routing must return exactly the
243
+ // requested session — never a peer — even under parallel load.
244
+ const lookupA = resolveSession(sessionA.id);
245
+ const lookupB = resolveSession(sessionB.id);
246
+ const lookupC = resolveSession(sessionC.id);
247
+ expect(lookupA.kind).toBe('ok');
248
+ expect(lookupB.kind).toBe('ok');
249
+ expect(lookupC.kind).toBe('ok');
250
+ if (lookupA.kind === 'ok')
251
+ expect(lookupA.session).toBe(sessionA);
252
+ if (lookupB.kind === 'ok')
253
+ expect(lookupB.session).toBe(sessionB);
254
+ if (lookupC.kind === 'ok')
255
+ expect(lookupC.session).toBe(sessionC);
256
+ // Invariant 5: each session's page still reads its own per-URL
257
+ // marker. This is the end-to-end "different browsers" check —
258
+ // even though they share the same MCP server process, the
259
+ // underlying Chromium / localStorage is distinct per session.
260
+ const [markerA, markerB, markerC] = await Promise.all([
261
+ waitForMarkerText(sessionA, 'marker-from-'),
262
+ waitForMarkerText(sessionB, 'marker-from-'),
263
+ waitForMarkerText(sessionC, 'marker-from-'),
264
+ ]);
265
+ expect(markerA).toBe('marker-from-/page-a');
266
+ expect(markerB).toBe('marker-from-/page-b');
267
+ expect(markerC).toBe('marker-from-/page-a');
268
+ // Invariant 6: looking up a session id that no longer exists must
269
+ // return `not_found` with the full active-id list, NEVER silently
270
+ // fall back to the default. This is the v1.42-era contamination
271
+ // vector: workers were seeing their tool calls routed to whatever
272
+ // the "most recent" session happened to be.
273
+ disconnect({ sessionId: sessionB.id, closeProxy: true });
274
+ const afterDisconnect = resolveSession(sessionB.id);
275
+ expect(afterDisconnect.kind).toBe('not_found');
276
+ if (afterDisconnect.kind === 'not_found') {
277
+ expect(afterDisconnect.id).toBe(sessionB.id);
278
+ }
279
+ // And the implicit default is STILL null — disconnecting one
280
+ // isolated session doesn't promote the next one to default.
281
+ expect(getDefaultSessionId()).toBeNull();
282
+ }
283
+ finally {
284
+ disconnect({ sessionId: sessionA.id, closeProxy: true });
285
+ disconnect({ sessionId: sessionC.id, closeProxy: true });
286
+ }
287
+ }, 60_000);
288
+ it('a single non-isolated session DOES become the implicit default (back-compat)', async () => {
289
+ // Single pooled session — the default-fallback behavior is retained
290
+ // for callers that don't pass sessionId and aren't running in
291
+ // parallel. This preserves every existing single-worker script.
292
+ const session = await connectThroughProxy({
293
+ pageUrl: `${baseUrl}/page-a`,
294
+ headless: true,
295
+ });
296
+ try {
297
+ expect(session.isolated).toBeFalsy();
298
+ expect(getDefaultSessionId()).toBe(session.id);
299
+ const resolved = resolveSession();
300
+ expect(resolved.kind).toBe('ok');
301
+ if (resolved.kind === 'ok')
302
+ expect(resolved.session.id).toBe(session.id);
303
+ }
304
+ finally {
305
+ disconnect({ sessionId: session.id, closeProxy: true });
306
+ }
307
+ }, 30_000);
193
308
  });
@@ -422,6 +422,80 @@ describe('buildFormSchemas', () => {
422
422
  },
423
423
  });
424
424
  });
425
+ // Bug #3 regression (v1.43): a React Select / Headless UI / Radix
426
+ // autocomplete combobox renders as a plain <input role="textbox"> in the
427
+ // accessibility tree. The extractor tags it with meta.isAutocompleteCombobox
428
+ // when its ancestry matches the autocomplete-wrapper fingerprint
429
+ // (see isAutocompleteComboboxAncestry in packages/proxy/src/extractor.ts,
430
+ // which mirrors isAutocompleteCombobox in packages/proxy/src/dom-actions.ts).
431
+ // The form-schema classifier MUST re-tag that field as choice/listbox so
432
+ // fill_form routes it through pick_listbox_option — otherwise Greenhouse
433
+ // Remix Country pickers (and every other React Select) get fed through
434
+ // the plain text-fill path, the controlled form state never commits, and
435
+ // Submit fails with "This field is required" while the field looks filled.
436
+ it('re-classifies autocomplete-combobox-wrapped textboxes as choice/listbox', () => {
437
+ const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
438
+ children: [
439
+ node('form', 'Address', { x: 20, y: 20, width: 760, height: 480 }, {
440
+ path: [0],
441
+ children: [
442
+ // A plain text field — should stay classified as `text`.
443
+ node('textbox', 'Postal code', { x: 40, y: 60, width: 200, height: 36 }, {
444
+ path: [0, 0],
445
+ state: { required: true },
446
+ meta: { controlTag: 'input', inputType: 'text' },
447
+ }),
448
+ // The React Select Country picker: role=textbox but the
449
+ // extractor flagged its ancestry as autocomplete-combobox. The
450
+ // classifier must re-tag this as choice/listbox.
451
+ node('textbox', 'Country', { x: 40, y: 120, width: 320, height: 36 }, {
452
+ path: [0, 1],
453
+ state: { required: true, invalid: true },
454
+ meta: {
455
+ controlTag: 'input',
456
+ isAutocompleteCombobox: true,
457
+ },
458
+ }),
459
+ // A real native <combobox> wrapping a <select> — should stay
460
+ // classified as choice/select (NOT listbox) because the
461
+ // controlTag is 'select' and no autocomplete flag is set.
462
+ node('combobox', 'State / Region', { x: 40, y: 180, width: 320, height: 36 }, {
463
+ path: [0, 2],
464
+ state: { required: true },
465
+ value: 'California',
466
+ meta: { controlTag: 'select' },
467
+ }),
468
+ ],
469
+ }),
470
+ ],
471
+ });
472
+ const schemas = buildFormSchemas(tree);
473
+ expect(schemas).toHaveLength(1);
474
+ const fields = schemas[0]?.fields ?? [];
475
+ expect(fields).toHaveLength(3);
476
+ expect(fields[0]).toMatchObject({
477
+ kind: 'text',
478
+ label: 'Postal code',
479
+ required: true,
480
+ });
481
+ // Critical assertion: the Country textbox comes out as a listbox
482
+ // choice so fill_form will use pick_listbox_option.
483
+ expect(fields[1]).toMatchObject({
484
+ kind: 'choice',
485
+ choiceType: 'listbox',
486
+ label: 'Country',
487
+ required: true,
488
+ invalid: true,
489
+ });
490
+ // Back-compat: native <select> still classified as choice/select.
491
+ expect(fields[2]).toMatchObject({
492
+ kind: 'choice',
493
+ choiceType: 'select',
494
+ label: 'State / Region',
495
+ required: true,
496
+ value: 'California',
497
+ });
498
+ });
425
499
  });
426
500
  describe('buildFormRequiredSnapshot', () => {
427
501
  it('keeps offscreen required fields with bounds, visibility, and scroll hints', () => {
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { z } from 'zod';
5
5
  import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
6
- import { connect, connectThroughProxy, disconnect, getSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
6
+ import { connect, connectThroughProxy, disconnect, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
7
7
  function checkedStateInput() {
8
8
  return z
9
9
  .union([z.boolean(), z.literal('mixed')])
@@ -476,9 +476,10 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
476
476
  Unknown parameter names are rejected (strict schema). To wait until visible text goes away (e.g. a parsing banner), use geometra_wait_for with that substring in text and present: false — there is no textGone field.`,
477
477
  inputSchema: geometraQueryInputSchema,
478
478
  }, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, maxResults, detail, sessionId }) => {
479
- const session = getSession(sessionId);
480
- if (!session)
481
- return err('Not connected. Call geometra_connect first.');
479
+ const sessionResult = resolveToolSession(sessionId);
480
+ if ('error' in sessionResult)
481
+ return sessionResult.error;
482
+ const session = sessionResult.session;
482
483
  const a11y = await sessionA11yWhenReady(session);
483
484
  if (!a11y)
484
485
  return err('No UI tree available');
@@ -533,9 +534,10 @@ Use this when geometra_page_model tells you the page shape, but you want one dir
533
534
  detail: detailInput(),
534
535
  sessionId: sessionIdInput,
535
536
  }, async ({ name, role, sectionText, promptText, itemText, maxResults, detail, sessionId }) => {
536
- const session = getSession(sessionId);
537
- if (!session)
538
- return err('Not connected. Call geometra_connect first.');
537
+ const sessionResult = resolveToolSession(sessionId);
538
+ if ('error' in sessionResult)
539
+ return sessionResult.error;
540
+ const session = sessionResult.session;
539
541
  const a11y = await sessionA11yWhenReady(session);
540
542
  if (!a11y)
541
543
  return err('No UI tree available');
@@ -568,9 +570,10 @@ Use this when geometra_page_model tells you the page shape, but you want one dir
568
570
  The filter matches the same fields as geometra_query (strict schema — unknown keys error). Set \`present: false\` to wait until **no** node matches — for example Ashby/Lever-style “Parsing your resume” or any “Parsing…” banner: \`{ "text": "Parsing", "present": false }\` (tune the substring to the site). Do not use a textGone parameter; use \`text\` + \`present: false\`, or \`geometra_wait_for_resume_parse\` for the usual post-upload parsing banner.`,
569
571
  inputSchema: geometraWaitForInputSchema,
570
572
  }, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs, detail, sessionId }) => {
571
- const session = getSession(sessionId);
572
- if (!session)
573
- return err('Not connected. Call geometra_connect first.');
573
+ const sessionResult = resolveToolSession(sessionId);
574
+ if ('error' in sessionResult)
575
+ return sessionResult.error;
576
+ const session = sessionResult.session;
574
577
  const filterProbe = {
575
578
  id,
576
579
  role,
@@ -620,9 +623,10 @@ The filter matches the same fields as geometra_query (strict schema — unknown
620
623
  Equivalent to \`geometra_wait_for\` with \`present: false\` and \`text\` set to a banner substring. Default \`text\` is \`Parsing\` (tune per site). Strict schema (unknown keys rejected).`,
621
624
  inputSchema: geometraWaitForResumeParseInputSchema,
622
625
  }, async ({ text, timeoutMs, sessionId }) => {
623
- const session = getSession(sessionId);
624
- if (!session)
625
- return err('Not connected. Call geometra_connect first.');
626
+ const sessionResult = resolveToolSession(sessionId);
627
+ if ('error' in sessionResult)
628
+ return sessionResult.error;
629
+ const session = sessionResult.session;
626
630
  const filter = { text };
627
631
  const waited = await waitForSemanticCondition(session, {
628
632
  filter,
@@ -647,9 +651,10 @@ Captures the current URL, then polls until the URL changes and a stable UI tree
647
651
  expectedUrl: z.string().optional().describe('Optional URL substring to match — keeps waiting if the URL changes to something else (e.g. intermediate redirects)'),
648
652
  sessionId: sessionIdInput,
649
653
  }, async ({ timeoutMs, expectedUrl, sessionId }) => {
650
- const session = getSession(sessionId);
651
- if (!session)
652
- return err('Not connected. Call geometra_connect first.');
654
+ const sessionResult = resolveToolSession(sessionId);
655
+ if ('error' in sessionResult)
656
+ return sessionResult.error;
657
+ const session = sessionResult.session;
653
658
  const beforeA11y = sessionA11y(session);
654
659
  const beforeUrl = beforeA11y?.meta?.pageUrl ?? session.url;
655
660
  const startedAt = performance.now();
@@ -719,9 +724,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
719
724
  detail: detailInput(),
720
725
  sessionId: sessionIdInput,
721
726
  }, async ({ fields, stopOnError, failOnInvalid, includeSteps, detail, sessionId }) => {
722
- const session = getSession(sessionId);
723
- if (!session)
724
- return err('Not connected. Call geometra_connect first.');
727
+ const sessionResult = resolveToolSession(sessionId);
728
+ if ('error' in sessionResult)
729
+ return sessionResult.error;
730
+ const session = sessionResult.session;
725
731
  const resolvedFields = resolveFillFieldInputs(session, fields);
726
732
  if (!resolvedFields.ok)
727
733
  return err(resolvedFields.error);
@@ -1262,9 +1268,10 @@ Use this first on normal HTML pages when you want to understand the page shape w
1262
1268
  .describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy. Use when geometry alone is ambiguous (icon-only buttons, visual styling cues).'),
1263
1269
  sessionId: sessionIdInput,
1264
1270
  }, async ({ maxPrimaryActions, maxSectionsPerKind, includeScreenshot, sessionId }) => {
1265
- const session = getSession(sessionId);
1266
- if (!session)
1267
- return err('Not connected. Call geometra_connect first.');
1271
+ const sessionResult = resolveToolSession(sessionId);
1272
+ if ('error' in sessionResult)
1273
+ return sessionResult.error;
1274
+ const session = sessionResult.session;
1268
1275
  const a11y = await sessionA11yWhenReady(session);
1269
1276
  if (!a11y)
1270
1277
  return err('No UI tree available');
@@ -1341,9 +1348,10 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
1341
1348
  includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
1342
1349
  sessionId: sessionIdInput,
1343
1350
  }, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, sessionId, }) => {
1344
- const session = getSession(sessionId);
1345
- if (!session)
1346
- return err('Not connected. Call geometra_connect first.');
1351
+ const sessionResult = resolveToolSession(sessionId);
1352
+ if ('error' in sessionResult)
1353
+ return sessionResult.error;
1354
+ const session = sessionResult.session;
1347
1355
  const a11y = await sessionA11yWhenReady(session);
1348
1356
  if (!a11y)
1349
1357
  return err('No UI tree available');
@@ -1383,9 +1391,10 @@ This is the preferred approach for scrolling — no need to guess pixel offsets
1383
1391
  .describe('Per-scroll wait timeout (default 2500ms)'),
1384
1392
  sessionId: sessionIdInput,
1385
1393
  }, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs, sessionId }) => {
1386
- const session = getSession(sessionId);
1387
- if (!session)
1388
- return err('Not connected. Call geometra_connect first.');
1394
+ const sessionResult = resolveToolSession(sessionId);
1395
+ if ('error' in sessionResult)
1396
+ return sessionResult.error;
1397
+ const session = sessionResult.session;
1389
1398
  const filter = {
1390
1399
  id,
1391
1400
  role,
@@ -1451,9 +1460,10 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1451
1460
  detail: detailInput(),
1452
1461
  sessionId: sessionIdInput,
1453
1462
  }, async ({ x, y, id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxRevealSteps, revealTimeoutMs, waitFor, timeoutMs, detail, sessionId }) => {
1454
- const session = getSession(sessionId);
1455
- if (!session)
1456
- return err('Not connected. Call geometra_connect first.');
1463
+ const sessionResult = resolveToolSession(sessionId);
1464
+ if ('error' in sessionResult)
1465
+ return sessionResult.error;
1466
+ const session = sessionResult.session;
1457
1467
  const before = sessionA11y(session);
1458
1468
  const resolved = await resolveClickLocation(session, {
1459
1469
  x,
@@ -1547,9 +1557,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
1547
1557
  detail: detailInput(),
1548
1558
  sessionId: sessionIdInput,
1549
1559
  }, async ({ text, timeoutMs, detail, sessionId }) => {
1550
- const session = getSession(sessionId);
1551
- if (!session)
1552
- return err('Not connected. Call geometra_connect first.');
1560
+ const sessionResult = resolveToolSession(sessionId);
1561
+ if ('error' in sessionResult)
1562
+ return sessionResult.error;
1563
+ const session = sessionResult.session;
1553
1564
  const before = sessionA11y(session);
1554
1565
  const wait = await sendType(session, text, timeoutMs);
1555
1566
  const summary = postActionSummary(session, before, wait, detail);
@@ -1575,9 +1586,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
1575
1586
  detail: detailInput(),
1576
1587
  sessionId: sessionIdInput,
1577
1588
  }, async ({ key, shift, ctrl, meta, alt, timeoutMs, detail, sessionId }) => {
1578
- const session = getSession(sessionId);
1579
- if (!session)
1580
- return err('Not connected. Call geometra_connect first.');
1589
+ const sessionResult = resolveToolSession(sessionId);
1590
+ if ('error' in sessionResult)
1591
+ return sessionResult.error;
1592
+ const session = sessionResult.session;
1581
1593
  const before = sessionA11y(session);
1582
1594
  const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
1583
1595
  const summary = postActionSummary(session, before, wait, detail);
@@ -1586,6 +1598,46 @@ Each character is sent as a key event through the geometry protocol. Returns a c
1586
1598
  ...waitStatusPayload(wait),
1587
1599
  }, detail));
1588
1600
  });
1601
+ // ── fill OTP / verification-code box group ─────────────────────
1602
+ server.tool('geometra_fill_otp', `Fill a multi-cell OTP / verification-code input group (e.g. Greenhouse's 8-box security code, generic 6-digit 2FA widgets, Auth0 / Clerk per-char inputs). Auto-detects a row of sibling <input maxlength="1"> elements at adjacent x-coordinates and types each character through a real keyboard event cycle so React's per-cell onKeyDown handler can auto-advance focus.
1603
+
1604
+ Use this when geometra_fill_fields / geometra_fill_form fail on a 6-digit code field because the cells share accessibility bounds and the whole string gets written into cell 0 (which has maxlength=1 and silently truncates). This primitive is also auto-invoked by geometra_fill_fields and geometra_fill_form when a labeled field matches a verification-code / security-code / OTP pattern AND the underlying DOM is a cell group — you should only need to call it directly when the label doesn't match the auto-detection heuristic.
1605
+
1606
+ Detection is fully generic (no site branding). It refuses to run if the detected group's cell count is smaller than the typed value length, and it post-verifies every cell's value so you get an honest error instead of a silent "success" that leaves boxes empty.`, {
1607
+ value: z.string().min(1).max(32).describe('The code to type (e.g. "12345678" for an 8-digit security code)'),
1608
+ fieldLabel: z.string().optional().describe('Optional label to scope the OTP search (e.g. "Security code", "Verification code"). When omitted, scans the whole document for any qualifying cell group.'),
1609
+ perCharDelayMs: z.number().int().min(0).max(500).optional().describe('Optional per-character typing delay in milliseconds (default 30). Raise this if the widget needs longer to run its onKeyDown auto-advance.'),
1610
+ timeoutMs: z
1611
+ .number()
1612
+ .int()
1613
+ .min(500)
1614
+ .max(60_000)
1615
+ .optional()
1616
+ .describe('Optional action wait timeout'),
1617
+ detail: detailInput(),
1618
+ sessionId: sessionIdInput,
1619
+ }, async ({ value, fieldLabel, perCharDelayMs, timeoutMs, detail, sessionId }) => {
1620
+ const sessionResult = resolveToolSession(sessionId);
1621
+ if ('error' in sessionResult)
1622
+ return sessionResult.error;
1623
+ const session = sessionResult.session;
1624
+ const before = sessionA11y(session);
1625
+ try {
1626
+ const wait = await sendFillOtp(session, value, { fieldLabel, perCharDelayMs }, timeoutMs);
1627
+ const summary = postActionSummary(session, before, wait, detail);
1628
+ const result = wait.result;
1629
+ return ok(detailText(`Filled OTP code (${value.length} chars).\n${summary}`, {
1630
+ ...compactTextValue(value),
1631
+ ...(fieldLabel ? { fieldLabel } : {}),
1632
+ ...(result?.cellCount !== undefined ? { cellCount: result.cellCount } : {}),
1633
+ ...(result?.filledCount !== undefined ? { filledCount: result.filledCount } : {}),
1634
+ ...waitStatusPayload(wait),
1635
+ }, detail));
1636
+ }
1637
+ catch (e) {
1638
+ return err(e.message);
1639
+ }
1640
+ });
1589
1641
  // ── upload files (proxy) ───────────────────────────────────────
1590
1642
  server.tool('geometra_upload_files', `Attach local files to a file input. Requires \`@geometra/proxy\` (paths exist on the proxy host).
1591
1643
 
@@ -1613,9 +1665,10 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
1613
1665
  detail: detailInput(),
1614
1666
  sessionId: sessionIdInput,
1615
1667
  }, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
1616
- const session = getSession(sessionId);
1617
- if (!session)
1618
- return err('Not connected. Call geometra_connect first.');
1668
+ const sessionResult = resolveToolSession(sessionId);
1669
+ if ('error' in sessionResult)
1670
+ return sessionResult.error;
1671
+ const session = sessionResult.session;
1619
1672
  const before = sessionA11y(session);
1620
1673
  try {
1621
1674
  const wait = await sendFileUpload(session, paths, {
@@ -1659,9 +1712,10 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
1659
1712
  detail: detailInput(),
1660
1713
  sessionId: sessionIdInput,
1661
1714
  }, async ({ label, exact, openX, openY, fieldLabel, contextText, sectionText, query, timeoutMs, detail, sessionId }) => {
1662
- const session = getSession(sessionId);
1663
- if (!session)
1664
- return err('Not connected. Call geometra_connect first.');
1715
+ const sessionResult = resolveToolSession(sessionId);
1716
+ if ('error' in sessionResult)
1717
+ return sessionResult.error;
1718
+ const session = sessionResult.session;
1665
1719
  const before = sessionA11y(session);
1666
1720
  try {
1667
1721
  const wait = await sendListboxPick(session, label, {
@@ -1709,9 +1763,10 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
1709
1763
  detail: detailInput(),
1710
1764
  sessionId: sessionIdInput,
1711
1765
  }, async ({ x, y, value, label, index, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
1712
- const session = getSession(sessionId);
1713
- if (!session)
1714
- return err('Not connected. Call geometra_connect first.');
1766
+ const sessionResult = resolveToolSession(sessionId);
1767
+ if ('error' in sessionResult)
1768
+ return sessionResult.error;
1769
+ const session = sessionResult.session;
1715
1770
  if (value === undefined && label === undefined && index === undefined) {
1716
1771
  return err('Provide at least one of value, label, or index');
1717
1772
  }
@@ -1750,9 +1805,10 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
1750
1805
  detail: detailInput(),
1751
1806
  sessionId: sessionIdInput,
1752
1807
  }, async ({ label, checked, exact, controlType, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
1753
- const session = getSession(sessionId);
1754
- if (!session)
1755
- return err('Not connected. Call geometra_connect first.');
1808
+ const sessionResult = resolveToolSession(sessionId);
1809
+ if ('error' in sessionResult)
1810
+ return sessionResult.error;
1811
+ const session = sessionResult.session;
1756
1812
  const before = sessionA11y(session);
1757
1813
  try {
1758
1814
  const wait = await sendSetChecked(session, label, { checked, exact, controlType }, timeoutMs);
@@ -1784,9 +1840,10 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
1784
1840
  detail: detailInput(),
1785
1841
  sessionId: sessionIdInput,
1786
1842
  }, async ({ deltaY, deltaX, x, y, timeoutMs, detail, sessionId }) => {
1787
- const session = getSession(sessionId);
1788
- if (!session)
1789
- return err('Not connected. Call geometra_connect first.');
1843
+ const sessionResult = resolveToolSession(sessionId);
1844
+ if ('error' in sessionResult)
1845
+ return sessionResult.error;
1846
+ const session = sessionResult.session;
1790
1847
  const before = sessionA11y(session);
1791
1848
  try {
1792
1849
  const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
@@ -1815,9 +1872,10 @@ Use this for dropdowns, location pickers, or any scrollable list where items are
1815
1872
  scrollDelta: z.number().optional().default(300).describe('Vertical scroll delta per step (default 300)'),
1816
1873
  sessionId: sessionIdInput,
1817
1874
  }, async ({ listId, role, scrollX, scrollY, maxItems, maxScrollSteps, scrollDelta, sessionId }) => {
1818
- const session = getSession(sessionId);
1819
- if (!session)
1820
- return err('Not connected. Call geometra_connect first.');
1875
+ const sessionResult = resolveToolSession(sessionId);
1876
+ if ('error' in sessionResult)
1877
+ return sessionResult.error;
1878
+ const session = sessionResult.session;
1821
1879
  const itemRole = role ?? 'listitem';
1822
1880
  const collected = new Map();
1823
1881
  const cx = scrollX ?? 400;
@@ -1891,9 +1949,10 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
1891
1949
  .describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy.'),
1892
1950
  sessionId: sessionIdInput,
1893
1951
  }, async ({ view, maxNodes, formId, maxFields, includeOptions, includeScreenshot, sessionId }) => {
1894
- const session = getSession(sessionId);
1895
- if (!session)
1896
- return err('Not connected. Call geometra_connect first.');
1952
+ const sessionResult = resolveToolSession(sessionId);
1953
+ if ('error' in sessionResult)
1954
+ return sessionResult.error;
1955
+ const session = sessionResult.session;
1897
1956
  const a11y = await sessionA11yWhenReady(session);
1898
1957
  if (!a11y)
1899
1958
  return err('No UI tree available');
@@ -1927,10 +1986,15 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
1927
1986
  // ── layout ───────────────────────────────────────────────────
1928
1987
  server.tool('geometra_layout', `Get the raw computed layout geometry — the exact {x, y, width, height} for every node in the UI tree. This is the lowest-level view, useful for pixel-precise assertions in tests.
1929
1988
 
1930
- For a token-efficient semantic view, use geometra_snapshot (default compact). For the complete nested tree, geometra_snapshot with view=full.`, {}, async () => {
1931
- const session = getSession();
1932
- if (!session?.layout)
1933
- return err('Not connected. Call geometra_connect first.');
1989
+ For a token-efficient semantic view, use geometra_snapshot (default compact). For the complete nested tree, geometra_snapshot with view=full.`, {
1990
+ sessionId: sessionIdInput,
1991
+ }, async ({ sessionId }) => {
1992
+ const sessionResult = resolveToolSession(sessionId);
1993
+ if ('error' in sessionResult)
1994
+ return sessionResult.error;
1995
+ const session = sessionResult.session;
1996
+ if (!session.layout)
1997
+ return err('No layout available yet. Wait for the next frame or call geometra_page_model.');
1934
1998
  return ok(JSON.stringify(session.layout, null, 2));
1935
1999
  });
1936
2000
  // ── workflow state ───────────────────────────────────────────
@@ -1940,9 +2004,10 @@ Use this after navigating to a new page in a multi-step flow (e.g. job applicati
1940
2004
  clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
1941
2005
  sessionId: sessionIdInput,
1942
2006
  }, async ({ clear, sessionId }) => {
1943
- const session = getSession(sessionId);
1944
- if (!session)
1945
- return err('Not connected. Call geometra_connect first.');
2007
+ const sessionResult = resolveToolSession(sessionId);
2008
+ if ('error' in sessionResult)
2009
+ return sessionResult.error;
2010
+ const session = sessionResult.session;
1946
2011
  if (clear) {
1947
2012
  session.workflowState = undefined;
1948
2013
  return ok(JSON.stringify({ cleared: true }));
@@ -2005,9 +2070,10 @@ Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
2005
2070
  .describe('Include background graphics and colors.'),
2006
2071
  sessionId: sessionIdInput,
2007
2072
  }, async ({ html, format, landscape, margin, printBackground, sessionId }) => {
2008
- const session = getSession(sessionId);
2009
- if (!session)
2010
- return err('Not connected. Call geometra_connect first.');
2073
+ const sessionResult = resolveToolSession(sessionId);
2074
+ if ('error' in sessionResult)
2075
+ return sessionResult.error;
2076
+ const session = sessionResult.session;
2011
2077
  try {
2012
2078
  const wait = await sendPdfGenerate(session, {
2013
2079
  html: html ?? undefined,
@@ -2241,10 +2307,24 @@ function pageModelResponsePayload(session, options) {
2241
2307
  }
2242
2308
  async function ensureToolSession(target, missingConnectionMessage = 'Not connected. Call geometra_connect first.') {
2243
2309
  if (!target.url && !target.pageUrl) {
2244
- const session = getSession(target.sessionId);
2245
- if (!session)
2246
- return { ok: false, error: missingConnectionMessage };
2247
- return { ok: true, session, autoConnected: false };
2310
+ const resolved = resolveSession(target.sessionId);
2311
+ if (resolved.kind === 'ok') {
2312
+ return { ok: true, session: resolved.session, autoConnected: false };
2313
+ }
2314
+ if (resolved.kind === 'not_found') {
2315
+ return {
2316
+ ok: false,
2317
+ error: `session_not_found: no active session with id "${resolved.id}". Active sessions: ${resolved.activeIds.join(', ') || '(none)'}.`,
2318
+ };
2319
+ }
2320
+ if (resolved.kind === 'ambiguous') {
2321
+ const isolatedSuffix = resolved.isolatedIds.length > 0 ? ` (isolated: ${resolved.isolatedIds.join(', ')})` : '';
2322
+ return {
2323
+ ok: false,
2324
+ error: `multiple_active_sessions_provide_id: ${resolved.activeIds.length} active sessions — ${resolved.activeIds.join(', ')}${isolatedSuffix}. Pass sessionId explicitly.`,
2325
+ };
2326
+ }
2327
+ return { ok: false, error: missingConnectionMessage };
2248
2328
  }
2249
2329
  const normalized = normalizeConnectTarget({ url: target.url, pageUrl: target.pageUrl });
2250
2330
  if (!normalized.ok)
@@ -3616,6 +3696,41 @@ async function captureScreenshotBase64(session) {
3616
3696
  function err(text) {
3617
3697
  return { content: [{ type: 'text', text }], isError: true };
3618
3698
  }
3699
+ /**
3700
+ * Resolve a tool call's target session with strict routing. Returns either
3701
+ * a `Session` or a tool error response that the caller should propagate
3702
+ * verbatim. This is the server-side entry point for the Bug #1 fix — every
3703
+ * tool that used to do `const session = getSession(sessionId); if (!session)
3704
+ * return err('Not connected...')` should use this helper instead so that
3705
+ * ambiguous / not-found / none conditions get distinct, honest errors
3706
+ * instead of silently routing onto a stale default or a peer worker's
3707
+ * isolated session. The typical call site is:
3708
+ *
3709
+ * const sessionResult = resolveToolSession(sessionId)
3710
+ * if ('error' in sessionResult) return sessionResult.error
3711
+ * const session = sessionResult.session
3712
+ */
3713
+ function resolveToolSession(sessionId) {
3714
+ const result = resolveSession(sessionId);
3715
+ switch (result.kind) {
3716
+ case 'ok':
3717
+ return { session: result.session };
3718
+ case 'none':
3719
+ return { error: err('Not connected. Call geometra_connect first.') };
3720
+ case 'not_found':
3721
+ return {
3722
+ error: err(`session_not_found: no active session with id "${result.id}". Active sessions: ${result.activeIds.length > 0 ? result.activeIds.join(', ') : '(none)'}. Call geometra_connect again to start a new session — the MCP server never silently routes an explicit sessionId onto a different session.`),
3723
+ };
3724
+ case 'ambiguous': {
3725
+ const isolatedSuffix = result.isolatedIds.length > 0
3726
+ ? ` (isolated: ${result.isolatedIds.join(', ')})`
3727
+ : '';
3728
+ return {
3729
+ error: err(`multiple_active_sessions_provide_id: ${result.activeIds.length} active sessions — ${result.activeIds.join(', ')}${isolatedSuffix}. Pass sessionId explicitly; the implicit-default fallback is disabled while multiple sessions or any isolated session is active to prevent cross-contamination under parallel-worker load.`),
3730
+ };
3731
+ }
3732
+ }
3733
+ }
3619
3734
  function hasNodeFilter(filter) {
3620
3735
  return Object.values(filter).some(value => value !== undefined);
3621
3736
  }
package/dist/session.d.ts CHANGED
@@ -33,6 +33,19 @@ export interface A11yNode {
33
33
  inputPattern?: string;
34
34
  inputType?: string;
35
35
  autocomplete?: string;
36
+ /**
37
+ * True when the extractor detected that this `<input>` (or role=textbox)
38
+ * lives inside an autocomplete / searchable combobox wrapper — React
39
+ * Select, Radix Select, Headless UI combobox, Ant Select, cmdk, etc.
40
+ * Set from the extractor's `isAutocompleteComboboxAncestry` detector,
41
+ * which mirrors `isAutocompleteCombobox` in `dom-actions.ts`. The
42
+ * form-schema classifier reads this to re-tag the field as
43
+ * `choice` / `listbox` instead of `text`, so `fill_form` routes through
44
+ * `pick_listbox_option` (which does the click + Enter-commit dance that
45
+ * plain text-fill cannot do for a controlled React Select state).
46
+ * See Bug #3 in the v1.43 release notes.
47
+ */
48
+ isAutocompleteCombobox?: boolean;
36
49
  };
37
50
  bounds: {
38
51
  x: number;
@@ -513,6 +526,40 @@ export declare function connectThroughProxy(options: {
513
526
  isolated?: boolean;
514
527
  }): Promise<Session>;
515
528
  export declare function getSession(id?: string): Session | null;
529
+ /**
530
+ * Tool-side session resolution with strict routing semantics.
531
+ *
532
+ * - If `id` is passed, return exactly that session or `session_not_found`.
533
+ * Never fall back to the default — explicit ids mean the caller is tracking
534
+ * its own session and silently rerouting to some other session is worse
535
+ * than an honest failure.
536
+ * - If no `id` is passed, resolve to the default session IF AND ONLY IF
537
+ * there is exactly one active session AND it is not isolated. Otherwise
538
+ * return `ambiguous_default` so the caller is forced to provide an
539
+ * explicit sessionId.
540
+ *
541
+ * This is the Bug #1 session-contamination fix: tool calls that omit
542
+ * `sessionId` under parallel-worker load used to get routed to "most recent
543
+ * session", which was whatever parallel peer last called `geometra_connect`.
544
+ * That made cross-worker browser stomping inevitable. Forcing an explicit
545
+ * `sessionId` once >1 session is active (or whenever an isolated session is
546
+ * active) makes that class of bug structurally impossible.
547
+ */
548
+ export type ResolveSessionResult = {
549
+ kind: 'ok';
550
+ session: Session;
551
+ } | {
552
+ kind: 'not_found';
553
+ id: string;
554
+ activeIds: string[];
555
+ } | {
556
+ kind: 'ambiguous';
557
+ activeIds: string[];
558
+ isolatedIds: string[];
559
+ } | {
560
+ kind: 'none';
561
+ };
562
+ export declare function resolveSession(id?: string): ResolveSessionResult;
516
563
  export declare function listSessions(): Array<{
517
564
  id: string;
518
565
  url: string;
@@ -571,6 +618,16 @@ export declare function sendFieldChoice(session: Session, fieldLabel: string, va
571
618
  }, timeoutMs?: number): Promise<UpdateWaitResult>;
572
619
  /** Fill several semantic form fields in one proxy-side batch. */
573
620
  export declare function sendFillFields(session: Session, fields: ProxyFillField[], timeoutMs?: number): Promise<UpdateWaitResult>;
621
+ /**
622
+ * Fill an OTP / verification-code input group by typing char-by-char into
623
+ * the leftmost cell. See `fillOtp` in `packages/proxy/src/dom-actions.ts`
624
+ * for the detection and typing strategy. Bug #2 (v1.43) release notes
625
+ * cover the Greenhouse 8-box widget that made this primitive necessary.
626
+ */
627
+ export declare function sendFillOtp(session: Session, value: string, opts?: {
628
+ fieldLabel?: string;
629
+ perCharDelayMs?: number;
630
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
574
631
  /** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
575
632
  export declare function sendListboxPick(session: Session, label: string, opts?: {
576
633
  exact?: boolean;
package/dist/session.js CHANGED
@@ -192,11 +192,36 @@ function rememberReusableProxyPageUrl(session) {
192
192
  touchReusableProxy(entry);
193
193
  }
194
194
  function promoteDefaultSession() {
195
- if (activeSessions.size > 0) {
196
- defaultSessionId = Array.from(activeSessions.keys()).pop();
195
+ // Never promote an isolated session to be the implicit default. The whole
196
+ // point of `isolated: true` is that parallel workers should only address
197
+ // their session by its explicit id — falling back to "most recent" picks
198
+ // a random peer's browser and is exactly the contamination vector that
199
+ // made v1.42 apply marathons blow up. Walk the active sessions from newest
200
+ // to oldest and pick the newest non-isolated one; if none exist, the
201
+ // default goes null and `getSession(undefined)` will surface an
202
+ // `ambiguous_default` or `no_session` error instead of silently handing
203
+ // out a random isolated session.
204
+ const ids = Array.from(activeSessions.keys());
205
+ for (let i = ids.length - 1; i >= 0; i--) {
206
+ const id = ids[i];
207
+ const session = activeSessions.get(id);
208
+ if (session && !session.isolated) {
209
+ defaultSessionId = id;
210
+ return;
211
+ }
197
212
  }
198
- else {
199
- defaultSessionId = null;
213
+ defaultSessionId = null;
214
+ }
215
+ /**
216
+ * Clear `defaultSessionId` if it currently points at the given session id.
217
+ * Used after tagging a fresh session as isolated — `connect()` set the
218
+ * default pointer before the isolation flag was applied, and we need to
219
+ * retract that assignment so this session is only addressable by its
220
+ * explicit id.
221
+ */
222
+ function retractDefaultIfPointsTo(sessionId) {
223
+ if (defaultSessionId === sessionId) {
224
+ promoteDefaultSession();
200
225
  }
201
226
  }
202
227
  function shutdownSession(id, opts) {
@@ -486,6 +511,12 @@ async function startFreshProxySession(options) {
486
511
  session.proxyReusable = !options.isolated;
487
512
  if (options.isolated) {
488
513
  session.isolated = true;
514
+ // connect() already set defaultSessionId to this session. Retract
515
+ // that assignment so the isolated session is only addressable by its
516
+ // explicit id — the implicit-default fallback is the contamination
517
+ // vector we're fixing, and an isolated session must never be the
518
+ // implicit target of a tool call that omits sessionId.
519
+ retractDefaultIfPointsTo(session.id);
489
520
  }
490
521
  else {
491
522
  setReusableProxy({ runtime }, wsUrl, {
@@ -544,6 +575,9 @@ async function startFreshProxySession(options) {
544
575
  session.proxyReusable = !options.isolated;
545
576
  if (options.isolated) {
546
577
  session.isolated = true;
578
+ // See the embedded-runtime path above — an isolated session must
579
+ // not be the implicit default. Retract the pointer set by connect().
580
+ retractDefaultIfPointsTo(session.id);
547
581
  }
548
582
  else {
549
583
  setReusableProxy({ child }, wsUrl, {
@@ -785,6 +819,35 @@ export function getSession(id) {
785
819
  return activeSessions.get(defaultSessionId) ?? null;
786
820
  return null;
787
821
  }
822
+ export function resolveSession(id) {
823
+ if (id) {
824
+ const found = activeSessions.get(id);
825
+ if (found)
826
+ return { kind: 'ok', session: found };
827
+ return {
828
+ kind: 'not_found',
829
+ id,
830
+ activeIds: Array.from(activeSessions.keys()),
831
+ };
832
+ }
833
+ const active = Array.from(activeSessions.values());
834
+ if (active.length === 0)
835
+ return { kind: 'none' };
836
+ const isolatedIds = active.filter(s => s.isolated).map(s => s.id);
837
+ // Strict routing: if there is more than one active session, OR any active
838
+ // session is isolated (even if it's the only one), require an explicit id.
839
+ // The "only one isolated session" case still demands an explicit id
840
+ // because the point of isolation is that the caller tracks its own session
841
+ // and other tools must never implicitly attach to it.
842
+ if (active.length > 1 || isolatedIds.length > 0) {
843
+ return {
844
+ kind: 'ambiguous',
845
+ activeIds: active.map(s => s.id),
846
+ isolatedIds,
847
+ };
848
+ }
849
+ return { kind: 'ok', session: active[0] };
850
+ }
788
851
  export function listSessions() {
789
852
  return Array.from(activeSessions.values()).map(s => ({ id: s.id, url: s.url }));
790
853
  }
@@ -987,6 +1050,25 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
987
1050
  export function sendFillFields(session, fields, timeoutMs = estimateFillBatchTimeout(fields)) {
988
1051
  return sendAndWaitForUpdate(session, { type: 'fillFields', fields }, timeoutMs);
989
1052
  }
1053
+ /**
1054
+ * Fill an OTP / verification-code input group by typing char-by-char into
1055
+ * the leftmost cell. See `fillOtp` in `packages/proxy/src/dom-actions.ts`
1056
+ * for the detection and typing strategy. Bug #2 (v1.43) release notes
1057
+ * cover the Greenhouse 8-box widget that made this primitive necessary.
1058
+ */
1059
+ export function sendFillOtp(session, value, opts, timeoutMs) {
1060
+ const payload = {
1061
+ type: 'fillOtp',
1062
+ value,
1063
+ };
1064
+ if (opts?.fieldLabel)
1065
+ payload.fieldLabel = opts.fieldLabel;
1066
+ if (opts?.perCharDelayMs !== undefined)
1067
+ payload.perCharDelayMs = opts.perCharDelayMs;
1068
+ // Budget: base 3s + 150ms/char to cover the per-cell delay plus verify.
1069
+ const budget = timeoutMs ?? Math.max(3_000, 3_000 + value.length * 150);
1070
+ return sendAndWaitForUpdate(session, payload, budget);
1071
+ }
990
1072
  /** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
991
1073
  export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
992
1074
  const payload = { type: 'listboxPick', label };
@@ -1785,17 +1867,35 @@ function simpleSchemaField(root, node) {
1785
1867
  const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
1786
1868
  if (!label)
1787
1869
  return null;
1788
- const choiceType = node.role === 'combobox'
1789
- ? node.meta?.controlTag === 'select'
1790
- ? 'select'
1791
- : 'listbox'
1870
+ // Bug #3 (v1.43): if this node LOOKS like a plain textbox but its
1871
+ // ancestry fingerprints an autocomplete / searchable combobox wrapper
1872
+ // (React Select, Radix Select, Headless UI combobox, Ant Select, cmdk),
1873
+ // re-tag it as a listbox choice field. Without this, a React Select
1874
+ // Country picker in a Greenhouse Remix form gets classified as `text`,
1875
+ // fill_form routes through sendFieldText, and the controlled form
1876
+ // state never actually commits — Greenhouse's validator then says
1877
+ // "Country is required" and clears the value on submit-attempt scroll.
1878
+ // The autocomplete-combobox signal is set by the extractor's
1879
+ // isAutocompleteComboboxAncestry detector, which mirrors the
1880
+ // isAutocompleteCombobox helper used by pickListboxOption in
1881
+ // dom-actions.ts (v1.42 fix). Keeping the two detectors aligned is how
1882
+ // we guarantee that fill_form's classification matches what
1883
+ // pick_listbox_option can actually commit.
1884
+ const extractorSaysTextbox = node.role === 'textbox' && node.meta?.isAutocompleteCombobox === true;
1885
+ const classifiedRole = extractorSaysTextbox ? 'combobox' : node.role;
1886
+ const classifiedChoiceType = classifiedRole === 'combobox'
1887
+ ? // Native <select> stays on choiceType 'select'; any wrapper pattern
1888
+ // routes through 'listbox' so pick_listbox_option handles it.
1889
+ node.meta?.controlTag === 'select' && node.meta?.isAutocompleteCombobox !== true
1890
+ ? 'select'
1891
+ : 'listbox'
1792
1892
  : undefined;
1793
1893
  const format = buildFieldFormat(node);
1794
1894
  return {
1795
1895
  id: formFieldIdForPath(node.path),
1796
- kind: node.role === 'combobox' ? 'choice' : 'text',
1896
+ kind: classifiedRole === 'combobox' ? 'choice' : 'text',
1797
1897
  label,
1798
- ...(choiceType ? { choiceType } : {}),
1898
+ ...(classifiedChoiceType ? { choiceType: classifiedChoiceType } : {}),
1799
1899
  ...(node.state?.required ? { required: true } : {}),
1800
1900
  ...(node.state?.invalid ? { invalid: true } : {}),
1801
1901
  ...compactSchemaValue(node.value, 72),
@@ -2713,6 +2813,8 @@ function walkNode(element, layout, path) {
2713
2813
  meta.inputType = semantic.inputType;
2714
2814
  if (typeof semantic?.autocomplete === 'string')
2715
2815
  meta.autocomplete = semantic.autocomplete;
2816
+ if (semantic?.isAutocompleteCombobox === true)
2817
+ meta.isAutocompleteCombobox = true;
2716
2818
  const children = [];
2717
2819
  const elementChildren = element.children;
2718
2820
  const layoutChildren = layout.children;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.42.0",
3
+ "version": "1.43.0",
4
4
  "description": "MCP server for Geometra — interact with running Geometra apps via the geometry protocol, no browser needed",
5
5
  "license": "MIT",
6
6
  "type": "module",