@geometra/mcp 1.63.2 → 1.65.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/dist/server.js CHANGED
@@ -3,7 +3,9 @@ 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, pruneDisconnectedSessions, 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';
6
+ import { REDACTED_STATE_URL, sanitizeUrlToOrigin } from './state-privacy.js';
7
+ import { SERVER_IMPLEMENTATION } from './version.js';
8
+ import { connect, connectThroughProxy, ensureSessionConnected, disconnect, pruneDisconnectedSessions, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, formSchemaToFormGraph, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
7
9
  function checkedStateInput() {
8
10
  return z
9
11
  .union([z.boolean(), z.literal('mixed')])
@@ -188,7 +190,7 @@ const GEOMETRA_WAIT_FILTER_REQUIRED_MESSAGE = 'Provide at least one semantic fil
188
190
  '(common for “Parsing…”, “Parsing your resume”, or similar). Passing only present/timeoutMs is not enough without a filter.';
189
191
  /** Strict input so unknown keys (e.g. textGone) fail parse; empty-filter checks happen in handlers / waitForSemanticCondition. */
190
192
  const sessionIdSchemaField = {
191
- sessionId: z.string().optional().describe('Session identifier returned by geometra_connect. Omit to use the most recent session.'),
193
+ sessionId: z.string().optional().describe('Session identifier returned by geometra_connect or an auto-connected tool. Omit only when exactly one non-isolated session is active.'),
192
194
  };
193
195
  const geometraQueryInputSchema = z.object({
194
196
  ...nodeFilterShape(),
@@ -282,11 +284,87 @@ function capBatchActionTimeouts(action, capMs) {
282
284
  return action;
283
285
  }
284
286
  }
287
+ const nonEmptyFieldIdSchema = z.string().trim().min(1, 'fieldId must not be empty');
288
+ const nonEmptyFieldLabelSchema = z.string().trim().min(1, 'fieldLabel must not be empty');
289
+ const nonEmptyToggleLabelSchema = z.string().trim().min(1, 'toggle label must not be empty');
290
+ function incompleteCoordinatePairError(input, first, second) {
291
+ const hasFirst = input[first] !== undefined;
292
+ const hasSecond = input[second] !== undefined;
293
+ return hasFirst === hasSecond
294
+ ? undefined
295
+ : `${first} and ${second} must be provided together`;
296
+ }
297
+ function fileUploadContractError(input) {
298
+ const clickPairError = incompleteCoordinatePairError(input, 'x', 'y');
299
+ if (clickPairError)
300
+ return clickPairError;
301
+ const dropPairError = incompleteCoordinatePairError(input, 'dropX', 'dropY');
302
+ if (dropPairError)
303
+ return dropPairError;
304
+ const hasClick = input.x !== undefined && input.y !== undefined;
305
+ const hasDrop = input.dropX !== undefined && input.dropY !== undefined;
306
+ const hasFieldLabel = typeof input.fieldLabel === 'string' && input.fieldLabel.trim().length > 0;
307
+ const hasSemanticConstraint = hasFieldLabel || input.contextText !== undefined || input.sectionText !== undefined || input.exact !== undefined;
308
+ const strategy = input.strategy ?? 'auto';
309
+ if ((input.contextText !== undefined || input.sectionText !== undefined || input.exact !== undefined) && !hasFieldLabel) {
310
+ return 'contextText, sectionText, and exact require fieldLabel';
311
+ }
312
+ if (strategy === 'chooser') {
313
+ if (!hasClick)
314
+ return 'chooser strategy requires x and y';
315
+ if (hasDrop || hasSemanticConstraint)
316
+ return 'chooser strategy accepts only an x,y target; semantic and drop targets cannot be combined';
317
+ return undefined;
318
+ }
319
+ if (strategy === 'drop') {
320
+ if (!hasDrop)
321
+ return 'drop strategy requires dropX and dropY';
322
+ if (hasClick || hasSemanticConstraint)
323
+ return 'drop strategy accepts only a dropX,dropY target; chooser and semantic targets cannot be combined';
324
+ return undefined;
325
+ }
326
+ if (strategy === 'hidden') {
327
+ if (!hasFieldLabel)
328
+ return 'hidden strategy requires fieldLabel to prevent a global first-input fallback';
329
+ if (hasClick || hasDrop)
330
+ return 'hidden strategy does not accept chooser or drop coordinates';
331
+ return undefined;
332
+ }
333
+ if (hasDrop)
334
+ return 'dropX and dropY require strategy="drop"';
335
+ if (hasClick && hasFieldLabel)
336
+ return 'auto strategy requires exactly one target: either x,y or fieldLabel';
337
+ if (!hasClick && !hasFieldLabel)
338
+ return 'upload_files requires an explicit target: fieldLabel, x and y, or strategy="drop" with dropX and dropY';
339
+ return undefined;
340
+ }
341
+ function listboxPickContractError(input) {
342
+ if (typeof input.fieldLabel !== 'string' || input.fieldLabel.trim().length === 0) {
343
+ return 'pick_listbox_option requires fieldLabel so the committed field can be confirmed';
344
+ }
345
+ return undefined;
346
+ }
347
+ function selectOptionContractError(input) {
348
+ const coordinateError = incompleteCoordinatePairError(input, 'x', 'y');
349
+ if (coordinateError)
350
+ return coordinateError;
351
+ if (input.x === undefined || input.y === undefined)
352
+ return 'select_option requires x and y';
353
+ const selectorCount = Number(input.value !== undefined) + Number(input.label !== undefined) + Number(input.index !== undefined);
354
+ if (selectorCount === 0)
355
+ return 'select_option requires at least one of value, label, or index';
356
+ return undefined;
357
+ }
358
+ function addActionContractIssue(ctx, message) {
359
+ if (!message)
360
+ return;
361
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message });
362
+ }
285
363
  const fillFieldSchema = z.union([
286
364
  z.object({
287
365
  kind: z.literal('text'),
288
- fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
289
- fieldLabel: z.string().describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
366
+ fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
367
+ fieldLabel: nonEmptyFieldLabelSchema.describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
290
368
  value: z.string().describe('Text value to set'),
291
369
  exact: z.boolean().optional().describe('Exact label match'),
292
370
  typingDelayMs: z
@@ -301,11 +379,11 @@ const fillFieldSchema = z.union([
301
379
  .optional()
302
380
  .describe('Use composition-friendly events for IME-heavy controlled fields.'),
303
381
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
304
- }),
382
+ }).strict(),
305
383
  z.object({
306
384
  kind: z.literal('text'),
307
- fieldId: z.string().describe('Stable field id from geometra_form_schema'),
308
- fieldLabel: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
385
+ fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
386
+ fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
309
387
  value: z.string().describe('Text value to set'),
310
388
  exact: z.boolean().optional().describe('Exact label match'),
311
389
  typingDelayMs: z
@@ -320,12 +398,13 @@ const fillFieldSchema = z.union([
320
398
  .optional()
321
399
  .describe('Use composition-friendly events for IME-heavy controlled fields.'),
322
400
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
323
- }),
401
+ }).strict(),
324
402
  z.object({
325
403
  kind: z.literal('choice'),
326
- fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
327
- fieldLabel: z.string().describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
404
+ fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
405
+ fieldLabel: nonEmptyFieldLabelSchema.describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
328
406
  value: z.string().describe('Desired option value / answer label'),
407
+ optionIndex: z.number().int().min(0).optional().describe('Exact native <select> option index from optionDetails'),
329
408
  query: z.string().optional().describe('Optional search text for searchable comboboxes'),
330
409
  choiceType: z
331
410
  .enum(['select', 'group', 'listbox'])
@@ -333,12 +412,13 @@ const fillFieldSchema = z.union([
333
412
  .describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
334
413
  exact: z.boolean().optional().describe('Exact label match'),
335
414
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
336
- }),
415
+ }).strict(),
337
416
  z.object({
338
417
  kind: z.literal('choice'),
339
- fieldId: z.string().describe('Stable field id from geometra_form_schema'),
340
- fieldLabel: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
418
+ fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
419
+ fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
341
420
  value: z.string().describe('Desired option value / answer label'),
421
+ optionIndex: z.number().int().min(0).optional().describe('Exact native <select> option index from optionDetails'),
342
422
  query: z.string().optional().describe('Optional search text for searchable comboboxes'),
343
423
  choiceType: z
344
424
  .enum(['select', 'group', 'listbox'])
@@ -346,42 +426,51 @@ const fillFieldSchema = z.union([
346
426
  .describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
347
427
  exact: z.boolean().optional().describe('Exact label match'),
348
428
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
349
- }),
429
+ }).strict(),
350
430
  z.object({
351
431
  kind: z.literal('toggle'),
352
- fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
353
- label: z.string().describe('Visible checkbox/radio label to set. Optional to duplicate when fieldId is present.'),
432
+ fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
433
+ label: nonEmptyToggleLabelSchema.describe('Visible checkbox/radio label to set. Optional to duplicate when fieldId is present.'),
354
434
  checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
355
435
  exact: z.boolean().optional().describe('Exact label match'),
356
436
  controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
357
437
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
358
- }),
438
+ }).strict(),
359
439
  z.object({
360
440
  kind: z.literal('toggle'),
361
- fieldId: z.string().describe('Stable field id from geometra_form_schema'),
362
- label: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
441
+ fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
442
+ label: nonEmptyToggleLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
363
443
  checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
364
444
  exact: z.boolean().optional().describe('Exact label match'),
365
445
  controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
366
446
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
367
- }),
447
+ }).strict(),
368
448
  z.object({
369
449
  kind: z.literal('file'),
370
- fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
371
- fieldLabel: z.string().describe('Visible file-field label / accessible name'),
450
+ fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
451
+ fieldLabel: nonEmptyFieldLabelSchema.describe('Visible file-field label / accessible name'),
372
452
  paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
373
453
  exact: z.boolean().optional().describe('Exact label match'),
374
454
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
375
- }),
455
+ }).strict(),
376
456
  z.object({
377
457
  kind: z.literal('file'),
378
- fieldId: z.string().describe('Stable field id from geometra_form_schema'),
379
- fieldLabel: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label when file fields are exposed by geometra_form_schema'),
458
+ fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
459
+ fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label when file fields are exposed by geometra_form_schema'),
380
460
  paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
381
461
  exact: z.boolean().optional().describe('Exact label match'),
382
462
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
383
- }),
463
+ }).strict(),
384
464
  ]);
465
+ function toProxyFillFields(fields) {
466
+ return fields.map(field => {
467
+ const proxyField = { ...field };
468
+ delete proxyField.timeoutMs;
469
+ if (proxyField.kind === 'choice')
470
+ delete proxyField.expectedReadback;
471
+ return proxyField;
472
+ });
473
+ }
385
474
  const formValueSchema = z.union([
386
475
  z.string(),
387
476
  z.boolean(),
@@ -398,14 +487,14 @@ const batchActionSchema = z.discriminatedUnion('type', [
398
487
  fullyVisible: z.boolean().optional().describe('When clicking by semantic target, require full visibility before clicking (default true)'),
399
488
  maxRevealSteps: z.number().int().min(1).max(48).optional().describe('Maximum reveal attempts before clicking a semantic target. When omitted, Geometra auto-scales from scroll distance for tall forms.'),
400
489
  revealTimeoutMs: timeoutMsInput.describe('Per-scroll wait timeout while revealing a semantic target'),
401
- waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the click'),
490
+ waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the click'),
402
491
  timeoutMs: timeoutMsInput,
403
- }),
492
+ }).strict(),
404
493
  z.object({
405
494
  type: z.literal('type'),
406
- text: z.string(),
495
+ text: z.string().max(65_536),
407
496
  timeoutMs: timeoutMsInput,
408
- }),
497
+ }).strict(),
409
498
  z.object({
410
499
  type: z.literal('key'),
411
500
  key: z.string(),
@@ -414,46 +503,51 @@ const batchActionSchema = z.discriminatedUnion('type', [
414
503
  meta: z.boolean().optional(),
415
504
  alt: z.boolean().optional(),
416
505
  timeoutMs: timeoutMsInput,
417
- }),
506
+ }).strict(),
418
507
  z.object({
419
508
  type: z.literal('upload_files'),
420
- paths: z.array(z.string()).min(1),
509
+ paths: z.array(z.string().trim().min(1)).min(1),
421
510
  x: z.number().optional(),
422
511
  y: z.number().optional(),
423
- fieldLabel: z.string().optional(),
512
+ fieldLabel: z.string().trim().min(1).optional(),
424
513
  exact: z.boolean().optional(),
425
514
  strategy: z.enum(['auto', 'chooser', 'hidden', 'drop']).optional(),
426
515
  dropX: z.number().optional(),
427
516
  dropY: z.number().optional(),
517
+ contextText: z.string().trim().min(1).optional(),
518
+ sectionText: z.string().trim().min(1).optional(),
428
519
  timeoutMs: timeoutMsInput,
429
- }),
520
+ }).strict(),
430
521
  z.object({
431
522
  type: z.literal('pick_listbox_option'),
432
- label: z.string(),
523
+ label: z.string().trim().min(1),
433
524
  exact: z.boolean().optional(),
434
- openX: z.number().optional(),
435
- openY: z.number().optional(),
436
- fieldLabel: z.string().optional(),
525
+ fieldId: z.string().trim().min(1).optional(),
526
+ fieldKey: z.string().trim().min(1).optional(),
527
+ fieldLabel: z.string().trim().min(1),
437
528
  query: z.string().optional(),
438
529
  timeoutMs: timeoutMsInput,
439
- }),
530
+ }).strict(),
440
531
  z.object({
441
532
  type: z.literal('select_option'),
442
533
  x: z.number(),
443
534
  y: z.number(),
444
535
  value: z.string().optional(),
445
- label: z.string().optional(),
536
+ label: z.string().trim().min(1).optional(),
446
537
  index: z.number().int().min(0).optional(),
447
538
  timeoutMs: timeoutMsInput,
448
- }),
539
+ }).strict(),
449
540
  z.object({
450
541
  type: z.literal('set_checked'),
451
- label: z.string(),
542
+ label: z.string().trim().min(1, 'set_checked label must not be empty'),
452
543
  checked: z.boolean().optional(),
453
544
  exact: z.boolean().optional(),
454
545
  controlType: z.enum(['checkbox', 'radio']).optional(),
546
+ fieldKey: z.string().trim().min(1).optional(),
547
+ contextText: z.string().trim().min(1).optional(),
548
+ sectionText: z.string().trim().min(1).optional(),
455
549
  timeoutMs: timeoutMsInput,
456
- }),
550
+ }).strict(),
457
551
  z.object({
458
552
  type: z.literal('wheel'),
459
553
  deltaY: z.number(),
@@ -461,13 +555,13 @@ const batchActionSchema = z.discriminatedUnion('type', [
461
555
  x: z.number().optional(),
462
556
  y: z.number().optional(),
463
557
  timeoutMs: timeoutMsInput,
464
- }),
558
+ }).strict(),
465
559
  z.object({
466
560
  type: z.literal('wait_for'),
467
561
  ...nodeFilterShape(),
468
562
  present: z.boolean().optional(),
469
563
  timeoutMs: timeoutMsInput,
470
- }),
564
+ }).strict(),
471
565
  z.object({
472
566
  type: z.literal('fill_fields'),
473
567
  fields: z.array(fillFieldSchema).min(1).max(80),
@@ -475,7 +569,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
475
569
  .boolean()
476
570
  .optional()
477
571
  .describe('After filling, read each text/choice field back and flag mismatches (e.g. autocomplete rejected input, format transformed). Adds a `verification` entry to the step.'),
478
- }),
572
+ }).strict(),
479
573
  z.object({
480
574
  type: z.literal('expand_section'),
481
575
  id: z.string().describe('Stable section id from geometra_page_model (e.g. fm:1.0, ls:2.1).'),
@@ -492,11 +586,21 @@ const batchActionSchema = z.discriminatedUnion('type', [
492
586
  itemOffset: z.number().int().min(0).optional(),
493
587
  maxTextPreview: z.number().int().min(0).max(20).optional(),
494
588
  includeBounds: z.boolean().optional(),
495
- }),
496
- ]);
589
+ }).strict(),
590
+ ]).superRefine((action, ctx) => {
591
+ if (action.type === 'upload_files') {
592
+ addActionContractIssue(ctx, fileUploadContractError(action));
593
+ }
594
+ else if (action.type === 'pick_listbox_option') {
595
+ addActionContractIssue(ctx, listboxPickContractError(action));
596
+ }
597
+ else if (action.type === 'select_option') {
598
+ addActionContractIssue(ctx, selectOptionContractError(action));
599
+ }
600
+ });
497
601
  export function createServer() {
498
- const server = new McpServer({ name: 'geometra', version: '1.19.26' }, { capabilities: { tools: {} } });
499
- const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect. Omit to use the most recent session.');
602
+ const server = new McpServer(SERVER_IMPLEMENTATION, { capabilities: { tools: {} } });
603
+ const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect or an auto-connected tool. Omit only when exactly one non-isolated session is active.');
500
604
  // ── connect ──────────────────────────────────────────────────
501
605
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
502
606
 
@@ -506,11 +610,16 @@ Use \`url\` (ws://…) only when a Geometra/native server or an already-running
506
610
 
507
611
  Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth: true\` (or set \`GEOMETRA_STEALTH=1\`) to opt into CloakBrowser's Chromium for authorized testing instead of stock Playwright Chromium. File upload / wheel / native \`<select>\` need the proxy path (\`pageUrl\` or ws to proxy). Set \`returnForms: true\` and/or \`returnPageModel: true\` when you want a lower-turn startup response. When connect first-response latency matters more than inlining the page model, pair \`returnPageModel: true\` with \`pageModelMode: "deferred"\` and call \`geometra_page_model\` next.
508
612
 
509
- **Parallelism:** by default, geometra MCP pools and reuses Chromium instances across sessions for speed. That pooling is safe for read-only exploration, but it shares localStorage / cookies / page state across whichever sessions land on the same proxy — which means **two parallel form-submission flows can contaminate each other** (one job's email/autocomplete state leaks into another, or worse, two agents end up driving the same browser tab). For parallel apply / form submission, pass \`isolated: true\`. Each isolated session gets its own brand-new Chromium that is destroyed on disconnect, never enters the pool, and is guaranteed independent of every other session. The cost is ~1–2s of extra startup vs the ~50ms reusable-proxy attach.`, {
613
+ **Session isolation:** browser sessions started from \`pageUrl\` (or an HTTP(S) \`url\`) are isolated by default: each gets a fresh Chromium that is destroyed on disconnect. Pass \`isolated: false\` explicitly only when you intentionally want sequential warm-browser reuse, including shared cookies, localStorage, and page state. Direct \`ws://\` connections attach to an externally managed endpoint and cannot create browser isolation.`, {
510
614
  url: z
511
615
  .string()
512
616
  .optional()
513
617
  .describe('WebSocket URL when a server is already running (e.g. ws://127.0.0.1:3200 or ws://localhost:3100). If you pass http(s) here by mistake, MCP will treat it as a page URL and start geometra-proxy.'),
618
+ authToken: z
619
+ .string()
620
+ .min(32)
621
+ .optional()
622
+ .describe('Bearer capability for an already-running authenticated geometra-proxy. Kept private and never returned in session metadata. Not needed when pageUrl starts the proxy automatically.'),
514
623
  pageUrl: z
515
624
  .string()
516
625
  .url()
@@ -541,8 +650,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
541
650
  isolated: z
542
651
  .boolean()
543
652
  .optional()
544
- .default(false)
545
- .describe('When true, bypass the reusable proxy pool and spawn a brand-new Chromium for this session that is destroyed on disconnect. Required for safe parallel form submission — without this, two parallel sessions can land on the same pooled proxy and contaminate each other. Default false (use the pool for speed).'),
653
+ .describe('For pageUrl/HTTP(S) browser connects, omission uses a fresh Chromium that is destroyed on disconnect. Pass false explicitly to opt into sequential warm-browser reuse and shared browser state. Direct ws:// endpoints are externally managed.'),
546
654
  proxy: z
547
655
  .object({
548
656
  server: z
@@ -576,6 +684,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
576
684
  onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields when returnForms=true'),
577
685
  onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields when returnForms=true'),
578
686
  includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels in returned form schemas'),
687
+ includeFormGraph: z.boolean().optional().default(false).describe('Also include FormGraph 0.1-compatible graphs in returned form schemas. Use for paperwork/application workflows that need field paths, review gates, and source anchors.'),
579
688
  includeContext: formSchemaContextInput(),
580
689
  sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
581
690
  schemaFormat: formSchemaFormatInput(),
@@ -590,12 +699,16 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
590
699
  if (!browser.ok)
591
700
  return err(browser.error);
592
701
  const target = normalized.value;
702
+ if (target.kind === 'ws' && input.isolated === true) {
703
+ return err('isolated:true is unsupported for direct ws:// endpoints because MCP does not own that browser/runtime. Use pageUrl/HTTP(S) url for an isolated browser, or omit isolated for the externally managed endpoint.');
704
+ }
593
705
  const formSchema = {
594
706
  formId: input.formId,
595
707
  maxFields: input.maxFields,
596
708
  onlyRequiredFields: input.onlyRequiredFields,
597
709
  onlyInvalidFields: input.onlyInvalidFields,
598
710
  includeOptions: input.includeOptions,
711
+ includeFormGraph: input.includeFormGraph,
599
712
  includeContext: input.includeContext,
600
713
  sinceSchemaId: input.sinceSchemaId,
601
714
  format: input.schemaFormat,
@@ -618,7 +731,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
618
731
  height: input.height,
619
732
  slowMo: input.slowMo,
620
733
  ...(browser.stealth !== undefined && { stealth: browser.stealth }),
621
- isolated: input.isolated,
734
+ isolated: input.isolated !== false,
622
735
  proxy: input.proxy,
623
736
  awaitInitialFrame: deferInlinePageModel ? false : undefined,
624
737
  eagerInitialExtract: deferInlinePageModel ? true : undefined,
@@ -650,6 +763,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
650
763
  width: input.width,
651
764
  height: input.height,
652
765
  awaitInitialFrame: deferInlinePageModel ? false : undefined,
766
+ authToken: input.authToken,
653
767
  });
654
768
  if (input.returnForms) {
655
769
  await stabilizeInlineFormSchemas(session, formSchema);
@@ -681,7 +795,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
681
795
  // ── prepare browser ──────────────────────────────────────────
682
796
  server.tool('geometra_prepare_browser', `Pre-launch and pre-navigate a reusable geometra-proxy browser for a normal web page without creating an active MCP session.
683
797
 
684
- Use this when you can prepare ahead of the user-facing task so the next \`geometra_connect\` or one-call \`geometra_run_actions\` on the same \`pageUrl\` / viewport / headless settings skips the cold browser launch.`, {
798
+ Use this when you can prepare ahead of the user-facing task so the next \`geometra_connect\` or one-call \`geometra_run_actions\` on the same \`pageUrl\` / viewport / headless settings skips the cold browser launch. The consuming call must pass \`isolated: false\` explicitly; isolated browser connects intentionally ignore the warm pool.`, {
685
799
  pageUrl: z
686
800
  .string()
687
801
  .url()
@@ -984,7 +1098,7 @@ Captures the current URL, then polls until the URL changes and a stable UI tree
984
1098
  });
985
1099
  server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
986
1100
 
987
- Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / comboboxes / radio-style questions addressed by field label + answer, \`"toggle"\` for individually labeled checkboxes or radios, and \`"file"\` for labeled uploads. When \`fieldId\` from \`geometra_form_schema\` is present, MCP can resolve the current label server-side so you do not need to duplicate \`fieldLabel\` / \`label\` for text, choice, and toggle fields.`, {
1101
+ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / comboboxes / radio-style questions addressed by field label + answer, \`"toggle"\` for individually labeled checkboxes or radios, and \`"file"\` for labeled uploads. When \`fieldId\` from \`geometra_form_schema\` is present, MCP can resolve the current label server-side so you do not need to duplicate \`fieldLabel\` / \`label\` for text, choice, toggle, or file fields.`, {
988
1102
  fields: z.array(fillFieldSchema).min(1).max(80).describe('Ordered field operations to apply. Use fieldId from geometra_form_schema to omit duplicate fieldLabel/label on schema-backed fields.'),
989
1103
  stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing field (default true)'),
990
1104
  failOnInvalid: z
@@ -1032,12 +1146,21 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
1032
1146
  fallbackFromBatch = { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 };
1033
1147
  }
1034
1148
  catch (e) {
1149
+ const ambiguity = ambiguousOutcomeDetails(e);
1150
+ if (ambiguity) {
1151
+ return err(JSON.stringify({
1152
+ completed: false,
1153
+ execution: 'batched',
1154
+ ...ambiguousOutcomePayload(ambiguity),
1155
+ }, null, detail === 'verbose' ? 2 : undefined));
1156
+ }
1035
1157
  const message = e instanceof Error ? e.message : String(e);
1036
1158
  return err(message);
1037
1159
  }
1038
1160
  }
1039
1161
  const steps = [];
1040
1162
  let stoppedAt;
1163
+ let ambiguousAt;
1041
1164
  for (let index = 0; index < resolvedFields.fields.length; index++) {
1042
1165
  const field = resolvedFields.fields[index];
1043
1166
  try {
@@ -1053,9 +1176,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
1053
1176
  : { index, kind: field.kind, ok: true, ...result.compact });
1054
1177
  }
1055
1178
  catch (e) {
1056
- const message = e instanceof Error ? e.message : String(e);
1179
+ let handledError = e;
1180
+ let message = e instanceof Error ? e.message : String(e);
1057
1181
  // Retry once for transient selection failures
1058
- if (message.includes('selection_not_confirmed') || message.includes('still invalid after fill')) {
1182
+ if (!ambiguousOutcomeDetails(e) && (message.includes('selection_not_confirmed') || message.includes('still invalid after fill'))) {
1059
1183
  try {
1060
1184
  const retryResult = await executeFillField(session, field, detail);
1061
1185
  steps.push(detail === 'verbose'
@@ -1063,12 +1187,25 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
1063
1187
  : { index, kind: field.kind, ok: true, ...retryResult.compact, retried: true });
1064
1188
  continue;
1065
1189
  }
1066
- catch { /* fall through to error handling */ }
1190
+ catch (retryError) {
1191
+ handledError = retryError;
1192
+ message = retryError instanceof Error ? retryError.message : String(retryError);
1193
+ }
1067
1194
  }
1195
+ const ambiguity = ambiguousOutcomeDetails(handledError);
1068
1196
  const suggestion = isResolvedFillFieldInput(field) ? suggestRecovery(field, message) : undefined;
1069
- steps.push({ index, kind: field.kind, ok: false, error: message, ...(suggestion ? { suggestion } : {}) });
1070
- if (stopOnError) {
1197
+ steps.push({
1198
+ index,
1199
+ kind: field.kind,
1200
+ ok: false,
1201
+ error: message,
1202
+ ...(ambiguity ? ambiguousOutcomePayload(ambiguity) : {}),
1203
+ ...(suggestion ? { suggestion } : {}),
1204
+ });
1205
+ if (ambiguity || stopOnError) {
1071
1206
  stoppedAt = index;
1207
+ if (ambiguity)
1208
+ ambiguousAt = index;
1072
1209
  break;
1073
1210
  }
1074
1211
  }
@@ -1079,12 +1216,19 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
1079
1216
  const successCount = steps.filter(step => step.ok === true).length;
1080
1217
  const errorCount = steps.length - successCount;
1081
1218
  const payload = {
1082
- completed: stoppedAt === undefined && steps.length === resolvedFields.fields.length,
1219
+ completed: errorCount === 0 && stoppedAt === undefined && steps.length === resolvedFields.fields.length,
1083
1220
  fieldCount: resolvedFields.fields.length,
1084
1221
  successCount,
1085
1222
  errorCount,
1086
1223
  ...(includeSteps ? { steps } : {}),
1087
1224
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
1225
+ ...(ambiguousAt !== undefined ? {
1226
+ ambiguous: true,
1227
+ ambiguousAt,
1228
+ resumeBlocked: true,
1229
+ resumeFromIndex: ambiguousAt,
1230
+ resumeInstruction: 'Inspect the current UI before resuming; do not skip the ambiguous field.',
1231
+ } : {}),
1088
1232
  ...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
1089
1233
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1090
1234
  };
@@ -1138,17 +1282,13 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1138
1282
  isolated: z
1139
1283
  .boolean()
1140
1284
  .optional()
1141
- .default(false)
1142
- .describe('When auto-connecting via pageUrl/url, request an isolated proxy (own brand-new Chromium, destroyed on disconnect). Required for safe parallel form submission. See geometra_connect for details. Ignored when reusing an existing sessionId — set isolated on the original geometra_connect for that case.'),
1285
+ .describe('When auto-connecting via pageUrl/HTTP(S) url, use a fresh isolated Chromium by default. Pass false explicitly for sequential warm reuse. Ignored when using an existing sessionId.'),
1143
1286
  detail: detailInput(),
1144
1287
  sessionId: sessionIdInput,
1145
1288
  }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, isolated, detail, sessionId }) => {
1146
1289
  const browser = resolveBrowserStealth({ stealth, browserMode });
1147
1290
  if (!browser.ok)
1148
1291
  return err(browser.error);
1149
- const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
1150
- ? directLabelBatchFields(valuesByLabel)
1151
- : null;
1152
1292
  const resolved = await ensureToolSession({
1153
1293
  sessionId,
1154
1294
  url,
@@ -1160,7 +1300,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1160
1300
  slowMo,
1161
1301
  stealth: browser.stealth,
1162
1302
  isolated,
1163
- awaitInitialFrame: directFields ? false : undefined,
1164
1303
  }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_fill_form.');
1165
1304
  if (!resolved.ok)
1166
1305
  return err(resolved.error);
@@ -1170,50 +1309,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1170
1309
  if (entryCount === 0) {
1171
1310
  return err('Provide at least one value in valuesById or valuesByLabel');
1172
1311
  }
1173
- if (directFields) {
1174
- try {
1175
- const startRevision = session.updateRevision;
1176
- const wait = await sendFillFields(session, directFields);
1177
- const ackResult = parseProxyFillAckResult(wait.result);
1178
- if (ackResult && ackResult.invalidCount === 0) {
1179
- recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
1180
- return ok(JSON.stringify({
1181
- ...connection,
1182
- completed: true,
1183
- execution: 'batched-direct',
1184
- finalSource: 'proxy',
1185
- requestedValueCount: entryCount,
1186
- fieldCount: directFields.length,
1187
- successCount: directFields.length,
1188
- errorCount: 0,
1189
- final: ackResult,
1190
- }, null, detail === 'verbose' ? 2 : undefined));
1191
- }
1192
- await waitForDeferredBatchUpdate(session, startRevision, wait);
1193
- const afterDirect = sessionA11y(session);
1194
- const directSignals = afterDirect ? collectSessionSignals(afterDirect) : undefined;
1195
- if (directSignals && directSignals.invalidFields.length === 0) {
1196
- recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
1197
- return ok(JSON.stringify({
1198
- ...connection,
1199
- completed: true,
1200
- execution: 'batched-direct',
1201
- finalSource: 'session',
1202
- requestedValueCount: entryCount,
1203
- fieldCount: directFields.length,
1204
- successCount: directFields.length,
1205
- errorCount: 0,
1206
- final: sessionSignalsPayload(directSignals, detail),
1207
- }, null, detail === 'verbose' ? 2 : undefined));
1208
- }
1209
- }
1210
- catch (e) {
1211
- if (!canFallbackToSequentialFill(e)) {
1212
- const message = e instanceof Error ? e.message : String(e);
1213
- return err(message);
1214
- }
1215
- }
1216
- }
1217
1312
  if (!session.tree || !session.layout) {
1218
1313
  await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
1219
1314
  }
@@ -1231,7 +1326,8 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1231
1326
  if (!resolution.ok)
1232
1327
  return err(resolution.error);
1233
1328
  const schema = resolution.schema;
1234
- const planned = planFormFill(schema, { valuesById, valuesByLabel });
1329
+ const targetFormPath = parseSectionId(schema.formId)?.path;
1330
+ const planned = planFormFill(schema, { valuesById, valuesByLabel }, schemas);
1235
1331
  if (!planned.ok)
1236
1332
  return err(planned.error);
1237
1333
  let skippedCount = 0;
@@ -1265,14 +1361,24 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1265
1361
  let fallbackFromBatch;
1266
1362
  if (!includeSteps) {
1267
1363
  let usedBatch = false;
1268
- let batchAckResult;
1364
+ let batchReadbackSnapshot;
1269
1365
  try {
1270
1366
  const startRevision = session.updateRevision;
1271
- const wait = await sendFillFields(session, planned.fields);
1367
+ const wait = await sendFillFields(session, toProxyFillFields(planned.fields));
1368
+ assertActionWaitConfirmed('Batched form fill', wait);
1272
1369
  const ackResult = parseProxyFillAckResult(wait.result);
1273
- batchAckResult = ackResult;
1274
- if (ackResult && ackResult.invalidCount === 0) {
1275
- usedBatch = true;
1370
+ const batchIncludesFile = planned.fields.some(field => field.kind === 'file');
1371
+ if (batchIncludesFile) {
1372
+ // Legacy proxies do not return a structured fillFields result.
1373
+ // Because a file chooser may already have mutated, replaying the
1374
+ // batch or falling back sequentially would be unsafe. Current
1375
+ // proxies only emit this result after attachFiles confirms the
1376
+ // exact input retained the requested files.
1377
+ if (!ackResult) {
1378
+ throw new AmbiguousActionOutcomeError('Batched file fill', wait, 'Batched file fill was acknowledged without structured proxy confirmation; the upload may already have happened. Do not retry blindly.');
1379
+ }
1380
+ await waitForDeferredBatchUpdate(session, startRevision, wait);
1381
+ const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
1276
1382
  const payload = {
1277
1383
  ...connection,
1278
1384
  completed: true,
@@ -1283,15 +1389,57 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1283
1389
  fieldCount: planned.fields.length,
1284
1390
  successCount: planned.fields.length,
1285
1391
  errorCount: 0,
1392
+ ...(verification ? { verification } : {}),
1286
1393
  final: ackResult,
1287
1394
  };
1395
+ recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, ackResult.invalidCount, planned.fields.length);
1396
+ if (failOnInvalid && ackResult.invalidCount > 0) {
1397
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1398
+ }
1288
1399
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1289
1400
  }
1401
+ if (ackResult && ackResult.invalidCount === 0) {
1402
+ usedBatch = true;
1403
+ if (!verifyFills) {
1404
+ const payload = {
1405
+ ...connection,
1406
+ completed: true,
1407
+ execution: 'batched',
1408
+ finalSource: 'proxy',
1409
+ formId: schema.formId,
1410
+ requestedValueCount: entryCount,
1411
+ fieldCount: planned.fields.length,
1412
+ successCount: planned.fields.length,
1413
+ errorCount: 0,
1414
+ final: ackResult,
1415
+ };
1416
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1417
+ }
1418
+ }
1290
1419
  await waitForDeferredBatchUpdate(session, startRevision, wait);
1291
- await waitForBatchFieldReadback(session, planned.fields);
1292
- usedBatch = true;
1420
+ batchReadbackSnapshot = await waitForBatchFieldReadback(session, planned.fields);
1421
+ if (!batchReadbackSnapshot) {
1422
+ if (!sessionA11y(session)) {
1423
+ throw new AmbiguousActionOutcomeError('Batched form fill', wait, 'Batched form fill was sent, but its read-back evidence became unavailable before confirmation. The fields may already have changed. Do not retry blindly.');
1424
+ }
1425
+ usedBatch = false;
1426
+ fallbackFromBatch = { attempted: true, used: true, reason: 'batched-invalid-readback', attempts: 2 };
1427
+ }
1428
+ else {
1429
+ usedBatch = true;
1430
+ }
1293
1431
  }
1294
1432
  catch (e) {
1433
+ const ambiguity = ambiguousOutcomeDetails(e);
1434
+ if (ambiguity) {
1435
+ return err(JSON.stringify({
1436
+ ...connection,
1437
+ completed: false,
1438
+ execution: 'batched',
1439
+ formId: schema.formId,
1440
+ ...ambiguousOutcomePayload(ambiguity),
1441
+ }, null, detail === 'verbose' ? 2 : undefined));
1442
+ }
1295
1443
  if (!canFallbackToSequentialFill(e)) {
1296
1444
  const message = e instanceof Error ? e.message : String(e);
1297
1445
  return err(message);
@@ -1299,18 +1447,25 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1299
1447
  fallbackFromBatch = { attempted: true, used: true, reason: 'batched-threw', attempts: 2 };
1300
1448
  }
1301
1449
  if (usedBatch) {
1302
- const after = sessionA11y(session);
1303
- const signals = after ? collectSessionSignals(after) : undefined;
1450
+ const after = batchReadbackSnapshot;
1451
+ const signals = after
1452
+ ? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
1453
+ : undefined;
1304
1454
  const invalidRemaining = signals?.invalidFields.length ?? 0;
1305
- if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
1455
+ if (invalidRemaining > 0) {
1306
1456
  usedBatch = false;
1307
1457
  fallbackFromBatch = { attempted: true, used: true, reason: 'batched-invalid-readback', attempts: 2 };
1308
1458
  }
1309
1459
  }
1310
1460
  if (usedBatch) {
1311
- const after = sessionA11y(session);
1312
- const signals = after ? collectSessionSignals(after) : undefined;
1461
+ const after = batchReadbackSnapshot;
1462
+ const signals = after
1463
+ ? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
1464
+ : undefined;
1313
1465
  const invalidRemaining = signals?.invalidFields.length ?? 0;
1466
+ const verification = verifyFills
1467
+ ? verifyFormFills(session, planned.planned, batchReadbackSnapshot)
1468
+ : undefined;
1314
1469
  const payload = {
1315
1470
  ...connection,
1316
1471
  completed: true,
@@ -1324,6 +1479,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1324
1479
  minConfidence: planned.planned.length > 0
1325
1480
  ? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
1326
1481
  : undefined,
1482
+ ...(verification ? { verification } : {}),
1327
1483
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1328
1484
  };
1329
1485
  recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
@@ -1335,6 +1491,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1335
1491
  }
1336
1492
  const steps = [];
1337
1493
  let stoppedAt;
1494
+ let ambiguousAt;
1338
1495
  const startIndex = resumeFromIndex ?? 0;
1339
1496
  for (let index = startIndex; index < planned.fields.length; index++) {
1340
1497
  const field = planned.fields[index];
@@ -1349,23 +1506,36 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1349
1506
  }
1350
1507
  catch (e) {
1351
1508
  const message = e instanceof Error ? e.message : String(e);
1509
+ const ambiguity = ambiguousOutcomeDetails(e);
1352
1510
  const suggestion = suggestRecovery(field, message);
1353
- steps.push({ index, kind: field.kind, ok: false, ...(confidence !== undefined ? { confidence, matchMethod } : {}), error: message, ...(suggestion ? { suggestion } : {}) });
1354
- if (stopOnError) {
1511
+ steps.push({
1512
+ index,
1513
+ kind: field.kind,
1514
+ ok: false,
1515
+ ...(confidence !== undefined ? { confidence, matchMethod } : {}),
1516
+ error: message,
1517
+ ...(ambiguity ? ambiguousOutcomePayload(ambiguity) : {}),
1518
+ ...(suggestion ? { suggestion } : {}),
1519
+ });
1520
+ if (ambiguity || stopOnError) {
1355
1521
  stoppedAt = index;
1522
+ if (ambiguity)
1523
+ ambiguousAt = index;
1356
1524
  break;
1357
1525
  }
1358
1526
  }
1359
1527
  }
1360
1528
  const after = sessionA11y(session);
1361
- const signals = after ? collectSessionSignals(after) : undefined;
1529
+ const signals = after
1530
+ ? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
1531
+ : undefined;
1362
1532
  const invalidRemaining = signals?.invalidFields.length ?? 0;
1363
1533
  const successCount = steps.filter(step => step.ok === true).length;
1364
1534
  const errorCount = steps.length - successCount;
1365
1535
  const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
1366
1536
  const payload = {
1367
1537
  ...connection,
1368
- completed: stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
1538
+ completed: errorCount === 0 && stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
1369
1539
  execution: 'sequential',
1370
1540
  formId: schema.formId,
1371
1541
  requestedValueCount: entryCount,
@@ -1378,7 +1548,16 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1378
1548
  : undefined,
1379
1549
  ...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
1380
1550
  ...(includeSteps ? { steps } : {}),
1381
- ...(stoppedAt !== undefined ? { stoppedAt, resumeFromIndex: stoppedAt + 1 } : {}),
1551
+ ...(stoppedAt !== undefined ? {
1552
+ stoppedAt,
1553
+ resumeFromIndex: ambiguousAt ?? (stoppedAt + 1),
1554
+ } : {}),
1555
+ ...(ambiguousAt !== undefined ? {
1556
+ ambiguous: true,
1557
+ ambiguousAt,
1558
+ resumeBlocked: true,
1559
+ resumeInstruction: 'Inspect the current UI before resuming; do not skip the ambiguous field.',
1560
+ } : {}),
1382
1561
  ...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
1383
1562
  ...(verification ? { verification } : {}),
1384
1563
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
@@ -1410,7 +1589,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1410
1589
 
1411
1590
  Pass \`valuesById\` or \`valuesByLabel\` to populate fields, \`submit\` to target the submit button (default: semantic \`{ role: 'button', name: 'Submit' }\`), and \`waitFor\` to block on the post-submit state (success banner, navigation, submit button gone, etc.). Navigation is detected automatically and surfaced as \`navigated: true\` with \`afterUrl\`.
1412
1591
 
1413
- Pass \`pageUrl\`/\`url\` to auto-connect in the same call use \`isolated: true\` for safe parallel submissions.`, {
1592
+ Pass \`pageUrl\`/\`url\` to auto-connect in the same call. These browser connects are isolated by default; pass \`isolated: false\` only for intentional sequential warm reuse.`, {
1414
1593
  url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before submitting.'),
1415
1594
  pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before submitting. Prefer this over url for browser pages.'),
1416
1595
  port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
@@ -1420,17 +1599,17 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1420
1599
  slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
1421
1600
  stealth: stealthInput(),
1422
1601
  browserMode: browserModeInput(),
1423
- isolated: z.boolean().optional().default(false).describe('When auto-connecting via pageUrl/url, request an isolated proxy. Required for safe parallel form submission.'),
1602
+ isolated: z.boolean().optional().describe('When auto-connecting via pageUrl/HTTP(S) url, use a fresh isolated Chromium by default. Pass false explicitly for sequential warm reuse.'),
1424
1603
  formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
1425
1604
  valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
1426
1605
  valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
1427
- submit: z.object(nodeFilterShape()).optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
1606
+ submit: z.object(nodeFilterShape()).strict().optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
1428
1607
  submitIndex: z.number().int().min(0).optional().default(0).describe('Which matching submit target to click after sorting top-to-bottom (default 0)'),
1429
1608
  submitTimeoutMs: z.number().int().min(50).max(60_000).optional().default(15_000).describe('Action wait timeout for the submit click (default 15000ms). Increase for slow backends.'),
1430
- waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the submit click (success banner, navigation, submit gone, etc.)'),
1609
+ waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the submit click (success banner, submit gone, etc.)'),
1431
1610
  skipFill: z.boolean().optional().default(false).describe('Skip the fill phase and go straight to submit+wait. Use when values have already been filled by a previous call.'),
1432
1611
  softTimeoutMs: softTimeoutMsInput(),
1433
- failOnInvalid: z.boolean().optional().default(false).describe('Return an error if invalid fields remain after the submit wait resolves.'),
1612
+ failOnInvalid: z.boolean().optional().default(false).describe('Control whether a validation_failed outcome is returned as an MCP error. Outcome facts are always reported.'),
1434
1613
  detail: detailInput(),
1435
1614
  sessionId: sessionIdInput,
1436
1615
  }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, softTimeoutMs, failOnInvalid, detail, sessionId }) => {
@@ -1445,28 +1624,37 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1445
1624
  return err(resolved.error);
1446
1625
  const session = resolved.session;
1447
1626
  const connection = autoConnectionPayload(resolved);
1627
+ let targetFormId = formId;
1628
+ let targetFormIdentity;
1448
1629
  let fillSummary;
1449
1630
  let fillFallback;
1450
- const pausedPayload = (phase, resumeHint, extra) => ({
1451
- ...connection,
1452
- completed: false,
1453
- paused: true,
1454
- phase,
1455
- pauseReason: 'soft-timeout',
1456
- softTimeoutMs: effectiveSoftTimeoutMs,
1457
- elapsedMs: Number((performance.now() - toolStartedAt).toFixed(1)),
1458
- ...(resumeHint ? { resumeHint } : {}),
1459
- ...(fillSummary ? { fill: fillSummary } : {}),
1460
- ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1461
- ...(extra ?? {}),
1462
- });
1631
+ const pausedPayload = (phase, resumeHint, extra) => {
1632
+ const resumesSubmitForm = resumeHint?.tool === 'geometra_submit_form';
1633
+ const scopedResumeHint = {
1634
+ ...(resumeHint ?? {}),
1635
+ ...(resumesSubmitForm && targetFormId ? { formId: targetFormId } : {}),
1636
+ };
1637
+ return {
1638
+ ...connection,
1639
+ completed: false,
1640
+ outcome: 'unconfirmed',
1641
+ paused: true,
1642
+ phase,
1643
+ pauseReason: 'soft-timeout',
1644
+ softTimeoutMs: effectiveSoftTimeoutMs,
1645
+ elapsedMs: Number((performance.now() - toolStartedAt).toFixed(1)),
1646
+ ...(Object.keys(scopedResumeHint).length > 0 ? { resumeHint: scopedResumeHint } : {}),
1647
+ ...(fillSummary ? { fill: fillSummary } : {}),
1648
+ ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1649
+ ...(extra ?? {}),
1650
+ };
1651
+ };
1463
1652
  if (!session.tree || !session.layout) {
1464
1653
  await waitForUiCondition(session, () => Boolean(session.tree && session.layout), capTimeoutMs(2_000, timeoutCapFromDeadline(deadlineAt), 2_000));
1465
1654
  }
1466
1655
  const entryA11y = sessionA11y(session);
1467
1656
  if (!entryA11y)
1468
1657
  return err('No UI tree available for form submission');
1469
- const entryUrl = entryA11y.meta?.pageUrl;
1470
1658
  if (!hasSoftBudget(deadlineAt)) {
1471
1659
  return ok(JSON.stringify(pausedPayload('before-fill', { retrySameCall: true }), null, detail === 'verbose' ? 2 : undefined));
1472
1660
  }
@@ -1482,13 +1670,47 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1482
1670
  if (!resolution.ok)
1483
1671
  return err(resolution.error);
1484
1672
  const schema = resolution.schema;
1485
- const planned = planFormFill(schema, { valuesById, valuesByLabel });
1673
+ targetFormId = schema.formId;
1674
+ const initialFormPath = parseSectionId(schema.formId);
1675
+ const initialFormNode = initialFormPath?.kind === 'form'
1676
+ ? formScopeNodeAtPath(entryA11y, initialFormPath.path)
1677
+ : undefined;
1678
+ if (!initialFormNode) {
1679
+ return err(`Target form ${schema.formId} does not resolve to a maintainable form scope before fill`);
1680
+ }
1681
+ const capturedIdentity = captureFormScopeIdentity(initialFormNode, schema);
1682
+ if (!capturedIdentity.ok)
1683
+ return err(capturedIdentity.error);
1684
+ targetFormIdentity = capturedIdentity.identity;
1685
+ const planned = planFormFill(schema, { valuesById, valuesByLabel }, schemas);
1486
1686
  if (!planned.ok)
1487
1687
  return err(planned.error);
1488
1688
  let usedBatch = true;
1489
1689
  try {
1490
1690
  const startRevision = session.updateRevision;
1491
- const wait = await sendFillFields(session, planned.fields.map(field => capFillFieldTimeout(field, timeoutCapFromDeadline(deadlineAt))), capTimeoutMs(undefined, timeoutCapFromDeadline(deadlineAt), HOST_SAFE_TOOL_TIMEOUT_MS));
1691
+ const wait = await sendFillFields(session, toProxyFillFields(planned.fields), capTimeoutMs(undefined, timeoutCapFromDeadline(deadlineAt), HOST_SAFE_TOOL_TIMEOUT_MS));
1692
+ if (wait.status === 'timed_out') {
1693
+ fillSummary = {
1694
+ formId: schema.formId,
1695
+ execution: 'batched',
1696
+ fieldCount: planned.fields.length,
1697
+ ...waitStatusPayload(wait),
1698
+ ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1699
+ };
1700
+ const guidance = ambiguousActionGuidance(wait);
1701
+ return ok(JSON.stringify(pausedPayload('fill', {
1702
+ tool: 'geometra_submit_form',
1703
+ retrySameCall: false,
1704
+ inspectCurrentUiFirst: true,
1705
+ skipFill: false,
1706
+ actionId: wait.actionId,
1707
+ requestId: wait.requestId,
1708
+ }, {
1709
+ pauseReason: 'action-outcome-ambiguous',
1710
+ retrySafety: 'inspect-first',
1711
+ guidance,
1712
+ }), null, detail === 'verbose' ? 2 : undefined));
1713
+ }
1492
1714
  const ack = parseProxyFillAckResult(wait.result);
1493
1715
  await waitForDeferredBatchUpdate(session, startRevision, wait);
1494
1716
  fillSummary = {
@@ -1497,19 +1719,23 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1497
1719
  fieldCount: planned.fields.length,
1498
1720
  ...waitStatusPayload(wait),
1499
1721
  ...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
1722
+ ...(ack?.invalidFields ? { invalidFields: ack.invalidFields } : {}),
1500
1723
  ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1501
1724
  };
1502
- if (wait.status === 'timed_out') {
1503
- return ok(JSON.stringify(pausedPayload('fill', {
1504
- tool: 'geometra_submit_form',
1505
- skipFill: true,
1506
- submit: submit ?? { role: 'button', name: 'Submit' },
1507
- submitIndex,
1508
- ...(waitFor ? { waitFor } : {}),
1509
- }), null, detail === 'verbose' ? 2 : undefined));
1510
- }
1511
1725
  }
1512
1726
  catch (e) {
1727
+ const ambiguity = ambiguousOutcomeDetails(e);
1728
+ if (ambiguity) {
1729
+ return err(JSON.stringify({
1730
+ ...connection,
1731
+ completed: false,
1732
+ paused: true,
1733
+ phase: 'fill',
1734
+ pauseReason: 'action-outcome-ambiguous',
1735
+ ...ambiguousOutcomePayload(ambiguity),
1736
+ submit: { attempted: false, reason: 'Fill outcome must be inspected before submission.' },
1737
+ }, null, detail === 'verbose' ? 2 : undefined));
1738
+ }
1513
1739
  if (!canFallbackToSequentialFill(e)) {
1514
1740
  const message = e instanceof Error ? e.message : String(e);
1515
1741
  return err(`Failed to fill form before submit: ${message}`);
@@ -1520,6 +1746,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1520
1746
  if (!usedBatch) {
1521
1747
  let successCount = 0;
1522
1748
  let firstErr;
1749
+ let firstAmbiguity;
1523
1750
  for (const field of planned.fields) {
1524
1751
  if (!hasSoftBudget(deadlineAt)) {
1525
1752
  fillSummary = {
@@ -1531,10 +1758,8 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1531
1758
  };
1532
1759
  return ok(JSON.stringify(pausedPayload('fill', {
1533
1760
  tool: 'geometra_submit_form',
1534
- skipFill: true,
1535
- submit: submit ?? { role: 'button', name: 'Submit' },
1536
- submitIndex,
1537
- ...(waitFor ? { waitFor } : {}),
1761
+ retrySameCall: true,
1762
+ skipFill: false,
1538
1763
  }), null, detail === 'verbose' ? 2 : undefined));
1539
1764
  }
1540
1765
  try {
@@ -1543,19 +1768,42 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1543
1768
  }
1544
1769
  catch (e) {
1545
1770
  firstErr = e instanceof Error ? e.message : String(e);
1771
+ firstAmbiguity = ambiguousOutcomeDetails(e);
1546
1772
  break;
1547
1773
  }
1548
1774
  }
1549
- if (firstErr !== undefined && successCount === 0) {
1550
- return err(`Failed to fill form before submit (sequential fallback): ${firstErr}`);
1551
- }
1552
1775
  fillSummary = {
1553
1776
  formId: schema.formId,
1554
1777
  execution: 'sequential',
1555
1778
  fieldCount: planned.fields.length,
1556
1779
  successCount,
1780
+ ...(firstErr !== undefined ? { error: firstErr } : {}),
1557
1781
  ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1558
1782
  };
1783
+ if (firstErr !== undefined) {
1784
+ if (firstAmbiguity) {
1785
+ return err(JSON.stringify({
1786
+ ...connection,
1787
+ completed: false,
1788
+ paused: true,
1789
+ phase: 'fill',
1790
+ pauseReason: 'action-outcome-ambiguous',
1791
+ fill: fillSummary,
1792
+ ...ambiguousOutcomePayload(firstAmbiguity),
1793
+ submit: { attempted: false, reason: 'Fill outcome must be inspected before submission.' },
1794
+ }, null, detail === 'verbose' ? 2 : undefined));
1795
+ }
1796
+ return err(JSON.stringify({
1797
+ ...connection,
1798
+ completed: false,
1799
+ execution: 'unconfirmed',
1800
+ outcome: 'unconfirmed',
1801
+ phase: 'fill',
1802
+ fill: fillSummary,
1803
+ ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1804
+ submit: { attempted: false, reason: 'Fill phase did not complete successfully.' },
1805
+ }, null, detail === 'verbose' ? 2 : undefined));
1806
+ }
1559
1807
  }
1560
1808
  }
1561
1809
  const submitResumeHint = {
@@ -1569,110 +1817,217 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1569
1817
  return ok(JSON.stringify(pausedPayload('submit', submitResumeHint), null, detail === 'verbose' ? 2 : undefined));
1570
1818
  }
1571
1819
  const submitFilter = submit ?? { role: 'button', name: 'Submit' };
1820
+ const submitTargetFailurePayload = (error, fallback) => ({
1821
+ ...connection,
1822
+ completed: false,
1823
+ outcome: 'unconfirmed',
1824
+ ...(fillSummary ? { fill: fillSummary } : {}),
1825
+ ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1826
+ submit: { ok: false, error: `Submit target not found: ${error}` },
1827
+ ...(fallback ? { submit_fallback: fallback } : {}),
1828
+ });
1829
+ let submitFormNode;
1830
+ let submitFormPath;
1831
+ let formScope;
1832
+ if (targetFormId) {
1833
+ const currentA11y = sessionA11y(session);
1834
+ const parsedForm = parseSectionId(targetFormId);
1835
+ const currentSchema = currentA11y
1836
+ ? getSessionFormSchemas(session, { includeOptions: false, includeContext: 'auto' })
1837
+ .find(schema => schema.formId === targetFormId)
1838
+ : undefined;
1839
+ const currentForm = currentA11y && parsedForm?.kind === 'form'
1840
+ ? formScopeNodeAtPath(currentA11y, parsedForm.path)
1841
+ : undefined;
1842
+ if (!currentA11y || parsedForm?.kind !== 'form' || !currentSchema || !currentForm) {
1843
+ return err(JSON.stringify(submitTargetFailurePayload(`Target form ${targetFormId} has an invalid or stale form path`), null, detail === 'verbose' ? 2 : undefined));
1844
+ }
1845
+ if (!targetFormIdentity) {
1846
+ const capturedIdentity = captureFormScopeIdentity(currentForm, currentSchema);
1847
+ if (!capturedIdentity.ok) {
1848
+ return err(JSON.stringify(submitTargetFailurePayload(capturedIdentity.error), null, detail === 'verbose' ? 2 : undefined));
1849
+ }
1850
+ targetFormIdentity = capturedIdentity.identity;
1851
+ }
1852
+ submitFormPath = [...parsedForm.path];
1853
+ formScope = { formId: targetFormId, path: submitFormPath, identity: targetFormIdentity };
1854
+ const scoped = currentFormScope(session, currentA11y, formScope);
1855
+ if (!scoped.ok) {
1856
+ return err(JSON.stringify(submitTargetFailurePayload(scoped.error), null, detail === 'verbose' ? 2 : undefined));
1857
+ }
1858
+ submitFormNode = scoped.node;
1859
+ }
1572
1860
  const resolvedClick = await resolveClickLocationWithFallback(session, {
1573
1861
  filter: submitFilter,
1574
1862
  index: submitIndex,
1575
1863
  fullyVisible: true,
1864
+ formScope,
1576
1865
  revealTimeoutMs: capTimeoutMs(2_500, timeoutCapFromDeadline(deadlineAt), 2_500),
1577
1866
  });
1578
1867
  if (!resolvedClick.ok) {
1868
+ const payload = submitTargetFailurePayload(resolvedClick.error, resolvedClick.fallback);
1869
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1870
+ }
1871
+ const preSubmitA11y = sessionA11y(session);
1872
+ let submitPoint = { x: resolvedClick.value.x, y: resolvedClick.value.y };
1873
+ if (formScope) {
1874
+ if (!preSubmitA11y) {
1875
+ return err(JSON.stringify(submitTargetFailurePayload(`Target form ${formScope.formId} cannot be revalidated because the current UI tree is unavailable`), null, detail === 'verbose' ? 2 : undefined));
1876
+ }
1877
+ const scoped = currentFormScope(session, preSubmitA11y, formScope);
1878
+ const targetPath = resolvedClick.value.target?.path;
1879
+ const currentTarget = targetPath ? findNodeByPath(preSubmitA11y, targetPath) : undefined;
1880
+ const targetContext = currentTarget ? nodeContextForNode(preSubmitA11y, currentTarget) : undefined;
1881
+ if (!scoped.ok ||
1882
+ !currentTarget ||
1883
+ !targetPath ||
1884
+ !pathIsStrictDescendant(targetPath, formScope.path) ||
1885
+ !nodeMatchesFilter(currentTarget, submitFilter, targetContext)) {
1886
+ return err(JSON.stringify(submitTargetFailurePayload(`Target form ${formScope.formId} or its selected submit control became stale before click`), null, detail === 'verbose' ? 2 : undefined));
1887
+ }
1888
+ const currentCenter = currentVisibleClickCenter(preSubmitA11y, currentTarget);
1889
+ if (!currentCenter) {
1890
+ return err(JSON.stringify(submitTargetFailurePayload(`Target form ${formScope.formId} submit control is no longer visible at a usable click point`), null, detail === 'verbose' ? 2 : undefined));
1891
+ }
1892
+ submitPoint = currentCenter;
1893
+ submitFormNode = scoped.node;
1894
+ }
1895
+ else if (preSubmitA11y) {
1896
+ submitFormNode = resolveSubmitFormNode(preSubmitA11y, undefined, resolvedClick.value.target?.path);
1897
+ submitFormPath = submitFormNode?.path;
1898
+ }
1899
+ const preSubmitSignals = preSubmitA11y
1900
+ ? scopeSessionSignalsToForm(preSubmitA11y, submitFormNode)
1901
+ : undefined;
1902
+ if (preSubmitSignals && preSubmitSignals.invalidFields.length > 0) {
1579
1903
  const payload = {
1580
1904
  ...connection,
1581
1905
  completed: false,
1906
+ execution: 'not_started',
1907
+ outcome: 'validation_failed',
1582
1908
  ...(fillSummary ? { fill: fillSummary } : {}),
1583
1909
  ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1584
- submit: { ok: false, error: `Submit target not found: ${resolvedClick.error}` },
1585
- ...(resolvedClick.fallback ? { submit_fallback: resolvedClick.fallback } : {}),
1910
+ submit: {
1911
+ attempted: false,
1912
+ reason: 'Invalid fields remain in the target form before submit; the submit control was not clicked.',
1913
+ },
1914
+ final: sessionSignalsPayload(preSubmitSignals, detail),
1586
1915
  };
1587
- return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1916
+ const serialized = JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined);
1917
+ return failOnInvalid ? err(serialized) : ok(serialized);
1588
1918
  }
1589
1919
  if (!hasSoftBudget(deadlineAt)) {
1590
1920
  return ok(JSON.stringify(pausedPayload('submit', submitResumeHint), null, detail === 'verbose' ? 2 : undefined));
1591
1921
  }
1592
1922
  const beforeSubmit = sessionA11y(session);
1593
- const clickWait = await sendClick(session, resolvedClick.value.x, resolvedClick.value.y, capTimeoutMs(submitTimeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000));
1923
+ const beforeClickUrl = beforeSubmit?.meta?.pageUrl;
1924
+ const waitFilter = {
1925
+ id: waitFor?.id,
1926
+ role: waitFor?.role,
1927
+ name: waitFor?.name,
1928
+ text: waitFor?.text,
1929
+ contextText: waitFor?.contextText,
1930
+ promptText: waitFor?.promptText,
1931
+ sectionText: waitFor?.sectionText,
1932
+ itemText: waitFor?.itemText,
1933
+ value: waitFor?.value,
1934
+ checked: waitFor?.checked,
1935
+ disabled: waitFor?.disabled,
1936
+ focused: waitFor?.focused,
1937
+ selected: waitFor?.selected,
1938
+ expanded: waitFor?.expanded,
1939
+ invalid: waitFor?.invalid,
1940
+ required: waitFor?.required,
1941
+ busy: waitFor?.busy,
1942
+ };
1943
+ const beforeWaitEvidence = waitFor && beforeSubmit
1944
+ ? captureWaitEvidence(beforeSubmit, waitFilter)
1945
+ : undefined;
1946
+ const clickWait = await sendClick(session, submitPoint.x, submitPoint.y, capTimeoutMs(submitTimeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000));
1947
+ if (clickWait.status === 'timed_out') {
1948
+ const guidance = ambiguousActionGuidance(clickWait);
1949
+ return ok(JSON.stringify(pausedPayload('submit', {
1950
+ ...submitResumeHint,
1951
+ retrySameCall: false,
1952
+ inspectCurrentUiFirst: true,
1953
+ actionId: clickWait.actionId,
1954
+ requestId: clickWait.requestId,
1955
+ }, {
1956
+ pauseReason: 'action-outcome-ambiguous',
1957
+ retrySafety: 'inspect-first',
1958
+ guidance,
1959
+ submit: {
1960
+ at: submitPoint,
1961
+ ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
1962
+ ...waitStatusPayload(clickWait),
1963
+ },
1964
+ }), null, detail === 'verbose' ? 2 : undefined));
1965
+ }
1594
1966
  let waitResult;
1967
+ let waitError;
1595
1968
  if (waitFor) {
1596
1969
  if (!hasSoftBudget(deadlineAt)) {
1597
1970
  return ok(JSON.stringify(pausedPayload('wait_for', {
1598
1971
  tool: 'geometra_wait_for',
1599
- filter: compactFilterPayload({
1600
- id: waitFor.id,
1601
- role: waitFor.role,
1602
- name: waitFor.name,
1603
- text: waitFor.text,
1604
- contextText: waitFor.contextText,
1605
- promptText: waitFor.promptText,
1606
- sectionText: waitFor.sectionText,
1607
- itemText: waitFor.itemText,
1608
- value: waitFor.value,
1609
- checked: waitFor.checked,
1610
- disabled: waitFor.disabled,
1611
- focused: waitFor.focused,
1612
- selected: waitFor.selected,
1613
- expanded: waitFor.expanded,
1614
- invalid: waitFor.invalid,
1615
- required: waitFor.required,
1616
- busy: waitFor.busy,
1617
- }),
1972
+ ...compactFilterPayload(waitFilter),
1618
1973
  present: waitFor.present ?? true,
1974
+ ...(waitFor.timeoutMs !== undefined ? { timeoutMs: waitFor.timeoutMs } : {}),
1619
1975
  }, {
1620
1976
  submit: {
1621
- at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
1977
+ at: submitPoint,
1622
1978
  ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
1623
1979
  ...waitStatusPayload(clickWait),
1624
1980
  },
1625
1981
  }), null, detail === 'verbose' ? 2 : undefined));
1626
1982
  }
1627
1983
  const postWait = await waitForSemanticCondition(session, {
1628
- filter: {
1629
- id: waitFor.id,
1630
- role: waitFor.role,
1631
- name: waitFor.name,
1632
- text: waitFor.text,
1633
- contextText: waitFor.contextText,
1634
- promptText: waitFor.promptText,
1635
- sectionText: waitFor.sectionText,
1636
- itemText: waitFor.itemText,
1637
- value: waitFor.value,
1638
- checked: waitFor.checked,
1639
- disabled: waitFor.disabled,
1640
- focused: waitFor.focused,
1641
- selected: waitFor.selected,
1642
- expanded: waitFor.expanded,
1643
- invalid: waitFor.invalid,
1644
- required: waitFor.required,
1645
- busy: waitFor.busy,
1646
- },
1984
+ filter: waitFilter,
1647
1985
  present: waitFor.present ?? true,
1648
1986
  timeoutMs: capTimeoutMs(waitFor.timeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000),
1649
1987
  });
1650
1988
  if (!postWait.ok) {
1651
- const preErrFallbacks = [];
1652
- if (fillFallback)
1653
- preErrFallbacks.push({ phase: 'fill', ...fillFallback });
1654
- if (resolvedClick.fallback)
1655
- preErrFallbacks.push({ phase: 'submit', ...resolvedClick.fallback });
1656
- const payload = {
1657
- ...connection,
1658
- completed: false,
1659
- ...(fillSummary ? { fill: fillSummary } : {}),
1660
- submit: {
1661
- at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
1662
- ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
1663
- ...waitStatusPayload(clickWait),
1664
- },
1665
- ...(preErrFallbacks.length > 0 ? { fallbacks: preErrFallbacks } : {}),
1666
- waitFor: { ok: false, error: postWait.error },
1667
- };
1668
- return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1989
+ waitError = postWait.error;
1990
+ }
1991
+ else {
1992
+ waitResult = postWait.value;
1669
1993
  }
1670
- waitResult = postWait.value;
1671
1994
  }
1672
1995
  const after = sessionA11y(session);
1673
- const signals = after ? collectSessionSignals(after) : undefined;
1674
- const afterUrl = after?.meta?.pageUrl;
1675
- const navigated = Boolean(afterUrl && entryUrl && afterUrl !== entryUrl);
1996
+ const signals = after
1997
+ ? scopeSessionSignalsToForm(after, submitFormPath ? formScopeNodeAtPath(after, submitFormPath) : undefined, submitFormPath !== undefined)
1998
+ : undefined;
1999
+ const clickResult = clickWait.result && typeof clickWait.result === 'object'
2000
+ ? clickWait.result
2001
+ : undefined;
2002
+ const clickAfterUrl = typeof clickResult?.pageUrl === 'string' ? clickResult.pageUrl : undefined;
2003
+ const clickBeforeUrl = typeof clickResult?.urlBefore === 'string' ? clickResult.urlBefore : beforeClickUrl;
2004
+ const observedAfterUrl = after?.meta?.pageUrl;
2005
+ const navigatedByClick = Boolean(clickResult?.navigated === true &&
2006
+ clickAfterUrl &&
2007
+ clickBeforeUrl &&
2008
+ clickAfterUrl !== clickBeforeUrl);
2009
+ const navigatedByTree = Boolean(observedAfterUrl && beforeClickUrl && observedAfterUrl !== beforeClickUrl);
2010
+ const navigated = navigatedByClick || navigatedByTree;
2011
+ const afterUrl = navigatedByClick && clickAfterUrl
2012
+ ? clickAfterUrl
2013
+ : observedAfterUrl ?? clickAfterUrl;
2014
+ const waitEvidenceFresh = waitResult
2015
+ ? waitConditionHasFreshEvidence(beforeWaitEvidence, after, waitResult)
2016
+ : false;
2017
+ const execution = 'completed';
2018
+ const invalidCount = signals?.invalidFields.length ?? 0;
2019
+ const outcome = invalidCount > 0
2020
+ ? 'validation_failed'
2021
+ : waitError
2022
+ ? 'unconfirmed'
2023
+ : waitFor
2024
+ ? waitEvidenceFresh
2025
+ ? 'submitted'
2026
+ : 'unconfirmed'
2027
+ : navigated
2028
+ ? 'submitted'
2029
+ : 'unconfirmed';
2030
+ const completed = outcome === 'submitted';
1676
2031
  // Aggregate all fallback usage into a single top-level `fallbacks[]`
1677
2032
  // array so this tool matches the shape emitted by `geometra_run_actions`
1678
2033
  // and can be aggregated the same way by operators.
@@ -1685,16 +2040,22 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1685
2040
  }
1686
2041
  const payload = {
1687
2042
  ...connection,
1688
- completed: true,
2043
+ completed,
2044
+ execution,
2045
+ outcome,
1689
2046
  ...(fillSummary ? { fill: fillSummary } : {}),
1690
2047
  submit: {
1691
- at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
2048
+ at: submitPoint,
1692
2049
  ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target), revealSteps: resolvedClick.value.revealAttempts ?? 0 } : {}),
1693
2050
  ...waitStatusPayload(clickWait),
1694
2051
  },
1695
- ...(waitResult ? { waitFor: waitConditionCompact(waitResult) } : {}),
2052
+ ...(waitResult
2053
+ ? { waitFor: { ...waitConditionCompact(waitResult), fresh: waitEvidenceFresh } }
2054
+ : waitError
2055
+ ? { waitFor: { ok: false, error: waitError } }
2056
+ : {}),
1696
2057
  ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
1697
- ...(navigated ? { navigated: true, afterUrl } : {}),
2058
+ ...(navigated ? { navigated: true, ...(afterUrl ? { afterUrl } : {}) } : {}),
1698
2059
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1699
2060
  };
1700
2061
  // Pull in page model hints on navigation to mirror fill_form behavior.
@@ -1705,15 +2066,12 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1705
2066
  if (model.verification)
1706
2067
  payload.verification = model.verification;
1707
2068
  }
1708
- if (failOnInvalid && signals && signals.invalidFields.length > 0) {
1709
- return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1710
- }
1711
- // Swallow the unused `beforeSubmit` binding; it anchors that the a11y tree was
1712
- // captured pre-click and keeps the pattern consistent with other tools that
1713
- // diff before/after for summaries (we rely on the waitFor / final signals
1714
- // for the actual comparison here).
1715
- void beforeSubmit;
1716
- return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
2069
+ const serialized = JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined);
2070
+ if (waitError)
2071
+ return err(serialized);
2072
+ if (failOnInvalid && outcome === 'validation_failed')
2073
+ return err(serialized);
2074
+ return ok(serialized);
1717
2075
  });
1718
2076
  server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
1719
2077
 
@@ -1730,8 +2088,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1730
2088
  isolated: z
1731
2089
  .boolean()
1732
2090
  .optional()
1733
- .default(false)
1734
- .describe('When auto-connecting via pageUrl/url, request an isolated proxy. See geometra_connect for details.'),
2091
+ .describe('When auto-connecting via pageUrl/HTTP(S) url, use a fresh isolated Chromium by default. Pass false explicitly for sequential warm reuse.'),
1735
2092
  actions: z.array(batchActionSchema).min(1).max(80).describe('Ordered high-level action steps to run sequentially'),
1736
2093
  resumeFromIndex: z
1737
2094
  .number()
@@ -1781,6 +2138,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1781
2138
  const steps = [];
1782
2139
  let stoppedAt;
1783
2140
  let pausedAt;
2141
+ let ambiguousAt;
1784
2142
  const batchStartedAt = performance.now();
1785
2143
  // Collect transparent-fallback signals from each step so run_actions
1786
2144
  // surfaces them at top level regardless of `includeSteps` — otherwise
@@ -1853,6 +2211,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1853
2211
  }
1854
2212
  catch (e) {
1855
2213
  const message = e instanceof Error ? e.message : String(e);
2214
+ const ambiguity = ambiguousOutcomeDetails(e);
1856
2215
  const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
1857
2216
  const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
1858
2217
  steps.push({
@@ -1863,7 +2222,15 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1863
2222
  cumulativeMs,
1864
2223
  ...(uiTreeWaitMs > 0 ? { uiTreeWaitMs: Number(uiTreeWaitMs.toFixed(1)) } : {}),
1865
2224
  error: message,
2225
+ ...(ambiguity ? ambiguousOutcomePayload(ambiguity) : {}),
1866
2226
  });
2227
+ if (ambiguity) {
2228
+ // Continuing would silently skip a mutation that may still land.
2229
+ // Stop regardless of stopOnError and pin any resume to this step.
2230
+ ambiguousAt = index;
2231
+ stoppedAt = index;
2232
+ break;
2233
+ }
1867
2234
  if (stopOnError) {
1868
2235
  stoppedAt = index;
1869
2236
  break;
@@ -1874,9 +2241,18 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1874
2241
  const successCount = steps.filter(step => step.ok === true).length;
1875
2242
  const errorCount = steps.length - successCount;
1876
2243
  const elapsedMs = Number((performance.now() - toolStartedAt).toFixed(1));
1877
- const completed = stoppedAt === undefined && pausedAt === undefined && startIndex + steps.length >= actions.length;
2244
+ const completed = errorCount === 0 && stoppedAt === undefined && pausedAt === undefined && startIndex + steps.length >= actions.length;
1878
2245
  const resumePayload = {
1879
2246
  ...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
2247
+ ...(ambiguousAt !== undefined
2248
+ ? {
2249
+ ambiguous: true,
2250
+ ambiguousAt,
2251
+ resumeBlocked: true,
2252
+ resumeFromIndex: ambiguousAt,
2253
+ resumeInstruction: 'Do not skip this ambiguous step. Inspect the current UI; an identical retry will reuse its action identity when deduplication is supported.',
2254
+ }
2255
+ : {}),
1880
2256
  ...(pausedAt !== undefined
1881
2257
  ? {
1882
2258
  paused: true,
@@ -1892,6 +2268,8 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1892
2268
  ? {
1893
2269
  ...connection,
1894
2270
  completed,
2271
+ successCount,
2272
+ errorCount,
1895
2273
  ...resumePayload,
1896
2274
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
1897
2275
  ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
@@ -1972,18 +2350,18 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
1972
2350
  isolated: z
1973
2351
  .boolean()
1974
2352
  .optional()
1975
- .default(false)
1976
- .describe('When auto-connecting via pageUrl/url, request an isolated proxy. See geometra_connect for details.'),
2353
+ .describe('When auto-connecting via pageUrl/HTTP(S) url, use a fresh isolated Chromium by default. Pass false explicitly for sequential warm reuse.'),
1977
2354
  formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
1978
2355
  maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
1979
2356
  onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
1980
2357
  onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
1981
2358
  includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels'),
2359
+ includeFormGraph: z.boolean().optional().default(false).describe('Also include FormGraph 0.1-compatible graphs for the returned forms'),
1982
2360
  includeContext: formSchemaContextInput(),
1983
2361
  sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
1984
2362
  format: formSchemaFormatInput(),
1985
2363
  sessionId: sessionIdInput,
1986
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
2364
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeFormGraph, includeContext, sinceSchemaId, format, sessionId }) => {
1987
2365
  const browser = resolveBrowserStealth({ stealth, browserMode });
1988
2366
  if (!browser.ok)
1989
2367
  return err(browser.error);
@@ -2000,6 +2378,7 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
2000
2378
  onlyRequiredFields,
2001
2379
  onlyInvalidFields,
2002
2380
  includeOptions,
2381
+ includeFormGraph,
2003
2382
  includeContext,
2004
2383
  sinceSchemaId,
2005
2384
  format,
@@ -2132,7 +2511,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
2132
2511
  .optional()
2133
2512
  .default(2_500)
2134
2513
  .describe('Per-scroll wait timeout while revealing a semantic target (default 2500ms)'),
2135
- waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the click'),
2514
+ waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the click'),
2136
2515
  timeoutMs: z
2137
2516
  .number()
2138
2517
  .int()
@@ -2177,6 +2556,29 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
2177
2556
  });
2178
2557
  if (!resolved.ok)
2179
2558
  return err(clickFallbackErrorMessage(resolved));
2559
+ const waitFilter = waitFor ? {
2560
+ id: waitFor.id,
2561
+ role: waitFor.role,
2562
+ name: waitFor.name,
2563
+ text: waitFor.text,
2564
+ contextText: waitFor.contextText,
2565
+ promptText: waitFor.promptText,
2566
+ sectionText: waitFor.sectionText,
2567
+ itemText: waitFor.itemText,
2568
+ value: waitFor.value,
2569
+ checked: waitFor.checked,
2570
+ disabled: waitFor.disabled,
2571
+ focused: waitFor.focused,
2572
+ selected: waitFor.selected,
2573
+ expanded: waitFor.expanded,
2574
+ invalid: waitFor.invalid,
2575
+ required: waitFor.required,
2576
+ busy: waitFor.busy,
2577
+ } : undefined;
2578
+ const waitEvidenceRoot = sessionA11y(session) ?? before;
2579
+ const beforeWaitEvidence = waitFilter && waitEvidenceRoot
2580
+ ? captureWaitEvidence(waitEvidenceRoot, waitFilter)
2581
+ : undefined;
2180
2582
  const wait = await sendClick(session, resolved.value.x, resolved.value.y, timeoutMs);
2181
2583
  const summary = postActionSummary(session, before, wait, detail);
2182
2584
  const clickLine = !resolved.value.target
@@ -2185,37 +2587,23 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
2185
2587
  const lines = [clickLine, summary];
2186
2588
  if (waitFor) {
2187
2589
  const postWait = await waitForSemanticCondition(session, {
2188
- filter: {
2189
- id: waitFor.id,
2190
- role: waitFor.role,
2191
- name: waitFor.name,
2192
- text: waitFor.text,
2193
- contextText: waitFor.contextText,
2194
- promptText: waitFor.promptText,
2195
- sectionText: waitFor.sectionText,
2196
- itemText: waitFor.itemText,
2197
- value: waitFor.value,
2198
- checked: waitFor.checked,
2199
- disabled: waitFor.disabled,
2200
- focused: waitFor.focused,
2201
- selected: waitFor.selected,
2202
- expanded: waitFor.expanded,
2203
- invalid: waitFor.invalid,
2204
- required: waitFor.required,
2205
- busy: waitFor.busy,
2206
- },
2590
+ filter: waitFilter,
2207
2591
  present: waitFor.present ?? true,
2208
2592
  timeoutMs: waitFor.timeoutMs ?? 10_000,
2209
2593
  });
2210
2594
  if (!postWait.ok)
2211
2595
  return err([...lines, postWait.error].join('\n'));
2596
+ const fresh = waitConditionHasFreshEvidence(beforeWaitEvidence, sessionA11y(session), postWait.value);
2597
+ if (!fresh) {
2598
+ return err([...lines, 'Post-click condition matched only pre-existing evidence; the click outcome is unconfirmed.'].join('\n'));
2599
+ }
2212
2600
  lines.push(`Post-click ${waitConditionSuccessLine(postWait.value)}`);
2213
2601
  const compact = {
2214
2602
  at: { x: resolved.value.x, y: resolved.value.y },
2215
2603
  ...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
2216
2604
  ...waitStatusPayload(wait),
2217
2605
  ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
2218
- postWait: waitConditionCompact(postWait.value),
2606
+ postWait: { ...waitConditionCompact(postWait.value), fresh: true },
2219
2607
  };
2220
2608
  return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
2221
2609
  }
@@ -2225,13 +2613,23 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
2225
2613
  ...waitStatusPayload(wait),
2226
2614
  ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
2227
2615
  };
2616
+ if (wait.status === 'timed_out') {
2617
+ const guidance = ambiguousActionGuidance(wait);
2618
+ return err(detailText([...lines.filter(Boolean), guidance].join('\n'), {
2619
+ completed: false,
2620
+ outcome: 'unconfirmed',
2621
+ retrySafety: 'inspect-first',
2622
+ guidance,
2623
+ ...compact,
2624
+ }, detail));
2625
+ }
2228
2626
  return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
2229
2627
  });
2230
2628
  // ── type ─────────────────────────────────────────────────────
2231
2629
  server.tool('geometra_type', `Type text into the currently focused element. First click a textbox/input with geometra_click to focus it, then use this to type.
2232
2630
 
2233
2631
  Each character is sent as a key event through the geometry protocol. Returns a compact semantic delta when possible, otherwise a short current-UI overview.`, {
2234
- text: z.string().describe('Text to type into the focused element'),
2632
+ text: z.string().max(65_536).describe('Text to type into the focused element (maximum 65,536 characters)'),
2235
2633
  timeoutMs: z
2236
2634
  .number()
2237
2635
  .int()
@@ -2249,10 +2647,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
2249
2647
  const before = sessionA11y(session);
2250
2648
  const wait = await sendType(session, text, timeoutMs);
2251
2649
  const summary = postActionSummary(session, before, wait, detail);
2252
- return ok(detailText(`Typed "${text}".\n${summary}`, {
2650
+ return actionWaitResponse(wait, `Typed "${text}".\n${summary}`, {
2253
2651
  ...compactTextValue(text),
2254
2652
  ...waitStatusPayload(wait),
2255
- }, detail));
2653
+ }, detail);
2256
2654
  });
2257
2655
  // ── key ──────────────────────────────────────────────────────
2258
2656
  server.tool('geometra_key', `Send a special key press (Enter, Tab, Escape, ArrowDown, etc.) to the Geometra UI. Useful for form submission, focus navigation, and keyboard shortcuts.`, {
@@ -2278,10 +2676,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
2278
2676
  const before = sessionA11y(session);
2279
2677
  const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
2280
2678
  const summary = postActionSummary(session, before, wait, detail);
2281
- return ok(detailText(`Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`, {
2679
+ return actionWaitResponse(wait, `Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`, {
2282
2680
  key: formatKeyCombo(key, { shift, ctrl, meta, alt }),
2283
2681
  ...waitStatusPayload(wait),
2284
- }, detail));
2682
+ }, detail);
2285
2683
  });
2286
2684
  // ── fill OTP / verification-code box group ─────────────────────
2287
2685
  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.
@@ -2311,45 +2709,53 @@ Detection is fully generic (no site branding). It refuses to run if the detected
2311
2709
  const wait = await sendFillOtp(session, value, { fieldLabel, perCharDelayMs }, timeoutMs);
2312
2710
  const summary = postActionSummary(session, before, wait, detail);
2313
2711
  const result = wait.result;
2314
- return ok(detailText(`Filled OTP code (${value.length} chars).\n${summary}`, {
2712
+ return actionWaitResponse(wait, `Filled OTP code (${value.length} chars).\n${summary}`, {
2315
2713
  ...compactTextValue(value),
2316
2714
  ...(fieldLabel ? { fieldLabel } : {}),
2317
2715
  ...(result?.cellCount !== undefined ? { cellCount: result.cellCount } : {}),
2318
2716
  ...(result?.filledCount !== undefined ? { filledCount: result.filledCount } : {}),
2319
2717
  ...waitStatusPayload(wait),
2320
- }, detail));
2718
+ }, detail);
2321
2719
  }
2322
2720
  catch (e) {
2323
2721
  return err(e.message);
2324
2722
  }
2325
2723
  });
2326
2724
  // ── upload files (proxy) ───────────────────────────────────────
2327
- server.tool('geometra_upload_files', `Attach local files to a file input. Requires \`@geometra/proxy\` (paths exist on the proxy host).
2725
+ server.registerTool('geometra_upload_files', {
2726
+ description: `Attach local files to a file input. Requires \`@geometra/proxy\` (paths exist on the proxy host).
2328
2727
 
2329
- Strategies: **auto** (default) tries chooser click if x,y given, else a labeled file input when \`fieldLabel\` is provided, else hidden \`input[type=file]\`, else first visible file input. **hidden** targets hidden inputs directly. **drop** needs dropX,dropY for drag-target zones. **chooser** requires x,y.`, {
2330
- paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine, e.g. /Users/you/resume.pdf'),
2331
- x: z.number().optional().describe('Click X to trigger native file chooser'),
2332
- y: z.number().optional().describe('Click Y to trigger native file chooser'),
2333
- fieldLabel: z.string().optional().describe('Prefer a specific labeled file field (for example "Resume" or "Cover letter")'),
2334
- exact: z.boolean().optional().describe('Exact match when using fieldLabel'),
2335
- strategy: z
2336
- .enum(['auto', 'chooser', 'hidden', 'drop'])
2337
- .optional()
2338
- .describe('Upload strategy (default auto)'),
2339
- dropX: z.number().optional().describe('Drop target X (viewport) for strategy drop'),
2340
- dropY: z.number().optional().describe('Drop target Y (viewport) for strategy drop'),
2341
- contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated file inputs'),
2342
- sectionText: z.string().optional().describe('Containing section text to disambiguate repeated file inputs'),
2343
- timeoutMs: z
2344
- .number()
2345
- .int()
2346
- .min(50)
2347
- .max(60_000)
2348
- .optional()
2349
- .describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
2350
- detail: detailInput(),
2351
- sessionId: sessionIdInput,
2352
- }, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
2728
+ Every upload must name one explicit target. **auto** (default) accepts either a complete x,y chooser target or a labeled field. **hidden** requires fieldLabel. **chooser** requires x,y. **drop** requires dropX,dropY. Geometra rejects incomplete, conflicting, and global-first-input targets.`,
2729
+ inputSchema: z.object({
2730
+ paths: z.array(z.string().trim().min(1)).min(1).describe('Absolute paths on the proxy machine, e.g. /Users/you/resume.pdf'),
2731
+ x: z.number().optional().describe('Click X to trigger native file chooser; must be paired with y'),
2732
+ y: z.number().optional().describe('Click Y to trigger native file chooser; must be paired with x'),
2733
+ fieldLabel: z.string().trim().min(1).optional().describe('Specific labeled file field (for example "Resume" or "Cover letter")'),
2734
+ exact: z.boolean().optional().describe('Exact match when using fieldLabel'),
2735
+ strategy: z
2736
+ .enum(['auto', 'chooser', 'hidden', 'drop'])
2737
+ .optional()
2738
+ .describe('Upload strategy (default auto)'),
2739
+ dropX: z.number().optional().describe('Drop target X (viewport) for strategy drop; must be paired with dropY'),
2740
+ dropY: z.number().optional().describe('Drop target Y (viewport) for strategy drop; must be paired with dropX'),
2741
+ contextText: z.string().trim().min(1).optional().describe('Ancestor / prompt text to disambiguate repeated file inputs; requires fieldLabel'),
2742
+ sectionText: z.string().trim().min(1).optional().describe('Containing section text to disambiguate repeated file inputs; requires fieldLabel'),
2743
+ timeoutMs: z
2744
+ .number()
2745
+ .int()
2746
+ .min(50)
2747
+ .max(60_000)
2748
+ .optional()
2749
+ .describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
2750
+ detail: detailInput(),
2751
+ sessionId: sessionIdInput,
2752
+ }).strict().superRefine((input, ctx) => {
2753
+ addActionContractIssue(ctx, fileUploadContractError(input));
2754
+ }),
2755
+ }, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText, sectionText, timeoutMs, detail, sessionId }) => {
2756
+ const contractError = fileUploadContractError({ x, y, fieldLabel, exact, strategy, dropX, dropY, contextText, sectionText });
2757
+ if (contractError)
2758
+ return err(contractError);
2353
2759
  const sessionResult = resolveToolSession(sessionId);
2354
2760
  if ('error' in sessionResult)
2355
2761
  return sessionResult.error;
@@ -2362,41 +2768,51 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
2362
2768
  exact,
2363
2769
  strategy,
2364
2770
  drop: dropX !== undefined && dropY !== undefined ? { x: dropX, y: dropY } : undefined,
2771
+ contextText,
2772
+ sectionText,
2365
2773
  }, timeoutMs ?? 8_000);
2366
2774
  const summary = postActionSummary(session, before, wait, detail);
2367
- return ok(detailText(`Uploaded ${paths.length} file(s).\n${summary}`, {
2775
+ return actionWaitResponse(wait, `Uploaded ${paths.length} file(s).\n${summary}`, {
2368
2776
  fileCount: paths.length,
2369
2777
  ...(fieldLabel ? { fieldLabel } : {}),
2370
2778
  ...(strategy ? { strategy } : {}),
2779
+ ...(contextText ? { contextText } : {}),
2780
+ ...(sectionText ? { sectionText } : {}),
2371
2781
  ...waitStatusPayload(wait),
2372
2782
  ...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel) } : {}),
2373
- }, detail));
2783
+ }, detail);
2374
2784
  }
2375
2785
  catch (e) {
2376
2786
  return err(e.message);
2377
2787
  }
2378
2788
  });
2379
- server.tool('geometra_pick_listbox_option', `Pick an option from a custom dropdown / listbox / searchable combobox (Headless UI, React Select, Radix, Ashby-style custom selects, etc.). Requires \`@geometra/proxy\`.
2789
+ server.registerTool('geometra_pick_listbox_option', {
2790
+ description: `Pick an option from a custom dropdown / listbox / searchable combobox (Headless UI, React Select, Radix, Ashby-style custom selects, etc.). Requires \`@geometra/proxy\`.
2380
2791
 
2381
- Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying on coordinates. If the opened control is editable, MCP types \`query\` (or the option label by default) before selecting. Uses fuzzy-ish substring/alias matching unless exact=true, prefers the popup nearest the opened field, can fall back to keyboard navigation for searchable comboboxes, and returns capped \`visibleOptions\` in failure payloads so agents can retry with a real label.`, {
2382
- label: z.string().describe('Accessible name of the option (visible text or aria-label)'),
2383
- exact: z.boolean().optional().describe('Exact name match'),
2384
- openX: z.number().optional().describe('Click to open dropdown'),
2385
- openY: z.number().optional().describe('Click to open dropdown'),
2386
- fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
2387
- contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated dropdowns with the same label'),
2388
- sectionText: z.string().optional().describe('Containing section text to disambiguate repeated dropdowns'),
2389
- query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
2390
- timeoutMs: z
2391
- .number()
2392
- .int()
2393
- .min(50)
2394
- .max(60_000)
2395
- .optional()
2396
- .describe('Optional action wait timeout for slow dropdowns / remote search results'),
2397
- detail: detailInput(),
2398
- sessionId: sessionIdInput,
2399
- }, async ({ label, exact, openX, openY, fieldLabel, contextText: _contextText, sectionText: _sectionText, query, timeoutMs, detail, sessionId }) => {
2792
+ fieldLabel is required so Geometra can confirm which control committed the selection. fieldId/fieldKey are supplemental exact identity and are only meaningful alongside fieldLabel. Coordinate-only opening is intentionally unsupported because it cannot provide field readback. If the opened control is editable, MCP types query (or the option label by default) before selecting.`,
2793
+ inputSchema: z.object({
2794
+ label: z.string().trim().min(1).describe('Accessible name of the option (visible text or aria-label)'),
2795
+ exact: z.boolean().optional().describe('Exact name match'),
2796
+ fieldId: z.string().trim().min(1).optional().describe('Stable field id from geometra_form_schema; used with fieldLabel'),
2797
+ fieldKey: z.string().trim().min(1).optional().describe('Authored field key from geometra_form_schema for exact resolution; used with fieldLabel'),
2798
+ fieldLabel: z.string().trim().min(1).describe('Field label of the dropdown/combobox to open and verify (e.g. "Location")'),
2799
+ query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
2800
+ timeoutMs: z
2801
+ .number()
2802
+ .int()
2803
+ .min(50)
2804
+ .max(60_000)
2805
+ .optional()
2806
+ .describe('Optional action wait timeout for slow dropdowns / remote search results'),
2807
+ detail: detailInput(),
2808
+ sessionId: sessionIdInput,
2809
+ }).strict().superRefine((input, ctx) => {
2810
+ addActionContractIssue(ctx, listboxPickContractError(input));
2811
+ }),
2812
+ }, async ({ label, exact, fieldId, fieldKey, fieldLabel, query, timeoutMs, detail, sessionId }) => {
2813
+ const contractError = listboxPickContractError({ fieldId, fieldKey, fieldLabel });
2814
+ if (contractError)
2815
+ return err(contractError);
2400
2816
  const sessionResult = resolveToolSession(sessionId);
2401
2817
  if ('error' in sessionResult)
2402
2818
  return sessionResult.error;
@@ -2405,105 +2821,125 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
2405
2821
  try {
2406
2822
  const wait = await sendListboxPick(session, label, {
2407
2823
  exact,
2408
- open: openX !== undefined && openY !== undefined ? { x: openX, y: openY } : undefined,
2824
+ fieldId,
2825
+ fieldKey,
2409
2826
  fieldLabel,
2410
2827
  query,
2411
2828
  }, timeoutMs);
2412
2829
  const summary = postActionSummary(session, before, wait, detail);
2413
- const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel) : undefined;
2830
+ const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel, fieldKey) : undefined;
2414
2831
  const summaryText = [
2415
2832
  `Picked listbox option "${label}".`,
2416
2833
  fieldSummary,
2417
2834
  summary,
2418
2835
  ].filter(Boolean).join('\n');
2419
- return ok(detailText(summaryText, {
2836
+ return actionWaitResponse(wait, summaryText, {
2420
2837
  label,
2838
+ ...(fieldId ? { fieldId } : {}),
2839
+ ...(fieldKey ? { fieldKey } : {}),
2421
2840
  ...(fieldLabel ? { fieldLabel } : {}),
2422
2841
  ...waitStatusPayload(wait),
2423
- ...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel) } : {}),
2424
- }, detail));
2842
+ ...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel, fieldKey) } : {}),
2843
+ }, detail);
2425
2844
  }
2426
2845
  catch (e) {
2427
2846
  return err(e.message);
2428
2847
  }
2429
2848
  });
2430
2849
  // ── select option (proxy, native <select>) ─────────────────────
2431
- server.tool('geometra_select_option', `Set a native HTML \`<select>\` after clicking its center (x,y from geometra_query). Requires \`@geometra/proxy\`.
2850
+ server.registerTool('geometra_select_option', {
2851
+ description: `Set a native HTML \`<select>\` after clicking its center (x,y from geometra_query). Requires \`@geometra/proxy\`.
2432
2852
 
2433
- Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbox_option\` for custom dropdowns / searchable comboboxes.`, {
2434
- x: z.number().describe('X coordinate (e.g. center of the select from geometra_query)'),
2435
- y: z.number().describe('Y coordinate'),
2436
- value: z.string().optional().describe('Option value= attribute'),
2437
- label: z.string().optional().describe('Visible option label (substring match)'),
2438
- index: z.number().int().min(0).optional().describe('Zero-based option index'),
2439
- contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated selects'),
2440
- sectionText: z.string().optional().describe('Containing section text to disambiguate repeated selects'),
2441
- timeoutMs: z
2442
- .number()
2443
- .int()
2444
- .min(50)
2445
- .max(60_000)
2446
- .optional()
2447
- .describe('Optional action wait timeout'),
2448
- detail: detailInput(),
2449
- sessionId: sessionIdInput,
2450
- }, async ({ x, y, value, label, index, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
2853
+ Provide one or more of value, label, and index. Every supplied selector is conjunctive identity and must describe the same enabled option. Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbox_option\` for custom dropdowns / searchable comboboxes.`,
2854
+ inputSchema: z.object({
2855
+ x: z.number().describe('X coordinate (e.g. center of the select from geometra_query)'),
2856
+ y: z.number().describe('Y coordinate'),
2857
+ value: z.string().optional().describe('Exact option value= attribute'),
2858
+ label: z.string().trim().min(1).optional().describe('Exact normalized visible option label'),
2859
+ index: z.number().int().min(0).optional().describe('Zero-based option index'),
2860
+ timeoutMs: z
2861
+ .number()
2862
+ .int()
2863
+ .min(50)
2864
+ .max(60_000)
2865
+ .optional()
2866
+ .describe('Optional action wait timeout'),
2867
+ detail: detailInput(),
2868
+ sessionId: sessionIdInput,
2869
+ }).strict().superRefine((input, ctx) => {
2870
+ addActionContractIssue(ctx, selectOptionContractError(input));
2871
+ }),
2872
+ }, async ({ x, y, value, label, index, timeoutMs, detail, sessionId }) => {
2873
+ const contractError = selectOptionContractError({ x, y, value, label, index });
2874
+ if (contractError)
2875
+ return err(contractError);
2451
2876
  const sessionResult = resolveToolSession(sessionId);
2452
2877
  if ('error' in sessionResult)
2453
2878
  return sessionResult.error;
2454
2879
  const session = sessionResult.session;
2455
- if (value === undefined && label === undefined && index === undefined) {
2456
- return err('Provide at least one of value, label, or index');
2457
- }
2458
2880
  const before = sessionA11y(session);
2459
2881
  try {
2460
2882
  const wait = await sendSelectOption(session, x, y, { value, label, index }, timeoutMs);
2461
2883
  const summary = postActionSummary(session, before, wait, detail);
2462
- return ok(detailText(`Selected option.\n${summary}`, {
2884
+ return actionWaitResponse(wait, `Selected option.\n${summary}`, {
2463
2885
  at: { x, y },
2464
2886
  ...(value !== undefined ? { value } : {}),
2465
2887
  ...(label !== undefined ? { label } : {}),
2466
2888
  ...(index !== undefined ? { index } : {}),
2467
2889
  ...waitStatusPayload(wait),
2468
- }, detail));
2890
+ }, detail);
2469
2891
  }
2470
2892
  catch (e) {
2471
2893
  return err(e.message);
2472
2894
  }
2473
2895
  });
2474
- server.tool('geometra_set_checked', `Set a checkbox or radio by label. Requires \`@geometra/proxy\`.
2896
+ server.registerTool('geometra_set_checked', {
2897
+ description: `Set a checkbox or radio by label. Requires \`@geometra/proxy\`.
2475
2898
 
2476
- Prefer this over raw coordinate clicks for custom forms that keep the real input visually hidden (common on Ashby, Greenhouse custom widgets, and design-system checkboxes/radios). Uses substring label matching unless exact=true.`, {
2477
- label: z.string().describe('Accessible label or visible option text to match'),
2478
- checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
2479
- exact: z.boolean().optional().describe('Exact label match'),
2480
- controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
2481
- contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated checkboxes/radios'),
2482
- sectionText: z.string().optional().describe('Containing section text to disambiguate repeated checkboxes/radios'),
2483
- timeoutMs: z
2484
- .number()
2485
- .int()
2486
- .min(50)
2487
- .max(60_000)
2488
- .optional()
2489
- .describe('Optional action wait timeout'),
2490
- detail: detailInput(),
2491
- sessionId: sessionIdInput,
2492
- }, async ({ label, checked, exact, controlType, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
2899
+ Prefer this over raw coordinate clicks for custom forms that keep the real input visually hidden (common on Ashby, Greenhouse custom widgets, and design-system checkboxes/radios). Uses substring label matching unless exact=true. Unknown parameter names are rejected.`,
2900
+ inputSchema: z.object({
2901
+ label: z.string().trim().min(1, 'label must not be empty').describe('Accessible label or visible option text to match'),
2902
+ checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
2903
+ exact: z.boolean().optional().describe('Exact label match'),
2904
+ controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
2905
+ fieldKey: z.string().trim().min(1).optional().describe('Authored field key from geometra_form_schema for exact resolution'),
2906
+ contextText: z.string().trim().min(1).optional().describe('Ancestor / prompt text to disambiguate repeated checkboxes/radios'),
2907
+ sectionText: z.string().trim().min(1).optional().describe('Containing section text to disambiguate repeated checkboxes/radios'),
2908
+ timeoutMs: z
2909
+ .number()
2910
+ .int()
2911
+ .min(50)
2912
+ .max(60_000)
2913
+ .optional()
2914
+ .describe('Optional action wait timeout'),
2915
+ detail: detailInput(),
2916
+ sessionId: sessionIdInput,
2917
+ }).strict(),
2918
+ }, async ({ label, checked, exact, controlType, fieldKey, contextText, sectionText, timeoutMs, detail, sessionId }) => {
2493
2919
  const sessionResult = resolveToolSession(sessionId);
2494
2920
  if ('error' in sessionResult)
2495
2921
  return sessionResult.error;
2496
2922
  const session = sessionResult.session;
2497
2923
  const before = sessionA11y(session);
2498
2924
  try {
2499
- const wait = await sendSetChecked(session, label, { checked, exact, controlType }, timeoutMs);
2925
+ const wait = await sendSetChecked(session, label, {
2926
+ checked,
2927
+ exact,
2928
+ controlType,
2929
+ ...(fieldKey ? { fieldKey } : {}),
2930
+ contextText,
2931
+ sectionText,
2932
+ }, timeoutMs);
2500
2933
  const summary = postActionSummary(session, before, wait, detail);
2501
- return ok(detailText(`Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`, {
2934
+ return actionWaitResponse(wait, `Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`, {
2502
2935
  label,
2503
2936
  checked: checked ?? true,
2504
2937
  ...(controlType ? { controlType } : {}),
2938
+ ...(fieldKey ? { fieldKey } : {}),
2939
+ ...(contextText ? { contextText } : {}),
2940
+ ...(sectionText ? { sectionText } : {}),
2505
2941
  ...waitStatusPayload(wait),
2506
- }, detail));
2942
+ }, detail);
2507
2943
  }
2508
2944
  catch (e) {
2509
2945
  return err(e.message);
@@ -2533,12 +2969,12 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
2533
2969
  try {
2534
2970
  const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
2535
2971
  const summary = postActionSummary(session, before, wait, detail);
2536
- return ok(detailText(`Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`, {
2972
+ return actionWaitResponse(wait, `Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`, {
2537
2973
  deltaY,
2538
2974
  ...(deltaX !== undefined ? { deltaX } : {}),
2539
2975
  ...(x !== undefined && y !== undefined ? { at: { x, y } } : {}),
2540
2976
  ...waitStatusPayload(wait),
2541
- }, detail));
2977
+ }, detail);
2542
2978
  }
2543
2979
  catch (e) {
2544
2980
  return err(e.message);
@@ -2683,7 +3119,7 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
2683
3119
  return ok(JSON.stringify(session.layout, null, 2));
2684
3120
  });
2685
3121
  // ── workflow state ───────────────────────────────────────────
2686
- server.tool('geometra_workflow_state', `Get the accumulated workflow state across page navigations. Shows which pages/forms have been filled, what values were submitted, and the fill status per page.
3122
+ server.tool('geometra_workflow_state', `Get the accumulated workflow state across page navigations. Shows which pages/forms and field identities have been filled, with the fill status per page. Submitted values and local file paths are never stored or returned; every recorded field is represented by a redaction marker.
2687
3123
 
2688
3124
  Use this after navigating to a new page in a multi-step flow (e.g. job applications) to understand what has been completed so far. Pass \`clear: true\` to reset the workflow state.`, {
2689
3125
  clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
@@ -2700,6 +3136,8 @@ Use this after navigating to a new page in a multi-step flow (e.g. job applicati
2700
3136
  if (!session.workflowState || session.workflowState.pages.length === 0) {
2701
3137
  return ok(JSON.stringify({
2702
3138
  pageCount: 0,
3139
+ valuesRedacted: true,
3140
+ redactionNotice: 'Submitted values and local file paths are not stored.',
2703
3141
  message: 'No workflow state recorded yet. Fill a form with geometra_fill_form to start tracking.',
2704
3142
  }));
2705
3143
  }
@@ -2711,13 +3149,18 @@ Use this after navigating to a new page in a multi-step flow (e.g. job applicati
2711
3149
  totalFieldsFilled: totalFields,
2712
3150
  totalInvalidRemaining: totalInvalid,
2713
3151
  elapsedMs: Date.now() - state.startedAt,
3152
+ valuesRedacted: true,
3153
+ redactionNotice: 'Submitted values and local file paths are not stored; filledFields lists identities and filledValues contains redaction markers only.',
2714
3154
  pages: state.pages.map(p => ({
2715
3155
  pageUrl: p.pageUrl,
2716
3156
  ...(p.formId ? { formId: p.formId } : {}),
2717
3157
  ...(p.formName ? { formName: p.formName } : {}),
2718
3158
  fieldCount: p.fieldCount,
2719
3159
  invalidCount: p.invalidCount,
3160
+ filledFields: p.filledFields,
3161
+ // Preserve the established response key without retaining values.
2720
3162
  filledValues: p.filledValues,
3163
+ valuesRedacted: true,
2721
3164
  })),
2722
3165
  }));
2723
3166
  });
@@ -2786,12 +3229,19 @@ Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
2786
3229
  return err(`PDF generation failed: ${e instanceof Error ? e.message : String(e)}`);
2787
3230
  }
2788
3231
  });
2789
- server.tool('geometra_disconnect', `Disconnect from the Geometra server. Proxy-backed sessions keep compatible browsers alive by default so the next geometra_connect can reuse them quickly; pass closeBrowser=true to fully tear down the warm proxy/browser pool.`, {
2790
- closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
3232
+ server.tool('geometra_disconnect', 'Disconnect one explicitly resolved Geometra session. Isolated browser sessions are always destroyed. A session created with isolated: false may return its browser to the warm pool; pass closeBrowser: true to destroy that session\'s browser only. This tool never closes another session\'s browser.', {
3233
+ closeBrowser: z.boolean().optional().default(false).describe("Destroy this session's spawned proxy/browser instead of returning an explicitly reusable browser to the warm pool"),
2791
3234
  sessionId: sessionIdInput,
2792
3235
  }, async ({ closeBrowser, sessionId }) => {
2793
- disconnect({ closeProxy: closeBrowser, sessionId });
2794
- return ok(closeBrowser ? 'Disconnected and closed browser.' : 'Disconnected.');
3236
+ const resolved = resolveToolSession(sessionId);
3237
+ if ('error' in resolved)
3238
+ return resolved.error;
3239
+ const target = resolved.session;
3240
+ disconnect({ closeProxy: closeBrowser, sessionId: target.id });
3241
+ const browserClosed = closeBrowser || target.isolated === true;
3242
+ return ok(browserClosed
3243
+ ? `Disconnected session ${target.id} and closed its browser.`
3244
+ : `Disconnected session ${target.id}; its explicitly reusable browser remains warm.`);
2795
3245
  });
2796
3246
  server.tool('geometra_list_sessions', 'List all active Geometra sessions with their IDs and URLs. Use this to discover available sessions when operating on multiple pages in parallel.', {}, async () => {
2797
3247
  const sessions = listSessions();
@@ -2822,8 +3272,17 @@ function connectPayload(session, opts) {
2822
3272
  ...(opts.detail === 'verbose' && a11y ? { currentUi: sessionOverviewFromA11y(a11y) } : {}),
2823
3273
  };
2824
3274
  }
3275
+ function sessionHasFreshUi(session) {
3276
+ // `ws` is required on real Sessions. The absence allowance keeps legacy
3277
+ // in-process test doubles compatible while production always verifies the
3278
+ // transport is OPEN, closing the CLOSING -> close-event stale-read window.
3279
+ const transportOpen = !session.ws || session.ws.readyState === session.ws.OPEN;
3280
+ return transportOpen
3281
+ && session.hasFreshFrame !== false
3282
+ && Boolean(session.tree && session.layout);
3283
+ }
2825
3284
  function sessionA11y(session) {
2826
- if (!session.tree || !session.layout)
3285
+ if (!sessionHasFreshUi(session))
2827
3286
  return null;
2828
3287
  if (session.cachedA11yRevision === session.updateRevision) {
2829
3288
  return session.cachedA11y ?? null;
@@ -2834,9 +3293,17 @@ function sessionA11y(session) {
2834
3293
  return a11y;
2835
3294
  }
2836
3295
  async function ensureSessionUiTree(session, timeoutMs = 4_000) {
2837
- if (session.tree && session.layout)
3296
+ if (sessionHasFreshUi(session))
3297
+ return true;
3298
+ try {
3299
+ await ensureSessionConnected(session);
3300
+ }
3301
+ catch {
3302
+ return false;
3303
+ }
3304
+ if (sessionHasFreshUi(session))
2838
3305
  return true;
2839
- return await waitForUiCondition(session, () => Boolean(session.tree && session.layout), timeoutMs);
3306
+ return await waitForUiCondition(session, () => sessionHasFreshUi(session), timeoutMs);
2840
3307
  }
2841
3308
  async function sessionA11yWhenReady(session, timeoutMs = 4_000) {
2842
3309
  const ready = await ensureSessionUiTree(session, timeoutMs);
@@ -2883,6 +3350,7 @@ function packedFormSchemas(forms) {
2883
3350
  ic: form.invalidCount,
2884
3351
  f: form.fields.map(field => ({
2885
3352
  i: field.id,
3353
+ ...(field.fieldKey ? { fk: field.fieldKey } : {}),
2886
3354
  k: field.kind,
2887
3355
  l: field.label,
2888
3356
  ...(field.required ? { r: 1 } : {}),
@@ -2891,6 +3359,8 @@ function packedFormSchemas(forms) {
2891
3359
  ...(field.booleanChoice ? { b: 1 } : {}),
2892
3360
  ...(field.controlType ? { t: field.controlType } : {}),
2893
3361
  ...(field.optionCount !== undefined ? { oc: field.optionCount } : {}),
3362
+ ...(field.options ? { o: field.options } : {}),
3363
+ ...(field.optionDetails ? { od: field.optionDetails } : {}),
2894
3364
  ...(field.value ? { v: field.value } : {}),
2895
3365
  ...(field.valueLength !== undefined ? { vl: field.valueLength } : {}),
2896
3366
  ...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
@@ -2904,7 +3374,10 @@ function packedFormSchemas(forms) {
2904
3374
  }
2905
3375
  function formSchemaResponsePayload(session, opts) {
2906
3376
  const forms = getSessionFormSchemas(session, opts);
2907
- const schemaJson = JSON.stringify(forms);
3377
+ const graphForms = opts.includeFormGraph && !opts.includeOptions
3378
+ ? getSessionFormSchemas(session, { ...opts, includeOptions: true })
3379
+ : forms;
3380
+ const schemaJson = JSON.stringify(forms) + (opts.includeFormGraph ? '|formgraph' : '');
2908
3381
  const schemaId = `fs:${shortHash(schemaJson)}`;
2909
3382
  if (opts.sinceSchemaId && opts.sinceSchemaId === schemaId) {
2910
3383
  return {
@@ -2914,12 +3387,14 @@ function formSchemaResponsePayload(session, opts) {
2914
3387
  format: opts.format ?? 'compact',
2915
3388
  };
2916
3389
  }
3390
+ const pageUrl = sessionA11y(session)?.meta?.pageUrl;
2917
3391
  return {
2918
3392
  schemaId,
2919
3393
  changed: true,
2920
3394
  formCount: forms.length,
2921
3395
  format: opts.format ?? 'compact',
2922
3396
  forms: (opts.format ?? 'compact') === 'packed' ? packedFormSchemas(forms) : forms,
3397
+ ...(opts.includeFormGraph ? { formGraphs: graphForms.map(form => formSchemaToFormGraph(form, pageUrl)) } : {}),
2923
3398
  };
2924
3399
  }
2925
3400
  function totalReturnedSchemaFields(forms) {
@@ -2986,7 +3461,7 @@ function connectResponsePayload(session, opts) {
2986
3461
  function deferredPageModelConnectPayload(session, options) {
2987
3462
  return {
2988
3463
  deferred: true,
2989
- ready: Boolean(session.tree && session.layout),
3464
+ ready: sessionHasFreshUi(session),
2990
3465
  tool: 'geometra_page_model',
2991
3466
  options: {
2992
3467
  maxPrimaryActions: options?.maxPrimaryActions ?? 6,
@@ -3029,6 +3504,12 @@ async function ensureToolSession(target, missingConnectionMessage = 'Not connect
3029
3504
  if (!normalized.ok)
3030
3505
  return { ok: false, error: normalized.error };
3031
3506
  const resolvedTarget = normalized.value;
3507
+ if (resolvedTarget.kind === 'ws' && target.isolated === true) {
3508
+ return {
3509
+ ok: false,
3510
+ error: 'isolated:true is unsupported for direct ws:// endpoints because MCP does not own that browser/runtime. Use pageUrl/HTTP(S) url for an isolated browser, or omit isolated for the externally managed endpoint.',
3511
+ };
3512
+ }
3032
3513
  try {
3033
3514
  if (resolvedTarget.kind === 'proxy') {
3034
3515
  const session = await connectThroughProxy({
@@ -3040,7 +3521,7 @@ async function ensureToolSession(target, missingConnectionMessage = 'Not connect
3040
3521
  slowMo: target.slowMo,
3041
3522
  ...(target.stealth !== undefined && { stealth: target.stealth }),
3042
3523
  awaitInitialFrame: target.awaitInitialFrame,
3043
- isolated: target.isolated,
3524
+ isolated: target.isolated !== false,
3044
3525
  });
3045
3526
  return {
3046
3527
  ok: true,
@@ -3073,6 +3554,7 @@ function autoConnectionPayload(target) {
3073
3554
  return {};
3074
3555
  return {
3075
3556
  autoConnected: true,
3557
+ sessionId: target.session.id,
3076
3558
  ...(target.transport ? { transport: target.transport } : {}),
3077
3559
  ...(target.requestedPageUrl ? { pageUrl: target.requestedPageUrl } : {}),
3078
3560
  ...(target.requestedWsUrl ? { requestedWsUrl: target.requestedWsUrl } : {}),
@@ -3096,8 +3578,8 @@ function postActionSummary(session, before, wait, detail = 'minimal') {
3096
3578
  }
3097
3579
  if (wait?.status === 'timed_out') {
3098
3580
  notes.push(detail === 'verbose'
3099
- ? `No frame or patch arrived within ${wait.timeoutMs}ms after the action. The action may still have succeeded if it did not change geometry or semantics.`
3100
- : `No update arrived within ${wait.timeoutMs}ms; the action may still have succeeded.`);
3581
+ ? `No correlated terminal response arrived within ${wait.timeoutMs}ms. The action may still be running or may already have succeeded. ${ambiguousActionGuidance(wait)}`
3582
+ : `No correlated response arrived within ${wait.timeoutMs}ms; the action may still have succeeded. Do not retry blindly.`);
3101
3583
  }
3102
3584
  if (!after)
3103
3585
  return [...notes, 'No UI update received'].filter(Boolean).join('\n');
@@ -3131,6 +3613,123 @@ function summarizeCompactContext(context) {
3131
3613
  }
3132
3614
  return parts.length > 0 ? `Context: ${parts.join(' | ')}` : '';
3133
3615
  }
3616
+ function pathIsStrictDescendant(path, ancestorPath) {
3617
+ return path.length > ancestorPath.length && ancestorPath.every((segment, index) => path[index] === segment);
3618
+ }
3619
+ function formNodeAtPath(root, path) {
3620
+ const node = findNodeByPath(root, path);
3621
+ return node?.role === 'form' ? node : undefined;
3622
+ }
3623
+ function formScopeNodeAtPath(root, path) {
3624
+ const node = findNodeByPath(root, path);
3625
+ return node && (node.role === 'form' || node.role === 'group' || node.role === 'region')
3626
+ ? node
3627
+ : undefined;
3628
+ }
3629
+ function formScopeFieldAnchor(field) {
3630
+ if (field.fieldKey)
3631
+ return `key:${field.fieldKey}`;
3632
+ return [
3633
+ 'field',
3634
+ field.kind,
3635
+ normalizeLookupKey(field.label),
3636
+ field.controlType ?? '',
3637
+ field.choiceType ?? '',
3638
+ field.booleanChoice ? 'boolean' : '',
3639
+ ].join(':');
3640
+ }
3641
+ function captureFormScopeIdentity(node, schema) {
3642
+ const fieldAnchors = schema.fields.map(formScopeFieldAnchor).sort();
3643
+ const name = normalizeLookupKey(node.name || schema.name || '');
3644
+ if (fieldAnchors.length === 0 && !name) {
3645
+ return {
3646
+ ok: false,
3647
+ error: `Target form ${schema.formId} has no stable semantic or field identity; refusing to submit without a maintainable form scope`,
3648
+ };
3649
+ }
3650
+ return {
3651
+ ok: true,
3652
+ identity: {
3653
+ role: node.role,
3654
+ name,
3655
+ fieldAnchors,
3656
+ },
3657
+ };
3658
+ }
3659
+ function containsFormFieldAnchors(current, expected) {
3660
+ const counts = new Map();
3661
+ for (const anchor of current)
3662
+ counts.set(anchor, (counts.get(anchor) ?? 0) + 1);
3663
+ for (const anchor of expected) {
3664
+ const count = counts.get(anchor) ?? 0;
3665
+ if (count === 0)
3666
+ return false;
3667
+ counts.set(anchor, count - 1);
3668
+ }
3669
+ return true;
3670
+ }
3671
+ function currentFormScope(session, root, scope) {
3672
+ const node = formScopeNodeAtPath(root, scope.path);
3673
+ const schema = getSessionFormSchemas(session, {
3674
+ includeOptions: false,
3675
+ includeContext: 'auto',
3676
+ }).find(candidate => candidate.formId === scope.formId);
3677
+ if (!node || !schema) {
3678
+ return { ok: false, error: `Target form ${scope.formId} is stale or no longer resolves to a form in the current UI` };
3679
+ }
3680
+ const currentName = normalizeLookupKey(node.name || schema.name || '');
3681
+ const currentAnchors = schema.fields.map(formScopeFieldAnchor).sort();
3682
+ if (node.role !== scope.identity.role ||
3683
+ currentName !== scope.identity.name ||
3684
+ !containsFormFieldAnchors(currentAnchors, scope.identity.fieldAnchors)) {
3685
+ return {
3686
+ ok: false,
3687
+ error: `Target form ${scope.formId} was replaced at the same path; its semantic/schema identity no longer matches`,
3688
+ };
3689
+ }
3690
+ return { ok: true, node, schema };
3691
+ }
3692
+ function currentVisibleClickCenter(root, target) {
3693
+ const { x, y, width, height } = target.bounds;
3694
+ const viewport = root.bounds;
3695
+ if (![x, y, width, height, viewport.x, viewport.y, viewport.width, viewport.height].every(Number.isFinite))
3696
+ return undefined;
3697
+ if (width <= 0 || height <= 0 || viewport.width <= 0 || viewport.height <= 0)
3698
+ return undefined;
3699
+ const visibleLeft = Math.max(x, viewport.x);
3700
+ const visibleTop = Math.max(y, viewport.y);
3701
+ const visibleRight = Math.min(x + width, viewport.x + viewport.width);
3702
+ const visibleBottom = Math.min(y + height, viewport.y + viewport.height);
3703
+ if (visibleRight <= visibleLeft || visibleBottom <= visibleTop)
3704
+ return undefined;
3705
+ return {
3706
+ x: visibleLeft + (visibleRight - visibleLeft) / 2,
3707
+ y: visibleTop + (visibleBottom - visibleTop) / 2,
3708
+ };
3709
+ }
3710
+ function resolveSubmitFormNode(root, formId, submitPath) {
3711
+ const parsedForm = formId ? parseSectionId(formId) : null;
3712
+ if (parsedForm?.kind === 'form') {
3713
+ const explicit = formNodeAtPath(root, parsedForm.path);
3714
+ if (explicit)
3715
+ return explicit;
3716
+ }
3717
+ if (!submitPath)
3718
+ return undefined;
3719
+ for (let length = submitPath.length; length >= 0; length--) {
3720
+ const candidate = formNodeAtPath(root, submitPath.slice(0, length));
3721
+ if (candidate)
3722
+ return candidate;
3723
+ }
3724
+ return undefined;
3725
+ }
3726
+ function scopeSessionSignalsToForm(pageRoot, formNode, targetFormWasKnown = false) {
3727
+ const pageSignals = collectSessionSignals(pageRoot);
3728
+ if (formNode) {
3729
+ return { ...pageSignals, invalidFields: collectSessionSignals(formNode).invalidFields };
3730
+ }
3731
+ return targetFormWasKnown ? { ...pageSignals, invalidFields: [] } : pageSignals;
3732
+ }
3134
3733
  function collectSessionSignals(root) {
3135
3734
  const signals = {
3136
3735
  ...(root.meta?.pageUrl ? { pageUrl: root.meta.pageUrl } : {}),
@@ -3143,6 +3742,17 @@ function collectSessionSignals(root) {
3143
3742
  };
3144
3743
  const seenAlerts = new Set();
3145
3744
  const seenInvalidIds = new Set();
3745
+ const formControlRoles = new Set([
3746
+ 'textbox',
3747
+ 'combobox',
3748
+ 'checkbox',
3749
+ 'radio',
3750
+ 'spinbutton',
3751
+ 'listbox',
3752
+ 'searchbox',
3753
+ 'slider',
3754
+ 'switch',
3755
+ ]);
3146
3756
  const captchaPattern = /recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile|captcha/i;
3147
3757
  const captchaTypes = {
3148
3758
  recaptcha: 'recaptcha', 'g-recaptcha': 'recaptcha',
@@ -3169,7 +3779,11 @@ function collectSessionSignals(root) {
3169
3779
  signals.alerts.push(text);
3170
3780
  }
3171
3781
  }
3172
- if ((node.role === 'textbox' || node.role === 'combobox' || node.role === 'checkbox' || node.role === 'radio') && node.state?.invalid) {
3782
+ const isFormControl = formControlRoles.has(node.role) ||
3783
+ node.meta?.controlTag === 'input' ||
3784
+ node.meta?.controlTag === 'textarea' ||
3785
+ node.meta?.controlTag === 'select';
3786
+ if (isFormControl && (node.state?.invalid === true || Boolean(node.validation?.error))) {
3173
3787
  const id = nodeIdForPath(node.path);
3174
3788
  if (!seenInvalidIds.has(id)) {
3175
3789
  seenInvalidIds.add(id);
@@ -3274,20 +3888,26 @@ function compactTextValue(value, inlineLimit = 48) {
3274
3888
  ? { value: normalized }
3275
3889
  : { valueLength: value.length };
3276
3890
  }
3277
- function fieldStatePayload(session, fieldLabel) {
3891
+ function fieldReadbackNodes(a11y, fieldLabel, roles, fieldKey) {
3892
+ if (fieldKey) {
3893
+ const matches = [];
3894
+ const allowedRoles = new Set(roles);
3895
+ const walk = (node) => {
3896
+ if (allowedRoles.has(node.role) && node.meta?.controlKey === fieldKey)
3897
+ matches.push(node);
3898
+ for (const child of node.children)
3899
+ walk(child);
3900
+ };
3901
+ walk(a11y);
3902
+ return matches;
3903
+ }
3904
+ return roles.flatMap(role => findNodes(a11y, { name: fieldLabel, role }));
3905
+ }
3906
+ function fieldStatePayload(session, fieldLabel, fieldKey) {
3278
3907
  const a11y = sessionA11y(session);
3279
3908
  if (!a11y)
3280
3909
  return undefined;
3281
- const matches = findNodes(a11y, {
3282
- name: fieldLabel,
3283
- role: 'combobox',
3284
- });
3285
- if (matches.length === 0) {
3286
- matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
3287
- }
3288
- if (matches.length === 0) {
3289
- matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
3290
- }
3910
+ const matches = fieldReadbackNodes(a11y, fieldLabel, ['combobox', 'textbox', 'button'], fieldKey);
3291
3911
  const match = matches[0];
3292
3912
  if (!match)
3293
3913
  return undefined;
@@ -3302,7 +3922,11 @@ function fieldStatePayload(session, fieldLabel) {
3302
3922
  function waitStatusPayload(wait) {
3303
3923
  if (!wait)
3304
3924
  return {};
3305
- const payload = { wait: wait.status };
3925
+ const payload = {
3926
+ wait: wait.status,
3927
+ requestId: wait.requestId,
3928
+ actionId: wait.actionId,
3929
+ };
3306
3930
  // Surface navigation info from proxy click handlers so callers can tell
3307
3931
  // when a click triggered a full-page nav (form submit → thank-you page).
3308
3932
  // Without this, the proxy session may die on the next request and the
@@ -3372,6 +3996,45 @@ function waitConditionCompact(result) {
3372
3996
  ...(result.present ? { matchCount: result.matchCount } : {}),
3373
3997
  };
3374
3998
  }
3999
+ function waitEvidenceNodeSnapshot(node, filter) {
4000
+ const relevantState = {
4001
+ ...(filter.checked !== undefined ? { checked: node.state?.checked } : {}),
4002
+ ...(filter.disabled !== undefined ? { disabled: node.state?.disabled } : {}),
4003
+ ...(filter.focused !== undefined ? { focused: node.state?.focused } : {}),
4004
+ ...(filter.selected !== undefined ? { selected: node.state?.selected } : {}),
4005
+ ...(filter.expanded !== undefined ? { expanded: node.state?.expanded } : {}),
4006
+ ...(filter.invalid !== undefined ? { invalid: node.state?.invalid } : {}),
4007
+ ...(filter.required !== undefined ? { required: node.state?.required } : {}),
4008
+ ...(filter.busy !== undefined ? { busy: node.state?.busy } : {}),
4009
+ };
4010
+ return {
4011
+ role: node.role,
4012
+ name: node.name,
4013
+ value: node.value,
4014
+ validation: node.validation,
4015
+ ...(Object.keys(relevantState).length > 0 ? { state: relevantState } : {}),
4016
+ };
4017
+ }
4018
+ function captureWaitEvidence(root, filter) {
4019
+ const matches = findNodes(root, filter);
4020
+ return {
4021
+ matchCount: matches.length,
4022
+ fingerprints: new Set(matches.map(node => JSON.stringify(waitEvidenceNodeSnapshot(node, filter)))),
4023
+ };
4024
+ }
4025
+ function waitConditionHasFreshEvidence(before, after, result) {
4026
+ if (!before || !after)
4027
+ return false;
4028
+ const current = captureWaitEvidence(after, result.filter);
4029
+ if (!result.present) {
4030
+ return before.matchCount > 0 && current.matchCount === 0;
4031
+ }
4032
+ if (current.matchCount === 0)
4033
+ return false;
4034
+ if (before.matchCount === 0)
4035
+ return true;
4036
+ return [...current.fingerprints].some(fingerprint => !before.fingerprints.has(fingerprint));
4037
+ }
3375
4038
  function inferRevealStepBudget(target, viewport) {
3376
4039
  const verticalSteps = Math.ceil(Math.abs(target.scrollHint.revealDeltaY) / Math.max(1, viewport.height * 0.75));
3377
4040
  const horizontalSteps = Math.ceil(Math.abs(target.scrollHint.revealDeltaX) / Math.max(1, viewport.width * 0.7));
@@ -3396,9 +4059,19 @@ async function revealSemanticTarget(session, options) {
3396
4059
  const a11y = sessionA11y(session);
3397
4060
  if (!a11y)
3398
4061
  return { ok: false, error: 'No UI tree available to reveal from' };
3399
- const matches = sortA11yNodes(findNodes(a11y, options.filter));
4062
+ if (options.formScope) {
4063
+ const scoped = currentFormScope(session, a11y, options.formScope);
4064
+ if (!scoped.ok) {
4065
+ return { ok: false, error: scoped.error, staleFormScope: true };
4066
+ }
4067
+ }
4068
+ // Match against the page root first so prompt/section/item context from
4069
+ // ancestors outside the form remains available, then constrain the
4070
+ // actionable candidates to descendants of the selected current form.
4071
+ const matches = sortA11yNodes(findNodes(a11y, options.filter).filter(node => !options.formScope || pathIsStrictDescendant(node.path, options.formScope.path)));
3400
4072
  if (matches.length === 0) {
3401
- return { ok: false, error: `No elements found matching ${JSON.stringify(options.filter)}` };
4073
+ const scopeSuffix = options.formScope ? ` within form ${options.formScope.formId}` : '';
4074
+ return { ok: false, error: `No elements found matching ${JSON.stringify(options.filter)}${scopeSuffix}` };
3402
4075
  }
3403
4076
  if (options.index >= matches.length) {
3404
4077
  return {
@@ -3467,6 +4140,7 @@ async function resolveClickLocation(session, options) {
3467
4140
  filter: options.filter,
3468
4141
  index: options.index ?? 0,
3469
4142
  fullyVisible: options.fullyVisible ?? true,
4143
+ formScope: options.formScope,
3470
4144
  maxSteps: options.maxRevealSteps,
3471
4145
  timeoutMs: options.revealTimeoutMs ?? 2_500,
3472
4146
  });
@@ -3486,6 +4160,8 @@ async function resolveClickLocationWithFallback(session, options) {
3486
4160
  const first = await resolveClickLocation(session, options);
3487
4161
  if (first.ok)
3488
4162
  return first;
4163
+ if (first.staleFormScope)
4164
+ return first;
3489
4165
  // Fallback only applies to semantic resolves. Explicit coordinates never enter
3490
4166
  // the reveal path, so there is nothing to retry.
3491
4167
  const hasExplicitCoordinates = options.x !== undefined || options.y !== undefined;
@@ -3508,6 +4184,8 @@ async function resolveClickLocationWithFallback(session, options) {
3508
4184
  fallback: { attempted: true, used: true, reason: 'revision-retry', attempts },
3509
4185
  };
3510
4186
  }
4187
+ if (retry.staleFormScope)
4188
+ return retry;
3511
4189
  }
3512
4190
  if (options.fullyVisible !== false) {
3513
4191
  attempts += 1;
@@ -3524,6 +4202,8 @@ async function resolveClickLocationWithFallback(session, options) {
3524
4202
  fallback: { attempted: true, used: true, reason: 'relaxed-visibility', attempts },
3525
4203
  };
3526
4204
  }
4205
+ if (relaxed.staleFormScope)
4206
+ return relaxed;
3527
4207
  }
3528
4208
  // All fallback phases tried and none recovered. Carry the trace of what we
3529
4209
  // tried so operators see the attempted-but-failed signal alongside the
@@ -3570,6 +4250,24 @@ function compactFormattedNode(node) {
3570
4250
  function detailText(summary, compact, detail) {
3571
4251
  return detail === 'terse' ? JSON.stringify(compact) : summary;
3572
4252
  }
4253
+ function ambiguousActionGuidance(wait) {
4254
+ return `Outcome is ambiguous for actionId ${wait.actionId} (requestId ${wait.requestId}). ` +
4255
+ 'Do not retry blindly or skip this step; inspect the current UI first. ' +
4256
+ 'An identical retry reuses the same action identity when the proxy supports deduplication.';
4257
+ }
4258
+ function actionWaitResponse(wait, summary, compact, detail) {
4259
+ if (wait.status === 'timed_out') {
4260
+ const guidance = ambiguousActionGuidance(wait);
4261
+ return err(detailText(`${summary}\n${guidance}`, {
4262
+ completed: false,
4263
+ outcome: 'unconfirmed',
4264
+ retrySafety: 'inspect-first',
4265
+ guidance,
4266
+ ...compact,
4267
+ }, detail));
4268
+ }
4269
+ return ok(detailText(summary, compact, detail));
4270
+ }
3573
4271
  function normalizeLookupKey(value) {
3574
4272
  return value.replace(/\s+/g, ' ').trim().toLowerCase();
3575
4273
  }
@@ -3613,6 +4311,57 @@ function coerceChoiceValue(field, value) {
3613
4311
  const option = field.options?.find(option => normalizeLookupKey(option) === desired);
3614
4312
  return option ?? (value ? 'Yes' : 'No');
3615
4313
  }
4314
+ function resolveNativeChoiceValue(field, value) {
4315
+ const options = field.optionDetails;
4316
+ if (!options || options.length === 0)
4317
+ return { ok: true, value };
4318
+ const resolved = (option) => ({
4319
+ ok: true,
4320
+ value: option.value,
4321
+ optionIndex: option.index,
4322
+ expectedReadback: option.label,
4323
+ });
4324
+ const enabled = options.filter(option => !option.disabled);
4325
+ const exactValue = enabled.filter(option => option.value === value);
4326
+ if (exactValue.length > 0)
4327
+ return resolved(exactValue[0]);
4328
+ const lookup = normalizeLookupKey(value);
4329
+ const normalizedValues = enabled.filter(option => normalizeLookupKey(option.value) === lookup);
4330
+ if (normalizedValues.length === 1)
4331
+ return resolved(normalizedValues[0]);
4332
+ if (normalizedValues.length > 1) {
4333
+ const distinct = new Set(normalizedValues.map(option => option.value));
4334
+ if (distinct.size === 1)
4335
+ return resolved(normalizedValues[0]);
4336
+ return {
4337
+ ok: false,
4338
+ error: `Option value "${value}" is ambiguous for field "${field.label}". Use an exact option value from optionDetails.`,
4339
+ };
4340
+ }
4341
+ const labelMatches = enabled.filter(option => normalizeLookupKey(option.label) === lookup);
4342
+ if (labelMatches.length === 1)
4343
+ return resolved(labelMatches[0]);
4344
+ if (labelMatches.length > 1) {
4345
+ const distinctValues = new Set(labelMatches.map(option => option.value));
4346
+ if (distinctValues.size === 1)
4347
+ return resolved(labelMatches[0]);
4348
+ return {
4349
+ ok: false,
4350
+ error: `Option label "${value}" maps to multiple submitted values for field "${field.label}". Use an exact option value from optionDetails.`,
4351
+ };
4352
+ }
4353
+ const disabledMatch = options.some(option => option.disabled && (option.value === value ||
4354
+ normalizeLookupKey(option.value) === lookup ||
4355
+ normalizeLookupKey(option.label) === lookup));
4356
+ if (disabledMatch) {
4357
+ return { ok: false, error: `Option "${value}" is disabled for field "${field.label}".` };
4358
+ }
4359
+ const available = enabled.slice(0, 12).map(option => `${option.label} (${option.value})`);
4360
+ return {
4361
+ ok: false,
4362
+ error: `Unknown option "${value}" for field "${field.label}".${available.length > 0 ? ` Available options: ${available.join(', ')}.` : ''}`,
4363
+ };
4364
+ }
3616
4365
  /**
3617
4366
  * Normalize a text value based on field format hints (placeholder, inputType, pattern).
3618
4367
  * Handles common date and phone format conversions.
@@ -3688,28 +4437,69 @@ function pad2(n) {
3688
4437
  return n < 10 ? `0${n}` : String(n);
3689
4438
  }
3690
4439
  function plannedFillInputsForField(field, value) {
4440
+ if (field.kind === 'file') {
4441
+ if (!Array.isArray(value)) {
4442
+ return { error: `Field "${field.label}" expects a non-empty string array of proxy-host file paths` };
4443
+ }
4444
+ const paths = value.map(path => path.trim()).filter(Boolean);
4445
+ if (paths.length === 0 || paths.length !== value.length) {
4446
+ return { error: `Field "${field.label}" expects a non-empty string array of proxy-host file paths` };
4447
+ }
4448
+ if (field.format?.multiple !== true && paths.length > 1) {
4449
+ return { error: `Field "${field.label}" accepts only one file` };
4450
+ }
4451
+ return [{
4452
+ kind: 'file',
4453
+ fieldId: field.id,
4454
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4455
+ fieldLabel: field.label,
4456
+ paths,
4457
+ }];
4458
+ }
3691
4459
  if (field.kind === 'text') {
3692
4460
  if (typeof value !== 'string')
3693
4461
  return { error: `Field "${field.label}" expects a string value` };
3694
4462
  const normalized = normalizeFieldValue(value, field.format);
3695
- return [{ kind: 'text', fieldId: field.id, fieldLabel: field.label, value: normalized }];
4463
+ return [{
4464
+ kind: 'text',
4465
+ fieldId: field.id,
4466
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4467
+ fieldLabel: field.label,
4468
+ value: normalized,
4469
+ }];
3696
4470
  }
3697
4471
  if (field.kind === 'choice') {
3698
4472
  const coerced = coerceChoiceValue(field, value);
3699
- if (!coerced)
4473
+ if (coerced === null)
3700
4474
  return { error: `Field "${field.label}" expects a string value` };
4475
+ const resolvedChoice = field.choiceType === 'select'
4476
+ ? resolveNativeChoiceValue(field, coerced)
4477
+ : { ok: true, value: coerced };
4478
+ if (!resolvedChoice.ok)
4479
+ return { error: resolvedChoice.error };
3701
4480
  return [{
3702
4481
  kind: 'choice',
3703
4482
  fieldId: field.id,
4483
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
3704
4484
  fieldLabel: field.label,
3705
- value: coerced,
4485
+ value: resolvedChoice.value,
4486
+ ...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
4487
+ ...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
4488
+ ...(field.choiceType === 'select' && field.optionDetails?.length ? { exact: true } : {}),
3706
4489
  ...(field.choiceType ? { choiceType: field.choiceType } : {}),
3707
4490
  }];
3708
4491
  }
3709
4492
  if (field.kind === 'toggle') {
3710
4493
  if (typeof value !== 'boolean')
3711
4494
  return { error: `Field "${field.label}" expects a boolean value` };
3712
- return [{ kind: 'toggle', fieldId: field.id, label: field.label, checked: value, controlType: field.controlType }];
4495
+ return [{
4496
+ kind: 'toggle',
4497
+ fieldId: field.id,
4498
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4499
+ label: field.label,
4500
+ checked: value,
4501
+ controlType: field.controlType,
4502
+ }];
3713
4503
  }
3714
4504
  const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
3715
4505
  if (!selected || selected.length === 0)
@@ -3721,12 +4511,29 @@ function plannedFillInputsForField(field, value) {
3721
4511
  return field.options.map(option => ({
3722
4512
  kind: 'toggle',
3723
4513
  fieldId: field.id,
4514
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
3724
4515
  label: option,
3725
4516
  checked: selectedKeys.has(normalizeLookupKey(option)),
3726
4517
  controlType: 'checkbox',
3727
4518
  }));
3728
4519
  }
3729
- function planFormFill(schema, opts) {
4520
+ function unkeyedSchemaFieldIsAmbiguous(field, schemas) {
4521
+ if (field.fieldKey)
4522
+ return false;
4523
+ const matchingIds = new Set();
4524
+ const label = normalizeLookupKey(field.label);
4525
+ for (const schema of schemas) {
4526
+ for (const candidate of schema.fields) {
4527
+ if (normalizeLookupKey(candidate.label) === label)
4528
+ matchingIds.add(candidate.id);
4529
+ }
4530
+ }
4531
+ return matchingIds.size > 1;
4532
+ }
4533
+ function ambiguousFieldIdentityError(field) {
4534
+ return `Field "${field.label}" has no unique authored identity and shares its label with another control. Geometra refused to guess; refresh the schema or use a contextual recovery action.`;
4535
+ }
4536
+ function planFormFill(schema, opts, pageSchemas = [schema]) {
3730
4537
  const fieldById = new Map(schema.fields.map(field => [field.id, field]));
3731
4538
  const fieldsByLabel = new Map();
3732
4539
  for (const field of schema.fields) {
@@ -3743,6 +4550,9 @@ function planFormFill(schema, opts) {
3743
4550
  const field = fieldById.get(fieldId);
3744
4551
  if (!field)
3745
4552
  return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
4553
+ if (unkeyedSchemaFieldIsAmbiguous(field, pageSchemas)) {
4554
+ return { ok: false, error: ambiguousFieldIdentityError(field) };
4555
+ }
3746
4556
  const next = plannedFillInputsForField(field, value);
3747
4557
  if ('error' in next)
3748
4558
  return { ok: false, error: next.error };
@@ -3758,6 +4568,9 @@ function planFormFill(schema, opts) {
3758
4568
  return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
3759
4569
  }
3760
4570
  const field = matches[0];
4571
+ if (unkeyedSchemaFieldIsAmbiguous(field, pageSchemas)) {
4572
+ return { ok: false, error: ambiguousFieldIdentityError(field) };
4573
+ }
3761
4574
  if (seenFieldIds.has(field.id)) {
3762
4575
  return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
3763
4576
  }
@@ -3780,36 +4593,83 @@ function isResolvedFillFieldInput(field) {
3780
4593
  }
3781
4594
  function resolveFillFieldInputs(session, fields) {
3782
4595
  const unresolved = fields.filter(field => !isResolvedFillFieldInput(field));
3783
- if (unresolved.length === 0) {
4596
+ const requiresSchemaLookup = fields.some(field => typeof field.fieldId === 'string');
4597
+ const a11y = sessionA11y(session);
4598
+ if (unresolved.length === 0 && !requiresSchemaLookup && !a11y) {
3784
4599
  return { ok: true, fields: fields };
3785
4600
  }
3786
- const a11y = sessionA11y(session);
3787
- if (!a11y)
4601
+ if (!a11y) {
3788
4602
  return { ok: false, error: 'No UI tree available to resolve fieldId entries from geometra_form_schema' };
4603
+ }
4604
+ const schemas = buildFormSchemas(a11y, { includeOptions: true, includeContext: 'always' });
3789
4605
  const fieldById = new Map();
3790
- for (const schema of buildFormSchemas(a11y, { includeOptions: true, includeContext: 'always' })) {
4606
+ for (const schema of schemas) {
3791
4607
  for (const field of schema.fields)
3792
4608
  fieldById.set(field.id, field);
3793
4609
  }
3794
4610
  const resolved = [];
3795
4611
  for (const field of fields) {
3796
4612
  if (isResolvedFillFieldInput(field)) {
3797
- if (field.kind === 'choice' && field.fieldId && field.choiceType === undefined) {
3798
- const schemaField = fieldById.get(field.fieldId);
4613
+ const schemaField = field.fieldId ? fieldById.get(field.fieldId) : undefined;
4614
+ if (field.fieldId && !schemaField) {
4615
+ return { ok: false, error: `Unknown form field id ${field.fieldId}. Refresh geometra_form_schema and try again.` };
4616
+ }
4617
+ if (schemaField) {
4618
+ if (schemaField.kind !== field.kind) {
4619
+ return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not ${field.kind}.` };
4620
+ }
4621
+ const suppliedLabel = field.kind === 'toggle' ? field.label : field.fieldLabel;
4622
+ if (normalizeLookupKey(suppliedLabel) !== normalizeLookupKey(schemaField.label)) {
4623
+ return {
4624
+ ok: false,
4625
+ error: `Field id ${field.fieldId} now resolves to "${schemaField.label}", not "${suppliedLabel}". Refresh geometra_form_schema; Geometra refused to downgrade the stale id to a label-only mutation.`,
4626
+ };
4627
+ }
4628
+ }
4629
+ const suppliedLabel = field.kind === 'toggle' ? field.label : field.fieldLabel;
4630
+ const distinctLabelMatches = new Map();
4631
+ for (const candidate of fieldById.values()) {
4632
+ if (normalizeLookupKey(candidate.label) === normalizeLookupKey(suppliedLabel)) {
4633
+ distinctLabelMatches.set(candidate.id, candidate);
4634
+ }
4635
+ }
4636
+ const effectiveFieldKey = field.fieldKey ?? schemaField?.fieldKey;
4637
+ if (!effectiveFieldKey && distinctLabelMatches.size > 1) {
4638
+ return {
4639
+ ok: false,
4640
+ error: schemaField
4641
+ ? ambiguousFieldIdentityError(schemaField)
4642
+ : `Field label "${suppliedLabel}" is ambiguous and no exact field identity was supplied. Geometra refused to guess.`,
4643
+ };
4644
+ }
4645
+ if (field.kind === 'choice' && schemaField?.kind === 'choice') {
4646
+ const resolvedChoice = schemaField.choiceType === 'select'
4647
+ ? resolveNativeChoiceValue(schemaField, field.value)
4648
+ : { ok: true, value: field.value };
4649
+ if (!resolvedChoice.ok)
4650
+ return { ok: false, error: resolvedChoice.error };
3799
4651
  resolved.push({
3800
4652
  ...field,
3801
- ...(schemaField?.kind === 'choice' && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
4653
+ value: resolvedChoice.value,
4654
+ ...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
4655
+ ...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
4656
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4657
+ ...(field.choiceType === undefined && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
4658
+ ...(schemaField.choiceType === 'select' && schemaField.optionDetails?.length ? { exact: true } : {}),
3802
4659
  });
3803
4660
  }
3804
- else if (field.kind === 'toggle' && field.fieldId && field.controlType === undefined) {
3805
- const schemaField = fieldById.get(field.fieldId);
4661
+ else if (field.kind === 'toggle' && schemaField?.kind === 'toggle') {
3806
4662
  resolved.push({
3807
4663
  ...field,
3808
- ...(schemaField?.kind === 'toggle' && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
4664
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4665
+ ...(field.controlType === undefined && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
3809
4666
  });
3810
4667
  }
3811
4668
  else {
3812
- resolved.push(field);
4669
+ resolved.push({
4670
+ ...field,
4671
+ ...(schemaField?.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4672
+ });
3813
4673
  }
3814
4674
  continue;
3815
4675
  }
@@ -3825,21 +4685,38 @@ function resolveFillFieldInputs(session, fields) {
3825
4685
  if (!schemaField) {
3826
4686
  return { ok: false, error: `Unknown form field id ${field.fieldId}. Refresh geometra_form_schema and try again.` };
3827
4687
  }
4688
+ if (unkeyedSchemaFieldIsAmbiguous(schemaField, schemas)) {
4689
+ return { ok: false, error: ambiguousFieldIdentityError(schemaField) };
4690
+ }
3828
4691
  if (field.kind === 'text') {
3829
4692
  if (schemaField.kind !== 'text') {
3830
4693
  return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not text.` };
3831
4694
  }
3832
- resolved.push({ ...field, fieldLabel: schemaField.label });
4695
+ resolved.push({
4696
+ ...field,
4697
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4698
+ fieldLabel: schemaField.label,
4699
+ });
3833
4700
  continue;
3834
4701
  }
3835
4702
  if (field.kind === 'choice') {
3836
4703
  if (schemaField.kind !== 'choice') {
3837
4704
  return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not choice.` };
3838
4705
  }
4706
+ const resolvedChoice = schemaField.choiceType === 'select'
4707
+ ? resolveNativeChoiceValue(schemaField, field.value)
4708
+ : { ok: true, value: field.value };
4709
+ if (!resolvedChoice.ok)
4710
+ return { ok: false, error: resolvedChoice.error };
3839
4711
  resolved.push({
3840
4712
  ...field,
4713
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
3841
4714
  fieldLabel: schemaField.label,
4715
+ value: resolvedChoice.value,
4716
+ ...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
4717
+ ...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
3842
4718
  ...(field.choiceType === undefined && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
4719
+ ...(schemaField.choiceType === 'select' && schemaField.optionDetails?.length ? { exact: true } : {}),
3843
4720
  });
3844
4721
  continue;
3845
4722
  }
@@ -3849,19 +4726,26 @@ function resolveFillFieldInputs(session, fields) {
3849
4726
  }
3850
4727
  resolved.push({
3851
4728
  ...field,
4729
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
3852
4730
  label: schemaField.label,
3853
4731
  ...(field.controlType === undefined && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
3854
4732
  });
3855
4733
  continue;
3856
4734
  }
3857
- return {
3858
- ok: false,
3859
- error: `File field id ${field.fieldId} still needs fieldLabel. geometra_form_schema does not reliably expose proxy file inputs yet.`,
3860
- };
4735
+ if (schemaField.kind !== 'file') {
4736
+ return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not file.` };
4737
+ }
4738
+ resolved.push({
4739
+ ...field,
4740
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4741
+ fieldLabel: schemaField.label,
4742
+ });
3861
4743
  }
3862
4744
  return { ok: true, fields: resolved };
3863
4745
  }
3864
4746
  function canFallbackToSequentialFill(error) {
4747
+ if (ambiguousOutcomeDetails(error))
4748
+ return false;
3865
4749
  const message = error instanceof Error ? error.message : String(error);
3866
4750
  try {
3867
4751
  const parsed = JSON.parse(message);
@@ -3906,25 +4790,25 @@ function parseProxyFillAckResult(value) {
3906
4790
  ...(invalidFields && invalidFields.length > 0 ? { invalidFields } : {}),
3907
4791
  };
3908
4792
  }
3909
- function directLabelBatchFields(valuesByLabel) {
3910
- const entries = Object.entries(valuesByLabel ?? {});
3911
- if (entries.length === 0)
3912
- return null;
3913
- const fields = [];
3914
- for (const [fieldLabel, value] of entries) {
3915
- if (typeof value !== 'string' && typeof value !== 'boolean')
3916
- return null;
3917
- fields.push({ kind: 'auto', fieldLabel, value });
3918
- }
3919
- return fields;
3920
- }
3921
4793
  async function tryBatchedResolvedFields(session, fields, detail) {
3922
4794
  let batchAckResult;
3923
4795
  try {
3924
4796
  const startRevision = session.updateRevision;
3925
- const wait = await sendFillFields(session, fields);
4797
+ const wait = await sendFillFields(session, toProxyFillFields(fields));
4798
+ assertActionWaitConfirmed('Batched field fill', wait);
3926
4799
  const ackResult = parseProxyFillAckResult(wait.result);
3927
4800
  batchAckResult = ackResult;
4801
+ if (fields.some(field => field.kind === 'file')) {
4802
+ if (!ackResult) {
4803
+ throw new AmbiguousActionOutcomeError('Batched file fill', wait, 'Batched file fill was acknowledged without structured proxy confirmation; the upload may already have happened. Do not retry blindly.');
4804
+ }
4805
+ return {
4806
+ ok: true,
4807
+ finalSource: 'proxy',
4808
+ final: ackResult,
4809
+ invalidRemaining: ackResult.invalidCount,
4810
+ };
4811
+ }
3928
4812
  if (ackResult && ackResult.invalidCount === 0) {
3929
4813
  return {
3930
4814
  ok: true,
@@ -3934,27 +4818,31 @@ async function tryBatchedResolvedFields(session, fields, detail) {
3934
4818
  };
3935
4819
  }
3936
4820
  await waitForDeferredBatchUpdate(session, startRevision, wait);
3937
- await waitForBatchFieldReadback(session, fields);
4821
+ const readbackSnapshot = await waitForBatchFieldReadback(session, fields);
4822
+ if (!readbackSnapshot) {
4823
+ if (!sessionA11y(session)) {
4824
+ throw new AmbiguousActionOutcomeError('Batched field fill', wait, 'Batched field fill was sent, but its read-back evidence became unavailable before confirmation. The fields may already have changed. Do not retry blindly.');
4825
+ }
4826
+ return { ok: false };
4827
+ }
4828
+ const signals = collectSessionSignals(readbackSnapshot);
4829
+ const invalidRemaining = signals.invalidFields.length;
4830
+ if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
4831
+ return { ok: false };
4832
+ }
4833
+ return {
4834
+ ok: true,
4835
+ finalSource: 'session',
4836
+ final: sessionSignalsPayload(signals, detail),
4837
+ invalidRemaining,
4838
+ readbackSnapshot,
4839
+ };
3938
4840
  }
3939
4841
  catch (e) {
3940
4842
  if (canFallbackToSequentialFill(e))
3941
4843
  return { ok: false };
3942
4844
  throw e;
3943
4845
  }
3944
- const after = sessionA11y(session);
3945
- if (!after)
3946
- return { ok: false };
3947
- const signals = collectSessionSignals(after);
3948
- const invalidRemaining = signals.invalidFields.length;
3949
- if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
3950
- return { ok: false };
3951
- }
3952
- return {
3953
- ok: true,
3954
- finalSource: 'session',
3955
- final: sessionSignalsPayload(signals, detail),
3956
- invalidRemaining,
3957
- };
3958
4846
  }
3959
4847
  async function waitForDeferredBatchUpdate(session, startRevision, wait) {
3960
4848
  if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
@@ -3962,37 +4850,48 @@ async function waitForDeferredBatchUpdate(session, startRevision, wait) {
3962
4850
  await waitForUiCondition(session, () => session.updateRevision > startRevision, 750);
3963
4851
  }
3964
4852
  async function waitForBatchFieldReadback(session, fields) {
4853
+ let matchingSnapshot;
3965
4854
  await waitForUiCondition(session, () => {
3966
4855
  const a11y = sessionA11y(session);
3967
4856
  if (!a11y)
3968
4857
  return false;
3969
- return fields.every(field => batchFieldReadbackMatches(a11y, field));
4858
+ if (!fields.every(field => batchFieldReadbackMatches(a11y, field)))
4859
+ return false;
4860
+ matchingSnapshot = a11y;
4861
+ return true;
3970
4862
  }, 1500);
4863
+ return matchingSnapshot;
3971
4864
  }
3972
4865
  function batchFieldReadbackMatches(a11y, field) {
3973
4866
  switch (field.kind) {
3974
4867
  case 'text': {
3975
- const matches = findNodes(a11y, { name: field.fieldLabel, role: 'textbox' });
4868
+ const matches = fieldReadbackNodes(a11y, field.fieldLabel, ['textbox'], field.fieldKey);
3976
4869
  return matches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
3977
4870
  }
3978
4871
  case 'choice': {
3979
- const directMatches = [
3980
- ...findNodes(a11y, { name: field.fieldLabel, role: 'combobox' }),
3981
- ...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
3982
- ...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
3983
- ];
4872
+ const directMatches = fieldReadbackNodes(a11y, field.fieldLabel, ['combobox', 'textbox', 'button'], field.fieldKey);
3984
4873
  if (directMatches.length === 0)
3985
- return true;
3986
- return directMatches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
4874
+ return field.fieldKey === undefined;
4875
+ if (field.optionIndex !== undefined) {
4876
+ return directMatches.some(match => {
4877
+ const selected = match.meta?.options?.find(option => option.selected);
4878
+ return selected
4879
+ ? selected.index === field.optionIndex && selected.value === field.value
4880
+ : false;
4881
+ });
4882
+ }
4883
+ const expected = [field.value, field.expectedReadback]
4884
+ .filter((value) => value !== undefined)
4885
+ .map(normalizeLookupKey);
4886
+ return directMatches.some(match => expected.includes(normalizeLookupKey(match.value ?? '')));
3987
4887
  }
3988
4888
  case 'toggle':
3989
4889
  return true;
3990
4890
  case 'file': {
3991
- const matches = [
3992
- ...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
3993
- ...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
3994
- ];
3995
- return matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
4891
+ const matches = fieldReadbackNodes(a11y, field.fieldLabel, ['textbox', 'button'], field.fieldKey);
4892
+ return field.fieldKey
4893
+ ? matches.some(match => Boolean(match.value && match.value.trim()))
4894
+ : matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
3996
4895
  }
3997
4896
  }
3998
4897
  }
@@ -4013,6 +4912,55 @@ function canDeferInitialFrameForRunActions(actions) {
4013
4912
  return false;
4014
4913
  return first.type === 'fill_fields';
4015
4914
  }
4915
+ function assertBatchActionConfirmed(actionType, wait) {
4916
+ assertActionWaitConfirmed(`${actionType} action`, wait);
4917
+ }
4918
+ class AmbiguousActionOutcomeError extends Error {
4919
+ wait;
4920
+ constructor(actionName, wait, message) {
4921
+ super(message ?? `${actionName} timed out after ${wait.timeoutMs}ms; its outcome is unconfirmed. ${ambiguousActionGuidance(wait)}`);
4922
+ this.name = 'AmbiguousActionOutcomeError';
4923
+ this.wait = wait;
4924
+ }
4925
+ }
4926
+ function ambiguousOutcomeDetails(error) {
4927
+ if (error instanceof AmbiguousActionOutcomeError) {
4928
+ return {
4929
+ wait: error.wait,
4930
+ requestId: error.wait.requestId,
4931
+ actionId: error.wait.actionId,
4932
+ };
4933
+ }
4934
+ if (!error || typeof error !== 'object')
4935
+ return undefined;
4936
+ const candidate = error;
4937
+ const code = typeof candidate.code === 'string' ? candidate.code : undefined;
4938
+ const requestId = typeof candidate.requestId === 'string' ? candidate.requestId : undefined;
4939
+ const actionId = typeof candidate.actionId === 'string' ? candidate.actionId : undefined;
4940
+ const message = typeof candidate.message === 'string' ? candidate.message : '';
4941
+ if (code !== 'ACTION_OUTCOME_AMBIGUOUS' &&
4942
+ !(requestId && actionId && message.includes('Outcome is ambiguous')))
4943
+ return undefined;
4944
+ return { ...(requestId ? { requestId } : {}), ...(actionId ? { actionId } : {}), ...(code ? { code } : {}) };
4945
+ }
4946
+ function ambiguousOutcomePayload(details) {
4947
+ return {
4948
+ outcome: 'unconfirmed',
4949
+ retrySafety: 'inspect-first',
4950
+ ...(details.wait ? waitStatusPayload(details.wait) : {}),
4951
+ ...(!details.wait && details.requestId ? { requestId: details.requestId } : {}),
4952
+ ...(!details.wait && details.actionId ? { actionId: details.actionId } : {}),
4953
+ ...(details.code ? { code: details.code } : {}),
4954
+ guidance: details.actionId && details.requestId
4955
+ ? `Outcome is ambiguous for actionId ${details.actionId} (requestId ${details.requestId}). Do not retry blindly or skip this step; inspect the current UI first.`
4956
+ : 'The action outcome is ambiguous. Do not retry blindly or skip this step; inspect the current UI first.',
4957
+ };
4958
+ }
4959
+ function assertActionWaitConfirmed(actionName, wait) {
4960
+ if (wait.status === 'timed_out') {
4961
+ throw new AmbiguousActionOutcomeError(actionName, wait);
4962
+ }
4963
+ }
4016
4964
  async function executeBatchAction(session, action, detail, includeSteps) {
4017
4965
  switch (action.type) {
4018
4966
  case 'click': {
@@ -4046,7 +4994,31 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4046
4994
  });
4047
4995
  if (!resolved.ok)
4048
4996
  throw new Error(clickFallbackErrorMessage(resolved));
4997
+ const waitFilter = action.waitFor ? {
4998
+ id: action.waitFor.id,
4999
+ role: action.waitFor.role,
5000
+ name: action.waitFor.name,
5001
+ text: action.waitFor.text,
5002
+ contextText: action.waitFor.contextText,
5003
+ promptText: action.waitFor.promptText,
5004
+ sectionText: action.waitFor.sectionText,
5005
+ itemText: action.waitFor.itemText,
5006
+ value: action.waitFor.value,
5007
+ checked: action.waitFor.checked,
5008
+ disabled: action.waitFor.disabled,
5009
+ focused: action.waitFor.focused,
5010
+ selected: action.waitFor.selected,
5011
+ expanded: action.waitFor.expanded,
5012
+ invalid: action.waitFor.invalid,
5013
+ required: action.waitFor.required,
5014
+ busy: action.waitFor.busy,
5015
+ } : undefined;
5016
+ const waitEvidenceRoot = sessionA11y(session) ?? before;
5017
+ const beforeWaitEvidence = waitFilter && waitEvidenceRoot
5018
+ ? captureWaitEvidence(waitEvidenceRoot, waitFilter)
5019
+ : undefined;
4049
5020
  const wait = await sendClick(session, resolved.value.x, resolved.value.y, action.timeoutMs);
5021
+ assertBatchActionConfirmed(action.type, wait);
4050
5022
  const targetSummary = resolved.value.target
4051
5023
  ? `Clicked ${describeFormattedNode(resolved.value.target)} at (${resolved.value.x}, ${resolved.value.y}).`
4052
5024
  : `Clicked at (${resolved.value.x}, ${resolved.value.y}).`;
@@ -4054,33 +5026,19 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4054
5026
  let postWaitCompact;
4055
5027
  if (action.waitFor) {
4056
5028
  const postWait = await waitForSemanticCondition(session, {
4057
- filter: {
4058
- id: action.waitFor.id,
4059
- role: action.waitFor.role,
4060
- name: action.waitFor.name,
4061
- text: action.waitFor.text,
4062
- contextText: action.waitFor.contextText,
4063
- promptText: action.waitFor.promptText,
4064
- sectionText: action.waitFor.sectionText,
4065
- itemText: action.waitFor.itemText,
4066
- value: action.waitFor.value,
4067
- checked: action.waitFor.checked,
4068
- disabled: action.waitFor.disabled,
4069
- focused: action.waitFor.focused,
4070
- selected: action.waitFor.selected,
4071
- expanded: action.waitFor.expanded,
4072
- invalid: action.waitFor.invalid,
4073
- required: action.waitFor.required,
4074
- busy: action.waitFor.busy,
4075
- },
5029
+ filter: waitFilter,
4076
5030
  present: action.waitFor.present ?? true,
4077
5031
  timeoutMs: action.waitFor.timeoutMs ?? 10_000,
4078
5032
  });
4079
5033
  if (!postWait.ok) {
4080
5034
  throw new Error(`Post-click wait failed after ${targetSummary.toLowerCase()}\n${postWait.error}`);
4081
5035
  }
5036
+ const fresh = waitConditionHasFreshEvidence(beforeWaitEvidence, sessionA11y(session), postWait.value);
5037
+ if (!fresh) {
5038
+ throw new Error(`Post-click wait matched only pre-existing evidence after ${targetSummary.toLowerCase()}; the click outcome is unconfirmed.`);
5039
+ }
4082
5040
  postWaitSummary = `Post-click ${waitConditionSuccessLine(postWait.value)}`;
4083
- postWaitCompact = waitConditionCompact(postWait.value);
5041
+ postWaitCompact = { ...waitConditionCompact(postWait.value), fresh: true };
4084
5042
  }
4085
5043
  return {
4086
5044
  summary: [targetSummary, postActionSummary(session, before, wait, detail), postWaitSummary].filter(Boolean).join('\n'),
@@ -4096,6 +5054,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4096
5054
  case 'type': {
4097
5055
  const before = sessionA11y(session);
4098
5056
  const wait = await sendType(session, action.text, action.timeoutMs);
5057
+ assertBatchActionConfirmed(action.type, wait);
4099
5058
  return {
4100
5059
  summary: `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`,
4101
5060
  compact: {
@@ -4107,6 +5066,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4107
5066
  case 'key': {
4108
5067
  const before = sessionA11y(session);
4109
5068
  const wait = await sendKey(session, action.key, { shift: action.shift, ctrl: action.ctrl, meta: action.meta, alt: action.alt }, action.timeoutMs);
5069
+ assertBatchActionConfirmed(action.type, wait);
4110
5070
  return {
4111
5071
  summary: `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`,
4112
5072
  compact: {
@@ -4116,6 +5076,9 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4116
5076
  };
4117
5077
  }
4118
5078
  case 'upload_files': {
5079
+ const contractError = fileUploadContractError(action);
5080
+ if (contractError)
5081
+ throw new Error(contractError);
4119
5082
  const before = sessionA11y(session);
4120
5083
  const wait = await sendFileUpload(session, action.paths, {
4121
5084
  click: action.x !== undefined && action.y !== undefined ? { x: action.x, y: action.y } : undefined,
@@ -4123,48 +5086,63 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4123
5086
  exact: action.exact,
4124
5087
  strategy: action.strategy,
4125
5088
  drop: action.dropX !== undefined && action.dropY !== undefined ? { x: action.dropX, y: action.dropY } : undefined,
5089
+ contextText: action.contextText,
5090
+ sectionText: action.sectionText,
4126
5091
  }, action.timeoutMs ?? 8_000);
5092
+ assertBatchActionConfirmed(action.type, wait);
4127
5093
  return {
4128
5094
  summary: `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`,
4129
5095
  compact: {
4130
5096
  fileCount: action.paths.length,
4131
5097
  ...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
4132
5098
  ...(action.strategy ? { strategy: action.strategy } : {}),
5099
+ ...(action.contextText ? { contextText: action.contextText } : {}),
5100
+ ...(action.sectionText ? { sectionText: action.sectionText } : {}),
4133
5101
  ...waitStatusPayload(wait),
4134
5102
  ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
4135
5103
  },
4136
5104
  };
4137
5105
  }
4138
5106
  case 'pick_listbox_option': {
5107
+ const contractError = listboxPickContractError(action);
5108
+ if (contractError)
5109
+ throw new Error(contractError);
4139
5110
  const before = sessionA11y(session);
4140
5111
  const wait = await sendListboxPick(session, action.label, {
4141
5112
  exact: action.exact,
4142
- open: action.openX !== undefined && action.openY !== undefined ? { x: action.openX, y: action.openY } : undefined,
5113
+ fieldId: action.fieldId,
5114
+ fieldKey: action.fieldKey,
4143
5115
  fieldLabel: action.fieldLabel,
4144
5116
  query: action.query,
4145
5117
  }, action.timeoutMs);
5118
+ assertBatchActionConfirmed(action.type, wait);
4146
5119
  const summary = postActionSummary(session, before, wait, detail);
4147
- const fieldSummary = action.fieldLabel ? summarizeFieldLabelState(session, action.fieldLabel) : undefined;
5120
+ const fieldSummary = action.fieldLabel
5121
+ ? summarizeFieldLabelState(session, action.fieldLabel, action.fieldKey)
5122
+ : undefined;
4148
5123
  return {
4149
5124
  summary: [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n'),
4150
5125
  compact: {
4151
5126
  label: action.label,
5127
+ ...(action.fieldId ? { fieldId: action.fieldId } : {}),
5128
+ ...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
4152
5129
  ...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
4153
5130
  ...waitStatusPayload(wait),
4154
- ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
5131
+ ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel, action.fieldKey) } : {}),
4155
5132
  },
4156
5133
  };
4157
5134
  }
4158
5135
  case 'select_option': {
4159
- if (action.value === undefined && action.label === undefined && action.index === undefined) {
4160
- throw new Error('select_option step requires at least one of value, label, or index');
4161
- }
5136
+ const contractError = selectOptionContractError(action);
5137
+ if (contractError)
5138
+ throw new Error(contractError);
4162
5139
  const before = sessionA11y(session);
4163
5140
  const wait = await sendSelectOption(session, action.x, action.y, {
4164
5141
  value: action.value,
4165
5142
  label: action.label,
4166
5143
  index: action.index,
4167
5144
  }, action.timeoutMs);
5145
+ assertBatchActionConfirmed(action.type, wait);
4168
5146
  return {
4169
5147
  summary: `Selected option.\n${postActionSummary(session, before, wait, detail)}`,
4170
5148
  compact: {
@@ -4182,13 +5160,20 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4182
5160
  checked: action.checked,
4183
5161
  exact: action.exact,
4184
5162
  controlType: action.controlType,
5163
+ ...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
5164
+ contextText: action.contextText,
5165
+ sectionText: action.sectionText,
4185
5166
  }, action.timeoutMs);
5167
+ assertBatchActionConfirmed(action.type, wait);
4186
5168
  return {
4187
5169
  summary: `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
4188
5170
  compact: {
4189
5171
  label: action.label,
4190
5172
  checked: action.checked ?? true,
4191
5173
  ...(action.controlType ? { controlType: action.controlType } : {}),
5174
+ ...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
5175
+ ...(action.contextText ? { contextText: action.contextText } : {}),
5176
+ ...(action.sectionText ? { sectionText: action.sectionText } : {}),
4192
5177
  ...waitStatusPayload(wait),
4193
5178
  },
4194
5179
  };
@@ -4200,6 +5185,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4200
5185
  x: action.x,
4201
5186
  y: action.y,
4202
5187
  }, action.timeoutMs);
5188
+ assertBatchActionConfirmed(action.type, wait);
4203
5189
  return {
4204
5190
  summary: `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`,
4205
5191
  compact: {
@@ -4289,13 +5275,13 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4289
5275
  if (!resolvedFields.ok)
4290
5276
  throw new Error(resolvedFields.error);
4291
5277
  const verifyFillsFn = action.verifyFills
4292
- ? () => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })))
5278
+ ? (readbackSnapshot) => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })), readbackSnapshot)
4293
5279
  : undefined;
4294
5280
  let fallbackFromBatch;
4295
5281
  if (!includeSteps) {
4296
5282
  const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
4297
5283
  if (batched.ok) {
4298
- const verification = verifyFillsFn?.();
5284
+ const verification = verifyFillsFn?.(batched.readbackSnapshot);
4299
5285
  return {
4300
5286
  summary: `Filled ${resolvedFields.fields.length} field(s) in one proxy batch.`,
4301
5287
  compact: {
@@ -4378,7 +5364,7 @@ function suggestRecovery(field, error) {
4378
5364
  }
4379
5365
  }
4380
5366
  if (lowerError.includes('timeout')) {
4381
- return `Action timed out. The page may still be loading. Try geometra_wait_for with a loading indicator, then retry.`;
5367
+ return 'Action timed out and may still complete. Do not retry blindly; inspect the current UI or wait for a loading indicator before repeating the identical action.';
4382
5368
  }
4383
5369
  return undefined;
4384
5370
  }
@@ -4458,8 +5444,8 @@ export function valuesEquivalent(expected, actual) {
4458
5444
  const aNorm = actual.replace(/\s+/g, ' ').trim().toLowerCase();
4459
5445
  return eNorm === aNorm;
4460
5446
  }
4461
- function verifyFormFills(session, planned) {
4462
- const a11y = sessionA11y(session);
5447
+ function verifyFormFills(session, planned, readbackSnapshot) {
5448
+ const a11y = readbackSnapshot ?? sessionA11y(session);
4463
5449
  if (!a11y)
4464
5450
  return { verified: 0, mismatches: [] };
4465
5451
  const mismatches = [];
@@ -4468,13 +5454,27 @@ function verifyFormFills(session, planned) {
4468
5454
  if (p.field.kind === 'toggle' || p.field.kind === 'file')
4469
5455
  continue;
4470
5456
  const label = p.field.fieldLabel;
4471
- const expected = p.field.kind === 'text' ? p.field.value : p.field.value;
4472
- const matches = [
4473
- ...findNodes(a11y, { name: label, role: 'textbox' }),
4474
- ...findNodes(a11y, { name: label, role: 'combobox' }),
4475
- ];
5457
+ const expected = p.field.kind === 'choice'
5458
+ ? p.field.expectedReadback ?? p.field.value
5459
+ : p.field.value;
5460
+ const matches = fieldReadbackNodes(a11y, label, ['textbox', 'combobox'], p.field.fieldKey);
4476
5461
  const match = matches[0];
4477
5462
  const actual = match?.value?.trim();
5463
+ if (p.field.kind === 'choice' && p.field.optionIndex !== undefined && match?.meta?.options) {
5464
+ const selected = match.meta.options.find(option => option.selected);
5465
+ if (selected?.index === p.field.optionIndex && selected.value === p.field.value) {
5466
+ verified++;
5467
+ }
5468
+ else {
5469
+ mismatches.push({
5470
+ fieldLabel: label,
5471
+ expected,
5472
+ actual: selected?.label ?? actual,
5473
+ ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}),
5474
+ });
5475
+ }
5476
+ continue;
5477
+ }
4478
5478
  if (!actual || !expected) {
4479
5479
  mismatches.push({ fieldLabel: label, expected, actual, ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}) });
4480
5480
  }
@@ -4494,10 +5494,12 @@ async function executeFillField(session, field, detail) {
4494
5494
  const wait = await sendFieldText(session, field.fieldLabel, field.value, {
4495
5495
  exact: field.exact,
4496
5496
  fieldId: field.fieldId,
5497
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4497
5498
  typingDelayMs: field.typingDelayMs,
4498
5499
  imeFriendly: field.imeFriendly,
4499
5500
  }, field.timeoutMs);
4500
- const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
5501
+ assertActionWaitConfirmed(`Text fill for "${field.fieldLabel}"`, wait);
5502
+ const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
4501
5503
  return {
4502
5504
  summary: [
4503
5505
  `Filled text field "${field.fieldLabel}".`,
@@ -4506,17 +5508,26 @@ async function executeFillField(session, field, detail) {
4506
5508
  ].filter(Boolean).join('\n'),
4507
5509
  compact: {
4508
5510
  ...(field.fieldId ? { fieldId: field.fieldId } : {}),
5511
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4509
5512
  fieldLabel: field.fieldLabel,
4510
5513
  ...compactTextValue(field.value),
4511
5514
  ...waitStatusPayload(wait),
4512
- readback: fieldStatePayload(session, field.fieldLabel),
5515
+ readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
4513
5516
  },
4514
5517
  };
4515
5518
  }
4516
5519
  case 'choice': {
4517
5520
  const before = sessionA11y(session);
4518
- const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query, choiceType: field.choiceType, fieldId: field.fieldId }, field.timeoutMs);
4519
- const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
5521
+ const wait = await sendFieldChoice(session, field.fieldLabel, field.value, {
5522
+ exact: field.exact,
5523
+ query: field.query,
5524
+ choiceType: field.choiceType,
5525
+ fieldId: field.fieldId,
5526
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
5527
+ ...(field.optionIndex !== undefined ? { optionIndex: field.optionIndex } : {}),
5528
+ }, field.timeoutMs);
5529
+ assertActionWaitConfirmed(`Choice fill for "${field.fieldLabel}"`, wait);
5530
+ const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
4520
5531
  return {
4521
5532
  summary: [
4522
5533
  `Set choice field "${field.fieldLabel}" to "${field.value}".`,
@@ -4525,21 +5536,31 @@ async function executeFillField(session, field, detail) {
4525
5536
  ].filter(Boolean).join('\n'),
4526
5537
  compact: {
4527
5538
  ...(field.fieldId ? { fieldId: field.fieldId } : {}),
5539
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4528
5540
  fieldLabel: field.fieldLabel,
4529
5541
  value: field.value,
5542
+ ...(field.optionIndex !== undefined ? { optionIndex: field.optionIndex } : {}),
4530
5543
  ...(field.choiceType ? { choiceType: field.choiceType } : {}),
4531
5544
  ...waitStatusPayload(wait),
4532
- readback: fieldStatePayload(session, field.fieldLabel),
5545
+ readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
4533
5546
  },
4534
5547
  };
4535
5548
  }
4536
5549
  case 'toggle': {
4537
5550
  const before = sessionA11y(session);
4538
- const wait = await sendSetChecked(session, field.label, { checked: field.checked, exact: field.exact, controlType: field.controlType }, field.timeoutMs);
5551
+ const wait = await sendSetChecked(session, field.label, {
5552
+ checked: field.checked,
5553
+ exact: field.exact,
5554
+ controlType: field.controlType,
5555
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
5556
+ }, field.timeoutMs);
5557
+ assertActionWaitConfirmed(`Toggle fill for "${field.label}"`, wait);
4539
5558
  return {
4540
5559
  summary: `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
4541
5560
  compact: {
4542
5561
  label: field.label,
5562
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
5563
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4543
5564
  checked: field.checked ?? true,
4544
5565
  ...(field.controlType ? { controlType: field.controlType } : {}),
4545
5566
  ...waitStatusPayload(wait),
@@ -4548,8 +5569,14 @@ async function executeFillField(session, field, detail) {
4548
5569
  }
4549
5570
  case 'file': {
4550
5571
  const before = sessionA11y(session);
4551
- const wait = await sendFileUpload(session, field.paths, { fieldLabel: field.fieldLabel, exact: field.exact }, field.timeoutMs ?? 8_000);
4552
- const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
5572
+ const wait = await sendFileUpload(session, field.paths, {
5573
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
5574
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
5575
+ fieldLabel: field.fieldLabel,
5576
+ exact: field.exact,
5577
+ }, field.timeoutMs ?? 8_000);
5578
+ assertActionWaitConfirmed(`File fill for "${field.fieldLabel}"`, wait);
5579
+ const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
4553
5580
  return {
4554
5581
  summary: [
4555
5582
  `Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
@@ -4558,9 +5585,11 @@ async function executeFillField(session, field, detail) {
4558
5585
  ].filter(Boolean).join('\n'),
4559
5586
  compact: {
4560
5587
  fieldLabel: field.fieldLabel,
5588
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
5589
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4561
5590
  fileCount: field.paths.length,
4562
5591
  ...waitStatusPayload(wait),
4563
- readback: fieldStatePayload(session, field.fieldLabel),
5592
+ readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
4564
5593
  },
4565
5594
  };
4566
5595
  }
@@ -4579,27 +5608,29 @@ function recordWorkflowFill(session, formId, formName, valuesById, valuesByLabel
4579
5608
  if (!session.workflowState) {
4580
5609
  session.workflowState = { pages: [], startedAt: Date.now() };
4581
5610
  }
4582
- const filledValues = {};
4583
- for (const [k, v] of Object.entries(valuesById ?? {})) {
4584
- if (typeof v === 'string' || typeof v === 'boolean')
4585
- filledValues[k] = v;
4586
- }
4587
- for (const [k, v] of Object.entries(valuesByLabel ?? {})) {
4588
- if (typeof v === 'string' || typeof v === 'boolean')
4589
- filledValues[k] = v;
4590
- }
5611
+ const fieldIdentities = new Set([
5612
+ ...Object.keys(valuesById ?? {}),
5613
+ ...Object.keys(valuesByLabel ?? {}),
5614
+ ]);
5615
+ const filledFields = Array.from(fieldIdentities).sort();
5616
+ const filledValues = Object.fromEntries(filledFields.map(identity => [identity, '[REDACTED]']));
4591
5617
  const a11y = sessionA11y(session);
4592
- const pageUrl = a11y?.meta?.pageUrl ?? session.url;
5618
+ const pageUrl = workflowUrlOrigin(a11y?.meta?.pageUrl ?? session.url);
4593
5619
  session.workflowState.pages.push({
4594
5620
  pageUrl,
4595
5621
  formId,
4596
5622
  formName,
4597
5623
  filledValues,
5624
+ filledFields,
5625
+ valuesRedacted: true,
4598
5626
  filledAt: Date.now(),
4599
5627
  fieldCount,
4600
5628
  invalidCount,
4601
5629
  });
4602
5630
  }
5631
+ function workflowUrlOrigin(rawUrl) {
5632
+ return sanitizeUrlToOrigin(rawUrl) ?? REDACTED_STATE_URL;
5633
+ }
4603
5634
  async function captureScreenshotBase64(session) {
4604
5635
  try {
4605
5636
  const wait = await sendScreenshot(session);
@@ -4726,8 +5757,8 @@ export function findNodes(node, filter) {
4726
5757
  walk(node);
4727
5758
  return matches;
4728
5759
  }
4729
- function summarizeFieldLabelState(session, fieldLabel) {
4730
- const payload = fieldStatePayload(session, fieldLabel);
5760
+ function summarizeFieldLabelState(session, fieldLabel, fieldKey) {
5761
+ const payload = fieldStatePayload(session, fieldLabel, fieldKey);
4731
5762
  if (!payload)
4732
5763
  return undefined;
4733
5764
  const parts = [`Field "${fieldLabel}"`];