@syrin/iris 0.5.0 → 0.6.10

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/test.js CHANGED
@@ -201,6 +201,30 @@ import { join as join6 } from "path";
201
201
  // ../protocol/dist/constants.js
202
202
  var IRIS_DEFAULT_PORT = 4400;
203
203
  var IRIS_WS_PATH = "/iris";
204
+ var IRIS_PROTOCOL_VERSION = 1;
205
+ var TRANSPORT_LIMITS = {
206
+ MAX_MESSAGE_BYTES: 1024 * 1024,
207
+ MAX_MESSAGES_PER_SECOND: 1e3,
208
+ MAX_SESSIONS: 32,
209
+ MAX_PENDING_CONNECTIONS: 16,
210
+ HELLO_TIMEOUT_MS: 5e3,
211
+ MAX_BUFFER_BYTES: 8 * 1024 * 1024,
212
+ MAX_SESSION_ID_LENGTH: 128,
213
+ MAX_URL_LENGTH: 4096,
214
+ MAX_TITLE_LENGTH: 512,
215
+ MAX_ADAPTERS: 32,
216
+ MAX_ADAPTER_NAME_LENGTH: 128,
217
+ MAX_TOKEN_LENGTH: 512,
218
+ MAX_COMMAND_ID_LENGTH: 128,
219
+ MAX_COMMAND_NAME_LENGTH: 128,
220
+ MAX_REF_LENGTH: 128,
221
+ MAX_ERROR_LENGTH: 4096,
222
+ MAX_SERIALIZE_DEPTH: 8,
223
+ MAX_COLLECTION_ITEMS: 200,
224
+ MAX_OBJECT_KEYS: 200,
225
+ MAX_STRING_LENGTH: 64 * 1024
226
+ };
227
+ var DANGEROUS_ACTION_CONFIRM_ARG = "confirmDangerous";
204
228
  var REPLAY_PROGRAM_VERSION = 1;
205
229
  var IrisDir = {
206
230
  ROOT: ".iris",
@@ -302,7 +326,7 @@ var ReplayStatus = {
302
326
  DRIFT: "drift",
303
327
  // an anchor missed (testid renamed / signal not observed) — legible drift returned
304
328
  ERROR: "error"
305
- // the flow file could not be loaded (missing/invalid) no steps ran
329
+ // the flow could not load or a resolved action failed
306
330
  };
307
331
  var DriftReason = {
308
332
  TESTID_NOT_FOUND: "testid_not_found",
@@ -336,8 +360,10 @@ var HealStatus = {
336
360
  // drift exists but no proposal cleared the confidence floor
337
361
  NOTHING_TO_HEAL: "nothing_to_heal",
338
362
  // replay was green
363
+ CONSEQUENCE_BROKEN: "consequence_broken",
364
+ // rebind resolves a locator but the flow's success consequence no longer fires — REFUSED (file untouched)
339
365
  ERROR: "error"
340
- // flow missing/malformed/invalid-name no steps ran
366
+ // flow missing/malformed/invalid-name, or a resolved action failed
341
367
  };
342
368
  var HEAL_CONFIDENCE_MIN = 0.5;
343
369
  var AnnotationTarget = {
@@ -353,7 +379,8 @@ var AnnotationErrorCode = {
353
379
  var COMPILED_PREDICATE_PREFIX = "will";
354
380
  var RING_BUFFER_DEFAULTS = {
355
381
  MAX_EVENTS: 2e3,
356
- MAX_AGE_MS: 6e4
382
+ MAX_AGE_MS: 6e4,
383
+ MAX_BYTES: TRANSPORT_LIMITS.MAX_BUFFER_BYTES
357
384
  };
358
385
  var EventType = {
359
386
  DOM_ADDED: "dom.added",
@@ -538,6 +565,8 @@ var MessageKind = {
538
565
 
539
566
  // ../protocol/dist/messages.js
540
567
  import { z } from "zod";
568
+ var sessionIdSchema = z.string().min(1).max(TRANSPORT_LIMITS.MAX_SESSION_ID_LENGTH);
569
+ var refSchema = z.string().max(TRANSPORT_LIMITS.MAX_REF_LENGTH);
541
570
  var HumanControlDataSchema = z.object({
542
571
  kind: z.nativeEnum(HumanControlKind),
543
572
  text: z.string().optional()
@@ -545,35 +574,37 @@ var HumanControlDataSchema = z.object({
545
574
  var IrisEventSchema = z.object({
546
575
  t: z.number(),
547
576
  type: z.nativeEnum(EventType),
548
- sessionId: z.string(),
577
+ sessionId: sessionIdSchema,
549
578
  /** Stable element reference this event concerns, when applicable (e.g. "e7"). */
550
- ref: z.string().optional(),
579
+ ref: refSchema.optional(),
551
580
  /** Event-type-specific payload. Kept open here; refined per observer at the edges. */
552
581
  data: z.record(z.unknown()).default({})
553
582
  });
554
583
  var HelloMessageSchema = z.object({
555
584
  kind: z.literal(MessageKind.HELLO),
556
- protocolVersion: z.number(),
557
- sessionId: z.string(),
558
- url: z.string(),
559
- title: z.string(),
560
- adapters: z.array(z.string()),
585
+ protocolVersion: z.literal(IRIS_PROTOCOL_VERSION),
586
+ sessionId: sessionIdSchema,
587
+ url: z.string().max(TRANSPORT_LIMITS.MAX_URL_LENGTH),
588
+ title: z.string().max(TRANSPORT_LIMITS.MAX_TITLE_LENGTH),
589
+ adapters: z.array(z.string().max(TRANSPORT_LIMITS.MAX_ADAPTER_NAME_LENGTH)).max(TRANSPORT_LIMITS.MAX_ADAPTERS),
590
+ /** Optional browser/bridge pairing token. Required when the bridge configures one. */
591
+ token: z.string().max(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH).optional(),
561
592
  /** Whether the app has advertised a capability registry (iris.describe). */
562
593
  hasCapabilities: z.boolean().optional()
563
594
  });
564
595
  var CommandMessageSchema = z.object({
565
596
  kind: z.literal(MessageKind.COMMAND),
566
- id: z.string(),
567
- sessionId: z.string().optional(),
568
- name: z.string(),
597
+ id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH),
598
+ sessionId: sessionIdSchema.optional(),
599
+ name: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_NAME_LENGTH),
569
600
  args: z.record(z.unknown()).default({})
570
601
  });
571
602
  var CommandResultSchema = z.object({
572
603
  kind: z.literal(MessageKind.COMMAND_RESULT),
573
- id: z.string(),
604
+ id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH),
574
605
  ok: z.boolean(),
575
606
  result: z.unknown().optional(),
576
- error: z.string().optional()
607
+ error: z.string().max(TRANSPORT_LIMITS.MAX_ERROR_LENGTH).optional()
577
608
  });
578
609
  var EventMessageSchema = z.object({
579
610
  kind: z.literal(MessageKind.EVENT),
@@ -586,6 +617,25 @@ var IrisMessageSchema = z.discriminatedUnion("kind", [
586
617
  EventMessageSchema
587
618
  ]);
588
619
 
620
+ // ../protocol/dist/security.js
621
+ var DANGEROUS_ACTION = /\b(delete|remove|destroy|erase|drop|terminate|revoke|reset|logout|log out|sign out|close account|cancel subscription|purchase|buy|pay|place order|confirm order|deploy|publish|send|transfer|withdraw|refund)\b/i;
622
+ function isLoopbackHostname(hostname) {
623
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
624
+ if (normalized === "localhost" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") {
625
+ return true;
626
+ }
627
+ const octets = normalized.split(".");
628
+ return octets.length === 4 && octets[0] === "127" && octets.every((octet) => {
629
+ if (!/^\d{1,3}$/.test(octet))
630
+ return false;
631
+ const value = Number(octet);
632
+ return value >= 0 && value <= 255;
633
+ });
634
+ }
635
+ function isDangerousActionText(text) {
636
+ return DANGEROUS_ACTION.test(text.replace(/[_-]+/g, " "));
637
+ }
638
+
589
639
  // ../protocol/dist/toon.js
590
640
  var ROLE_MAP = {
591
641
  button: "btn",
@@ -862,6 +912,7 @@ function log(event, fields = {}) {
862
912
  }
863
913
 
864
914
  // ../server/dist/bridge.js
915
+ import { timingSafeEqual } from "crypto";
865
916
  import * as http2 from "http";
866
917
  import { WebSocketServer } from "ws";
867
918
 
@@ -869,14 +920,21 @@ import { WebSocketServer } from "ws";
869
920
  var RingBuffer = class {
870
921
  #maxEvents;
871
922
  #maxAgeMs;
923
+ #maxBytes;
872
924
  #events = [];
925
+ #eventBytes = [];
926
+ #totalBytes = 0;
873
927
  #droppedCount = 0;
874
928
  constructor(options = {}) {
875
929
  this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
876
930
  this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
931
+ this.#maxBytes = options.maxBytes ?? RING_BUFFER_DEFAULTS.MAX_BYTES;
877
932
  }
878
933
  push(event, now) {
879
934
  this.#events.push(event);
935
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8");
936
+ this.#eventBytes.push(bytes);
937
+ this.#totalBytes += bytes;
880
938
  this.#evict(now);
881
939
  }
882
940
  /** Events at or after a given timestamp cursor. */
@@ -902,10 +960,14 @@ var RingBuffer = class {
902
960
  #evict(now) {
903
961
  const before = this.#events.length;
904
962
  const cutoff = now - this.#maxAgeMs;
905
- if (this.#events.length > this.#maxEvents) {
906
- this.#events = this.#events.slice(this.#events.length - this.#maxEvents);
963
+ while (this.#events.length > this.#maxEvents || this.#totalBytes > this.#maxBytes && this.#events.length > 0) {
964
+ this.#events.shift();
965
+ this.#totalBytes -= this.#eventBytes.shift() ?? 0;
966
+ }
967
+ while ((this.#events[0]?.t ?? cutoff) < cutoff) {
968
+ this.#events.shift();
969
+ this.#totalBytes -= this.#eventBytes.shift() ?? 0;
907
970
  }
908
- this.#events = this.#events.filter((e) => e.t >= cutoff);
909
971
  this.#droppedCount += before - this.#events.length;
910
972
  }
911
973
  /** Snapshot of buffer health for the agent — total events held and cumulative drops since connect. */
@@ -1142,6 +1204,14 @@ var Session = class {
1142
1204
  this.#pending.delete(id);
1143
1205
  }
1144
1206
  }
1207
+ /** End this transport without letting a stale socket remove its replacement session. */
1208
+ disconnect(reason) {
1209
+ this.rejectAll(reason);
1210
+ try {
1211
+ this.#socket.close(1008, reason);
1212
+ } catch {
1213
+ }
1214
+ }
1145
1215
  // ── Live-control: state machine + human→agent inbox (server-owned) ───────────────
1146
1216
  getState() {
1147
1217
  return this.#state;
@@ -1263,11 +1333,15 @@ var Session = class {
1263
1333
  var SessionManager = class {
1264
1334
  #sessions = /* @__PURE__ */ new Map();
1265
1335
  add(session) {
1336
+ const previous = this.#sessions.get(session.id);
1266
1337
  this.#sessions.set(session.id, session);
1338
+ return previous;
1267
1339
  }
1268
- remove(sessionId) {
1269
- this.#sessions.get(sessionId)?.rejectAll("session disconnected");
1270
- this.#sessions.delete(sessionId);
1340
+ remove(session) {
1341
+ if (this.#sessions.get(session.id) !== session)
1342
+ return false;
1343
+ session.rejectAll("session disconnected");
1344
+ return this.#sessions.delete(session.id);
1271
1345
  }
1272
1346
  get(sessionId) {
1273
1347
  return this.#sessions.get(sessionId);
@@ -1335,6 +1409,20 @@ var SessionManager = class {
1335
1409
  };
1336
1410
 
1337
1411
  // ../server/dist/bridge.js
1412
+ function normalizeOrigin(origin) {
1413
+ try {
1414
+ return new URL(origin).origin;
1415
+ } catch {
1416
+ return null;
1417
+ }
1418
+ }
1419
+ function tokensMatch(expected, received) {
1420
+ if (received === void 0)
1421
+ return false;
1422
+ const expectedBytes = Buffer.from(expected);
1423
+ const receivedBytes = Buffer.from(received);
1424
+ return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
1425
+ }
1338
1426
  function rawToString(raw) {
1339
1427
  if (typeof raw === "string")
1340
1428
  return raw;
@@ -1350,11 +1438,41 @@ var Bridge = class {
1350
1438
  ready;
1351
1439
  #wss;
1352
1440
  #clock;
1441
+ #token;
1442
+ #allowedOrigins;
1443
+ #maxMessagesPerSecond;
1444
+ #maxSessions;
1445
+ #maxPendingConnections;
1446
+ #helloTimeoutMs;
1447
+ #pendingConnections = 0;
1353
1448
  constructor(options) {
1449
+ const host = options.host ?? "127.0.0.1";
1450
+ if ((options.token?.length ?? 0) > TRANSPORT_LIMITS.MAX_TOKEN_LENGTH) {
1451
+ throw new Error(`Iris pairing token exceeds ${String(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH)} characters`);
1452
+ }
1453
+ if (!isLoopbackHostname(host) && (options.token === void 0 || options.token.length === 0)) {
1454
+ throw new Error("a pairing token is required when the Iris bridge binds beyond localhost");
1455
+ }
1354
1456
  this.#clock = options.clock ?? (() => Date.now());
1457
+ this.#token = options.token !== void 0 && options.token.length > 0 ? options.token : void 0;
1458
+ this.#allowedOrigins = new Set((options.allowedOrigins ?? []).map(normalizeOrigin).filter((origin) => origin !== null));
1459
+ this.#maxMessagesPerSecond = options.maxMessagesPerSecond ?? TRANSPORT_LIMITS.MAX_MESSAGES_PER_SECOND;
1460
+ this.#maxSessions = options.maxSessions ?? TRANSPORT_LIMITS.MAX_SESSIONS;
1461
+ this.#maxPendingConnections = options.maxPendingConnections ?? TRANSPORT_LIMITS.MAX_PENDING_CONNECTIONS;
1462
+ this.#helloTimeoutMs = options.helloTimeoutMs ?? TRANSPORT_LIMITS.HELLO_TIMEOUT_MS;
1355
1463
  if (options.server !== void 0) {
1356
1464
  const srv = options.server;
1357
- this.#wss = new WebSocketServer({ server: srv, path: IRIS_WS_PATH });
1465
+ this.#wss = new WebSocketServer({
1466
+ server: srv,
1467
+ path: IRIS_WS_PATH,
1468
+ maxPayload: TRANSPORT_LIMITS.MAX_MESSAGE_BYTES,
1469
+ verifyClient: ({ origin }, done) => {
1470
+ const allowed = this.#originAllowed(origin);
1471
+ if (!allowed)
1472
+ log("origin_rejected", { origin: origin ?? "missing" });
1473
+ done(allowed, 403, "Forbidden");
1474
+ }
1475
+ });
1358
1476
  this.ready = new Promise((resolve) => {
1359
1477
  if (srv.listening) {
1360
1478
  resolve(srv.address().port);
@@ -1367,8 +1485,15 @@ var Bridge = class {
1367
1485
  } else {
1368
1486
  this.#wss = new WebSocketServer({
1369
1487
  port: options.port,
1370
- host: options.host ?? "127.0.0.1",
1371
- path: IRIS_WS_PATH
1488
+ host,
1489
+ path: IRIS_WS_PATH,
1490
+ maxPayload: TRANSPORT_LIMITS.MAX_MESSAGE_BYTES,
1491
+ verifyClient: ({ origin }, done) => {
1492
+ const allowed = this.#originAllowed(origin);
1493
+ if (!allowed)
1494
+ log("origin_rejected", { origin: origin ?? "missing" });
1495
+ done(allowed, 403, "Forbidden");
1496
+ }
1372
1497
  });
1373
1498
  this.ready = new Promise((resolve) => {
1374
1499
  this.#wss.on("listening", () => {
@@ -1381,14 +1506,64 @@ var Bridge = class {
1381
1506
  });
1382
1507
  }
1383
1508
  #onConnection(socket) {
1509
+ if (this.#pendingConnections >= this.#maxPendingConnections) {
1510
+ socket.close(1013, "too many pending handshakes");
1511
+ return;
1512
+ }
1513
+ this.#pendingConnections += 1;
1514
+ let awaitingHello = true;
1384
1515
  let session;
1516
+ let messageWindowStartedAt = this.#clock();
1517
+ let messagesInWindow = 0;
1518
+ const releasePending = () => {
1519
+ if (!awaitingHello)
1520
+ return;
1521
+ awaitingHello = false;
1522
+ this.#pendingConnections -= 1;
1523
+ };
1524
+ const helloTimer = setTimeout(() => {
1525
+ if (!awaitingHello)
1526
+ return;
1527
+ releasePending();
1528
+ socket.close(1008, "hello timeout");
1529
+ }, this.#helloTimeoutMs);
1385
1530
  socket.on("message", (raw) => {
1531
+ const now = this.#clock();
1532
+ if (now - messageWindowStartedAt >= 1e3) {
1533
+ messageWindowStartedAt = now;
1534
+ messagesInWindow = 0;
1535
+ }
1536
+ messagesInWindow += 1;
1537
+ if (messagesInWindow > this.#maxMessagesPerSecond) {
1538
+ log("message_rate_exceeded", {});
1539
+ socket.close(1008, "message rate exceeded");
1540
+ return;
1541
+ }
1386
1542
  const parsed = this.#parse(rawToString(raw));
1387
- if (parsed === null)
1543
+ if (parsed === null) {
1544
+ socket.close(1008, "invalid message");
1388
1545
  return;
1546
+ }
1389
1547
  if (parsed.kind === MessageKind.HELLO) {
1548
+ if (session !== void 0) {
1549
+ socket.close(1008, "hello already received");
1550
+ return;
1551
+ }
1552
+ if (this.#token !== void 0 && !tokensMatch(this.#token, parsed.token)) {
1553
+ log("authentication_failed", {});
1554
+ socket.close(1008, "authentication failed");
1555
+ return;
1556
+ }
1557
+ const existing = this.sessions.get(parsed.sessionId);
1558
+ if (existing === void 0 && this.sessions.count() >= this.#maxSessions) {
1559
+ socket.close(1013, "session limit reached");
1560
+ return;
1561
+ }
1562
+ clearTimeout(helloTimer);
1563
+ releasePending();
1390
1564
  session = new Session(parsed, socket, this.#clock);
1391
- this.sessions.add(session);
1565
+ const replaced = this.sessions.add(session);
1566
+ replaced?.disconnect("session replaced by a newer connection");
1392
1567
  log("session_connected", { sessionId: session.id, url: session.url });
1393
1568
  return;
1394
1569
  }
@@ -1402,15 +1577,28 @@ var Bridge = class {
1402
1577
  }
1403
1578
  });
1404
1579
  socket.on("close", () => {
1580
+ clearTimeout(helloTimer);
1581
+ releasePending();
1405
1582
  if (session !== void 0) {
1406
- this.sessions.remove(session.id);
1407
- log("session_disconnected", { sessionId: session.id });
1583
+ if (this.sessions.remove(session)) {
1584
+ log("session_disconnected", { sessionId: session.id });
1585
+ }
1408
1586
  }
1409
1587
  });
1410
1588
  socket.on("error", (err) => {
1411
1589
  log("socket_error", { error: err.message });
1412
1590
  });
1413
1591
  }
1592
+ #originAllowed(origin) {
1593
+ if (origin === void 0)
1594
+ return true;
1595
+ const normalized = normalizeOrigin(origin);
1596
+ if (normalized === null)
1597
+ return false;
1598
+ if (this.#allowedOrigins.has(normalized))
1599
+ return true;
1600
+ return isLoopbackHostname(new URL(normalized).hostname);
1601
+ }
1414
1602
  #parse(text) {
1415
1603
  let json;
1416
1604
  try {
@@ -1427,6 +1615,8 @@ var Bridge = class {
1427
1615
  }
1428
1616
  close() {
1429
1617
  return new Promise((resolve) => {
1618
+ for (const client of this.#wss.clients)
1619
+ client.terminate();
1430
1620
  this.#wss.close(() => {
1431
1621
  resolve();
1432
1622
  });
@@ -1541,6 +1731,7 @@ var IrisTool = {
1541
1731
  STATE: "iris_state",
1542
1732
  CAPABILITIES: "iris_capabilities",
1543
1733
  CONTRACT_SAVE: "iris_contract_save",
1734
+ DOMAIN: "iris_domain",
1544
1735
  FLOW_SAVE: "iris_flow_save",
1545
1736
  FLOW_LIST: "iris_flow_list",
1546
1737
  FLOW_LOAD: "iris_flow_load",
@@ -1583,189 +1774,558 @@ var IrisTool = {
1583
1774
  ROLLBACK: "iris_rollback"
1584
1775
  };
1585
1776
 
1586
- // ../server/dist/project/iris-dir.js
1587
- import { join } from "path";
1588
- function irisDirPaths(root) {
1589
- return {
1590
- root,
1591
- contract: join(root, IrisDir.CONTRACT_FILE),
1592
- flows: join(root, IrisDir.FLOWS_SUBDIR),
1593
- baselines: join(root, IrisDir.BASELINES_SUBDIR),
1594
- project: join(root, IrisDir.PROJECT_FILE),
1595
- visual: join(root, IrisDir.VISUAL_SUBDIR)
1596
- };
1597
- }
1598
- function visualPath(root, name) {
1599
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.png`);
1600
- }
1601
- function visualDiffPath(root, name) {
1602
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.diff.png`);
1603
- }
1604
- function flowPath(root, name) {
1605
- return join(root, IrisDir.FLOWS_SUBDIR, `${name}.json`);
1606
- }
1607
- function isValidFlowName(name) {
1608
- return FLOW_NAME_PATTERN.test(name) && !name.includes("..");
1609
- }
1610
- async function ensureIrisDir(fs2, root) {
1611
- const p = irisDirPaths(root);
1612
- await fs2.mkdir(p.root);
1613
- await fs2.mkdir(p.flows);
1614
- await fs2.mkdir(p.baselines);
1615
- }
1616
- var JSON_INDENT = 2;
1617
- function stableSerialize(capabilities, generatedAt) {
1618
- const envelope = {
1619
- version: CONTRACT_FILE_VERSION,
1620
- generatedAt,
1621
- capabilities: {
1622
- testids: [...capabilities.testids].sort(),
1623
- signals: [...capabilities.signals].sort(),
1624
- stores: [...capabilities.stores].sort(),
1625
- flows: [...capabilities.flows].map((f) => ({ name: f.name, steps: [...f.steps] })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
1777
+ // ../server/dist/tools/tools-helpers.js
1778
+ function parseInteractive(tree) {
1779
+ const items = [];
1780
+ for (const line of tree.split("\n")) {
1781
+ const match = /\(ref=(e\d+)\)/.exec(line);
1782
+ if (match !== null) {
1783
+ items.push({ ref: match[1] ?? "", desc: line.replace(/\s*\(ref=e\d+\)/, "").trim() });
1626
1784
  }
1627
- };
1628
- return `${JSON.stringify(envelope, null, JSON_INDENT)}
1629
- `;
1630
- }
1631
- async function writeContract(fs2, root, capabilities, now) {
1632
- await ensureIrisDir(fs2, root);
1633
- await fs2.writeFile(irisDirPaths(root).contract, stableSerialize(capabilities, now()));
1634
- }
1635
- async function readContract(fs2, root) {
1636
- const path = irisDirPaths(root).contract;
1637
- if (!await fs2.exists(path))
1638
- return { ok: false, reason: ContractReadError.MISSING };
1639
- let text;
1640
- try {
1641
- text = await fs2.readFile(path);
1642
- } catch (error) {
1643
- return {
1644
- ok: false,
1645
- reason: fs2.isNotFound(error) ? ContractReadError.MISSING : ContractReadError.MALFORMED
1646
- };
1647
- }
1648
- let parsed;
1649
- try {
1650
- parsed = JSON.parse(text);
1651
- } catch {
1652
- return { ok: false, reason: ContractReadError.MALFORMED };
1653
1785
  }
1654
- const result = ContractFileSchema.safeParse(parsed);
1655
- if (!result.success)
1656
- return { ok: false, reason: ContractReadError.MALFORMED };
1657
- return { ok: true, capabilities: result.data.capabilities, generatedAt: result.data.generatedAt };
1786
+ return items;
1658
1787
  }
1659
-
1660
- // ../server/dist/flows/flows.js
1661
1788
  function asString(value) {
1662
1789
  return typeof value === "string" ? value : void 0;
1663
1790
  }
1791
+ function asNumber(value) {
1792
+ return typeof value === "number" ? value : void 0;
1793
+ }
1664
1794
  function asRecord(value) {
1665
1795
  return typeof value === "object" && value !== null ? value : {};
1666
1796
  }
1667
- function degradedAnchor() {
1668
- return { kind: AnchorKind.ROLE, role: DEGRADED_ANCHOR_ROLE };
1797
+
1798
+ // ../server/dist/flows/replay.js
1799
+ function asString2(value) {
1800
+ return typeof value === "string" ? value : void 0;
1669
1801
  }
1670
- function subStepToFlowStep(raw) {
1671
- const sub = asRecord(raw);
1672
- const by = asString(sub["by"]);
1673
- const value = asString(sub["value"]);
1674
- const action = asString(sub["action"]);
1675
- const args = asRecord(sub["args"]);
1676
- if (by === QueryBy.TESTID && value !== void 0) {
1677
- return buildStep(IrisTool.ACT, { kind: AnchorKind.TESTID, value }, action, args, false);
1678
- }
1679
- return buildStep(IrisTool.ACT, degradedAnchor(), action, args, true);
1802
+ function asRecord2(value) {
1803
+ return typeof value === "object" && value !== null ? value : {};
1680
1804
  }
1681
- function buildStep(tool, anchor, action, args, degraded) {
1682
- const step = { tool, anchor, args };
1683
- if (action !== void 0)
1684
- step.action = action;
1685
- if (degraded)
1686
- step.degraded = true;
1687
- return step;
1805
+ function replayActionArgs(value, confirmDangerous = false) {
1806
+ const args = { ...asRecord2(value) };
1807
+ delete args[DANGEROUS_ACTION_CONFIRM_ARG];
1808
+ if (confirmDangerous)
1809
+ args[DANGEROUS_ACTION_CONFIRM_ARG] = true;
1810
+ return args;
1688
1811
  }
1689
- function recordedStepToFlowStep(step) {
1690
- if (step.tool === IrisTool.ACT_SEQUENCE) {
1691
- const rawSubs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
1692
- const subs = rawSubs.map(subStepToFlowStep);
1693
- const degraded = subs.some((s) => s.degraded === true);
1694
- const anchor = subs[0]?.anchor ?? degradedAnchor();
1695
- const out2 = { tool: IrisTool.ACT_SEQUENCE, anchor, steps: subs };
1696
- if (degraded)
1697
- out2.degraded = true;
1698
- if (step.expect !== void 0)
1699
- out2.expect = step.expect;
1700
- return out2;
1812
+ function compileActStep(args, res) {
1813
+ const testid = asString2(asRecord2(res)["testid"]);
1814
+ const action = asString2(args["action"]) ?? "";
1815
+ const actArgs = replayActionArgs(args["args"]);
1816
+ if (testid !== void 0) {
1817
+ return {
1818
+ tool: IrisTool.ACT,
1819
+ stable: true,
1820
+ args: { by: QueryBy.TESTID, value: testid, action, args: actArgs }
1821
+ };
1701
1822
  }
1702
- const by = asString(step.args["by"]);
1703
- const value = asString(step.args["value"]);
1704
- const action = asString(step.args["action"]);
1705
- const args = asRecord(step.args["args"]);
1706
- const out = by === QueryBy.TESTID && value !== void 0 ? buildStep(step.tool, { kind: AnchorKind.TESTID, value }, action, args, false) : buildStep(step.tool, degradedAnchor(), action, args, true);
1707
- if (step.expect !== void 0)
1708
- out.expect = step.expect;
1709
- return out;
1823
+ return {
1824
+ tool: IrisTool.ACT,
1825
+ stable: false,
1826
+ args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
1827
+ };
1710
1828
  }
1711
- function withAnnotations(flow, ann) {
1712
- if (ann === void 0)
1713
- return flow;
1714
- const steps = flow.steps.map((step, i) => {
1715
- const expect = ann.stepExpect.get(i);
1716
- return expect === void 0 ? step : { ...step, expect };
1829
+ function compileSequenceStep(args, res) {
1830
+ const inputSteps = Array.isArray(args["steps"]) ? args["steps"] : [];
1831
+ const resolved = Array.isArray(asRecord2(res)["steps"]) ? asRecord2(res)["steps"] : [];
1832
+ let stable = inputSteps.length > 0;
1833
+ const subSteps = inputSteps.map((raw, i) => {
1834
+ const step = asRecord2(raw);
1835
+ const action = asString2(step["action"]) ?? "";
1836
+ const stepArgs = replayActionArgs(step["args"]);
1837
+ const testid = asString2(asRecord2(resolved[i])["testid"]);
1838
+ if (testid !== void 0) {
1839
+ return { by: QueryBy.TESTID, value: testid, action, args: stepArgs };
1840
+ }
1841
+ stable = false;
1842
+ return { ref: asString2(step["ref"]) ?? "", action, args: stepArgs };
1717
1843
  });
1718
- const out = { ...flow, steps };
1719
- if (ann.dynamic.length > 0) {
1720
- out.dynamic = ann.dynamic.map((value) => ({ kind: AnchorKind.TESTID, value }));
1721
- }
1722
- if (ann.success !== void 0)
1723
- out.success = ann.success;
1724
- return out;
1844
+ return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
1725
1845
  }
1726
- var JSON_INDENT2 = 2;
1727
- var FLOW_SUFFIX = ".json";
1728
- var FlowStore = class {
1729
- #fs;
1730
- #root;
1731
- #clock;
1732
- constructor(fs2, root, clock) {
1733
- this.#fs = fs2;
1734
- this.#root = root;
1735
- this.#clock = clock;
1736
- }
1737
- /**
1738
- * The single byte-stable flow serializer: 2-space indent + one trailing newline. save(),
1739
- * saveFlow() and heal() all route through it so an unchanged flow that round-trips through any
1740
- * of them produces byte-identical on-disk content (locked by the byte-stability tests).
1741
- */
1742
- #serialize(flow) {
1743
- return `${JSON.stringify(flow, null, JSON_INDENT2)}
1744
- `;
1846
+ async function resolveRef(session, step) {
1847
+ const by = asString2(step.by);
1848
+ const value = asString2(step.value);
1849
+ if (by === QueryBy.TESTID && value !== void 0) {
1850
+ const result = await session.command(IrisCommand.QUERY, { by, value });
1851
+ if (!result.ok)
1852
+ throw new Error(result.error ?? "query failed");
1853
+ const elements = Array.isArray(asRecord2(result.result)["elements"]) ? asRecord2(result.result)["elements"] : [];
1854
+ const ref2 = asString2(asRecord2(elements[0])["ref"]);
1855
+ if (ref2 === void 0)
1856
+ throw new Error(`testid '${value}' did not resolve in current page`);
1857
+ return elements.length > 1 ? { ref: ref2, note: `ambiguous testid '${value}', used first match` } : { ref: ref2 };
1745
1858
  }
1746
- /**
1747
- * Convert a CompiledProgram (testid-normalized) into an anchored, on-disk flow + write it.
1748
- * Optionally fold structured annotations (per-step expect, dynamic[], success) onto
1749
- * the flow before writing. Omitting `annotations` reproduces the same bytes.
1750
- */
1751
- async save(program, annotations) {
1752
- if (!isValidFlowName(program.name)) {
1753
- return { ok: false, code: FlowErrorCode.INVALID_NAME };
1754
- }
1755
- const steps = program.steps.map(recordedStepToFlowStep);
1756
- const base = {
1757
- version: FLOW_FILE_VERSION,
1758
- name: program.name,
1759
- createdAt: this.#clock.now(),
1760
- steps
1761
- };
1762
- const flow = withAnnotations(base, annotations);
1763
- await this.#fs.mkdir(irisDirPaths(this.#root).flows);
1764
- await this.#fs.writeFile(flowPath(this.#root, program.name), this.#serialize(flow));
1765
- const degraded = flow.steps.filter((s) => s.degraded === true).length;
1766
- return {
1767
- ok: true,
1768
- value: {
1859
+ const ref = asString2(step.ref);
1860
+ if (ref === void 0 || ref.length === 0)
1861
+ throw new Error("step has no testid or ref to resolve");
1862
+ return { ref, note: "replayed by stale ref (not portable across sessions)" };
1863
+ }
1864
+ async function replayProgram(session, program, confirmDangerous = false) {
1865
+ const results = [];
1866
+ for (const step of program.steps) {
1867
+ try {
1868
+ if (step.tool === IrisTool.ACT_SEQUENCE) {
1869
+ const subs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
1870
+ const notes = [];
1871
+ const liveSteps = [];
1872
+ for (const raw of subs) {
1873
+ const sub = asRecord2(raw);
1874
+ const { ref, note } = await resolveRef(session, sub);
1875
+ if (note !== void 0)
1876
+ notes.push(note);
1877
+ liveSteps.push({
1878
+ ref,
1879
+ action: asString2(sub["action"]) ?? "",
1880
+ args: replayActionArgs(sub["args"], confirmDangerous)
1881
+ });
1882
+ }
1883
+ const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps });
1884
+ results.push(buildResult(step.tool, r.ok, r.error, notes));
1885
+ if (!r.ok)
1886
+ break;
1887
+ } else {
1888
+ const { ref, note } = await resolveRef(session, step.args);
1889
+ const r = await session.command(IrisCommand.ACT, {
1890
+ ref,
1891
+ action: asString2(step.args["action"]) ?? "",
1892
+ args: replayActionArgs(step.args["args"], confirmDangerous)
1893
+ });
1894
+ results.push(buildResult(step.tool, r.ok, r.error, note !== void 0 ? [note] : []));
1895
+ if (!r.ok)
1896
+ break;
1897
+ }
1898
+ } catch (e) {
1899
+ results.push({
1900
+ tool: step.tool,
1901
+ ok: false,
1902
+ error: e instanceof Error ? e.message : String(e)
1903
+ });
1904
+ break;
1905
+ }
1906
+ }
1907
+ return results;
1908
+ }
1909
+ function buildResult(tool, ok, error, notes) {
1910
+ const base = { tool, ok };
1911
+ if (!ok)
1912
+ base.error = error ?? "command failed";
1913
+ if (notes.length > 0)
1914
+ base.note = notes.join("; ");
1915
+ return base;
1916
+ }
1917
+
1918
+ // ../server/dist/flows/flow-replay.js
1919
+ function editDistance(a, b) {
1920
+ const s = a.toLowerCase();
1921
+ const t = b.toLowerCase();
1922
+ const rows = s.length + 1;
1923
+ const cols = t.length + 1;
1924
+ const prev = new Array(cols);
1925
+ const curr = new Array(cols);
1926
+ for (let j = 0; j < cols; j++)
1927
+ prev[j] = j;
1928
+ for (let i = 1; i < rows; i++) {
1929
+ curr[0] = i;
1930
+ for (let j = 1; j < cols; j++) {
1931
+ const cost = s[i - 1] === t[j - 1] ? 0 : 1;
1932
+ curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
1933
+ }
1934
+ for (let j = 0; j < cols; j++)
1935
+ prev[j] = curr[j] ?? 0;
1936
+ }
1937
+ return prev[cols - 1] ?? 0;
1938
+ }
1939
+ function nearestTestid(missing, present) {
1940
+ let best = null;
1941
+ let bestDistance = Number.POSITIVE_INFINITY;
1942
+ for (const candidate of present) {
1943
+ const distance = editDistance(missing, candidate);
1944
+ if (distance < bestDistance || distance === bestDistance && best !== null && candidate.length < best.length || distance === bestDistance && best !== null && candidate.length === best.length && candidate < best) {
1945
+ best = candidate;
1946
+ bestDistance = distance;
1947
+ }
1948
+ }
1949
+ return best;
1950
+ }
1951
+ function readQuery(result) {
1952
+ if (!result.ok)
1953
+ return { refs: [] };
1954
+ const payload = asRecord(result.result);
1955
+ const elements = Array.isArray(payload["elements"]) ? payload["elements"] : [];
1956
+ const refs = elements.map((e) => asString(asRecord(e)["ref"]) ?? "").filter((r) => r.length > 0);
1957
+ const rawHint = payload["hint"];
1958
+ if (typeof rawHint === "object" && rawHint !== null) {
1959
+ const hint = asRecord(rawHint);
1960
+ const present = Array.isArray(hint["presentTestids"]) ? hint["presentTestids"].filter((t) => typeof t === "string") : [];
1961
+ return {
1962
+ refs,
1963
+ hint: {
1964
+ route: asString(hint["route"]) ?? "",
1965
+ presentTestids: present,
1966
+ presentRegions: [],
1967
+ knownEmptyState: hint["knownEmptyState"] === true
1968
+ }
1969
+ };
1970
+ }
1971
+ return { refs };
1972
+ }
1973
+ function nearestIsAmbiguous(missing, present) {
1974
+ if (present.length < 2)
1975
+ return false;
1976
+ let min = Number.POSITIVE_INFINITY;
1977
+ let count = 0;
1978
+ for (const candidate of present) {
1979
+ const distance = editDistance(missing, candidate);
1980
+ if (distance < min) {
1981
+ min = distance;
1982
+ count = 1;
1983
+ } else if (distance === min) {
1984
+ count += 1;
1985
+ }
1986
+ }
1987
+ return count >= 2;
1988
+ }
1989
+ function testidDrift(value, hint) {
1990
+ const present = hint?.presentTestids ?? [];
1991
+ const drift = {
1992
+ reasonKind: DriftReason.TESTID_NOT_FOUND,
1993
+ reason: `testid "${value}" not found`,
1994
+ anchor: value,
1995
+ nearest: nearestTestid(value, present)
1996
+ };
1997
+ if (nearestIsAmbiguous(value, present))
1998
+ drift.ambiguous = true;
1999
+ return drift;
2000
+ }
2001
+ function anchorLabel(anchor) {
2002
+ if (anchor.kind === AnchorKind.TESTID)
2003
+ return anchor.value;
2004
+ if (anchor.kind === AnchorKind.SIGNAL)
2005
+ return anchor.name;
2006
+ return anchor.name ?? anchor.role;
2007
+ }
2008
+ async function runTestidStep(session, step, index, value, dynamic, confirmDangerous) {
2009
+ const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value });
2010
+ const { refs, hint } = readQuery(queryResult);
2011
+ if (refs.length === 0) {
2012
+ return {
2013
+ step: index,
2014
+ tool: step.tool,
2015
+ anchor: value,
2016
+ ok: false,
2017
+ drift: testidDrift(value, hint)
2018
+ };
2019
+ }
2020
+ const ref = refs[0] ?? "";
2021
+ const note = refs.length > 1 ? `ambiguous testid '${value}', used first match` : void 0;
2022
+ const act = await session.command(IrisCommand.ACT, {
2023
+ ref,
2024
+ action: step.action ?? "",
2025
+ args: replayActionArgs(step.args, confirmDangerous)
2026
+ });
2027
+ const result = { step: index, tool: step.tool, anchor: value, ok: act.ok };
2028
+ if (!act.ok) {
2029
+ result.error = act.error ?? "command failed";
2030
+ if (note !== void 0)
2031
+ result.note = note;
2032
+ return result;
2033
+ }
2034
+ const expectTestid = step.expect?.element?.testid;
2035
+ if (expectTestid !== void 0 && !dynamic.has(expectTestid)) {
2036
+ const expectQuery = await session.command(IrisCommand.QUERY, {
2037
+ by: QueryBy.TESTID,
2038
+ value: expectTestid
2039
+ });
2040
+ const expectRefs = readQuery(expectQuery);
2041
+ if (expectRefs.refs.length === 0) {
2042
+ return {
2043
+ step: index,
2044
+ tool: step.tool,
2045
+ anchor: expectTestid,
2046
+ ok: false,
2047
+ drift: testidDrift(expectTestid, expectRefs.hint)
2048
+ };
2049
+ }
2050
+ }
2051
+ if (note !== void 0)
2052
+ result.note = note;
2053
+ return result;
2054
+ }
2055
+ async function runSignalStep(session, step, index, name, waitForSignal, signalTimeoutMs) {
2056
+ const verdict = await waitForSignal(session, { kind: "signal", name }, signalTimeoutMs);
2057
+ if (verdict.pass)
2058
+ return { step: index, tool: step.tool, anchor: name, ok: true };
2059
+ return {
2060
+ step: index,
2061
+ tool: step.tool,
2062
+ anchor: name,
2063
+ ok: false,
2064
+ drift: {
2065
+ reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
2066
+ reason: `signal "${name}" not observed`,
2067
+ anchor: name,
2068
+ nearest: null
2069
+ }
2070
+ };
2071
+ }
2072
+ async function replayFlow(session, flow, waitForSignal, signalTimeoutMs, confirmDangerous = false) {
2073
+ const results = [];
2074
+ const dynamic = new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
2075
+ let index = 0;
2076
+ for (const step of flow.steps) {
2077
+ const label = anchorLabel(step.anchor);
2078
+ let result;
2079
+ if (step.anchor.kind === AnchorKind.SIGNAL) {
2080
+ result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs);
2081
+ } else {
2082
+ result = await runTestidStep(session, step, index, label, dynamic, confirmDangerous);
2083
+ }
2084
+ results.push(result);
2085
+ if (result.drift !== void 0 || !result.ok)
2086
+ break;
2087
+ index += 1;
2088
+ }
2089
+ return results;
2090
+ }
2091
+
2092
+ // ../server/dist/flows/heal.js
2093
+ function confidenceFor(from, to) {
2094
+ if (from === to)
2095
+ return 1;
2096
+ const span = Math.max(from.length, to.length);
2097
+ if (span === 0)
2098
+ return 1;
2099
+ const raw = 1 - editDistance(from, to) / span;
2100
+ if (raw >= 1)
2101
+ return 1;
2102
+ if (raw <= 0)
2103
+ return Number.EPSILON;
2104
+ return raw;
2105
+ }
2106
+ function applyHealChanges(flow, changes) {
2107
+ const byStep = /* @__PURE__ */ new Map();
2108
+ for (const change of changes)
2109
+ byStep.set(change.step, change);
2110
+ const applied = [];
2111
+ const steps = flow.steps.map((step, index) => {
2112
+ const change = byStep.get(index);
2113
+ if (change === void 0 || step.anchor.kind !== AnchorKind.TESTID || step.anchor.value !== change.from) {
2114
+ return step;
2115
+ }
2116
+ applied.push(change);
2117
+ return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
2118
+ });
2119
+ return { flow: { ...flow, steps }, applied };
2120
+ }
2121
+ function proposeRebindWith(drift, step, minConfidence) {
2122
+ if (drift.reasonKind !== DriftReason.TESTID_NOT_FOUND)
2123
+ return void 0;
2124
+ if (drift.ambiguous === true)
2125
+ return void 0;
2126
+ const to = drift.nearest;
2127
+ if (to === null)
2128
+ return void 0;
2129
+ const confidence = confidenceFor(drift.anchor, to);
2130
+ if (confidence < minConfidence)
2131
+ return void 0;
2132
+ return { step, from: drift.anchor, to, confidence };
2133
+ }
2134
+ function collectProposals(steps, minConfidence = HEAL_CONFIDENCE_MIN) {
2135
+ const proposals = [];
2136
+ for (const step of steps) {
2137
+ if (step.drift === void 0)
2138
+ continue;
2139
+ const proposal = proposeRebindWith(step.drift, step.step, minConfidence);
2140
+ if (proposal !== void 0)
2141
+ proposals.push(proposal);
2142
+ }
2143
+ return proposals;
2144
+ }
2145
+
2146
+ // ../server/dist/project/iris-dir.js
2147
+ import { join } from "path";
2148
+ function irisDirPaths(root) {
2149
+ return {
2150
+ root,
2151
+ contract: join(root, IrisDir.CONTRACT_FILE),
2152
+ flows: join(root, IrisDir.FLOWS_SUBDIR),
2153
+ baselines: join(root, IrisDir.BASELINES_SUBDIR),
2154
+ project: join(root, IrisDir.PROJECT_FILE),
2155
+ visual: join(root, IrisDir.VISUAL_SUBDIR)
2156
+ };
2157
+ }
2158
+ function visualPath(root, name) {
2159
+ return join(root, IrisDir.VISUAL_SUBDIR, `${name}.png`);
2160
+ }
2161
+ function visualDiffPath(root, name) {
2162
+ return join(root, IrisDir.VISUAL_SUBDIR, `${name}.diff.png`);
2163
+ }
2164
+ function flowPath(root, name) {
2165
+ return join(root, IrisDir.FLOWS_SUBDIR, `${name}.json`);
2166
+ }
2167
+ function isValidFlowName(name) {
2168
+ return FLOW_NAME_PATTERN.test(name) && !name.includes("..");
2169
+ }
2170
+ async function ensureIrisDir(fs2, root) {
2171
+ const p = irisDirPaths(root);
2172
+ await fs2.mkdir(p.root);
2173
+ await fs2.mkdir(p.flows);
2174
+ await fs2.mkdir(p.baselines);
2175
+ }
2176
+ var JSON_INDENT = 2;
2177
+ function stableSerialize(capabilities, generatedAt) {
2178
+ const envelope = {
2179
+ version: CONTRACT_FILE_VERSION,
2180
+ generatedAt,
2181
+ capabilities: {
2182
+ testids: [...capabilities.testids].sort(),
2183
+ signals: [...capabilities.signals].sort(),
2184
+ stores: [...capabilities.stores].sort(),
2185
+ flows: [...capabilities.flows].map((f) => ({ name: f.name, steps: [...f.steps] })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
2186
+ }
2187
+ };
2188
+ return `${JSON.stringify(envelope, null, JSON_INDENT)}
2189
+ `;
2190
+ }
2191
+ async function writeContract(fs2, root, capabilities, now) {
2192
+ await ensureIrisDir(fs2, root);
2193
+ await fs2.writeFile(irisDirPaths(root).contract, stableSerialize(capabilities, now()));
2194
+ }
2195
+ async function readContract(fs2, root) {
2196
+ const path = irisDirPaths(root).contract;
2197
+ if (!await fs2.exists(path))
2198
+ return { ok: false, reason: ContractReadError.MISSING };
2199
+ let text;
2200
+ try {
2201
+ text = await fs2.readFile(path);
2202
+ } catch (error) {
2203
+ return {
2204
+ ok: false,
2205
+ reason: fs2.isNotFound(error) ? ContractReadError.MISSING : ContractReadError.MALFORMED
2206
+ };
2207
+ }
2208
+ let parsed;
2209
+ try {
2210
+ parsed = JSON.parse(text);
2211
+ } catch {
2212
+ return { ok: false, reason: ContractReadError.MALFORMED };
2213
+ }
2214
+ const result = ContractFileSchema.safeParse(parsed);
2215
+ if (!result.success)
2216
+ return { ok: false, reason: ContractReadError.MALFORMED };
2217
+ return { ok: true, capabilities: result.data.capabilities, generatedAt: result.data.generatedAt };
2218
+ }
2219
+
2220
+ // ../server/dist/flows/flows.js
2221
+ function asString3(value) {
2222
+ return typeof value === "string" ? value : void 0;
2223
+ }
2224
+ function asRecord3(value) {
2225
+ return typeof value === "object" && value !== null ? value : {};
2226
+ }
2227
+ function degradedAnchor() {
2228
+ return { kind: AnchorKind.ROLE, role: DEGRADED_ANCHOR_ROLE };
2229
+ }
2230
+ function subStepToFlowStep(raw) {
2231
+ const sub = asRecord3(raw);
2232
+ const by = asString3(sub["by"]);
2233
+ const value = asString3(sub["value"]);
2234
+ const action = asString3(sub["action"]);
2235
+ const args = asRecord3(sub["args"]);
2236
+ if (by === QueryBy.TESTID && value !== void 0) {
2237
+ return buildStep(IrisTool.ACT, { kind: AnchorKind.TESTID, value }, action, args, false);
2238
+ }
2239
+ return buildStep(IrisTool.ACT, degradedAnchor(), action, args, true);
2240
+ }
2241
+ function buildStep(tool, anchor, action, args, degraded) {
2242
+ const step = { tool, anchor, args };
2243
+ if (action !== void 0)
2244
+ step.action = action;
2245
+ if (degraded)
2246
+ step.degraded = true;
2247
+ return step;
2248
+ }
2249
+ function recordedStepToFlowStep(step) {
2250
+ if (step.tool === IrisTool.ACT_SEQUENCE) {
2251
+ const rawSubs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
2252
+ const subs = rawSubs.map(subStepToFlowStep);
2253
+ const degraded = subs.some((s) => s.degraded === true);
2254
+ const anchor = subs[0]?.anchor ?? degradedAnchor();
2255
+ const out2 = { tool: IrisTool.ACT_SEQUENCE, anchor, steps: subs };
2256
+ if (degraded)
2257
+ out2.degraded = true;
2258
+ if (step.expect !== void 0)
2259
+ out2.expect = step.expect;
2260
+ return out2;
2261
+ }
2262
+ const by = asString3(step.args["by"]);
2263
+ const value = asString3(step.args["value"]);
2264
+ const action = asString3(step.args["action"]);
2265
+ const args = asRecord3(step.args["args"]);
2266
+ const out = by === QueryBy.TESTID && value !== void 0 ? buildStep(step.tool, { kind: AnchorKind.TESTID, value }, action, args, false) : buildStep(step.tool, degradedAnchor(), action, args, true);
2267
+ if (step.expect !== void 0)
2268
+ out.expect = step.expect;
2269
+ return out;
2270
+ }
2271
+ function withAnnotations(flow, ann) {
2272
+ if (ann === void 0)
2273
+ return flow;
2274
+ const steps = flow.steps.map((step, i) => {
2275
+ const expect = ann.stepExpect.get(i);
2276
+ return expect === void 0 ? step : { ...step, expect };
2277
+ });
2278
+ const out = { ...flow, steps };
2279
+ if (ann.dynamic.length > 0) {
2280
+ out.dynamic = ann.dynamic.map((value) => ({ kind: AnchorKind.TESTID, value }));
2281
+ }
2282
+ if (ann.success !== void 0)
2283
+ out.success = ann.success;
2284
+ return out;
2285
+ }
2286
+ var JSON_INDENT2 = 2;
2287
+ var FLOW_SUFFIX = ".json";
2288
+ var FlowStore = class {
2289
+ #fs;
2290
+ #root;
2291
+ #clock;
2292
+ constructor(fs2, root, clock) {
2293
+ this.#fs = fs2;
2294
+ this.#root = root;
2295
+ this.#clock = clock;
2296
+ }
2297
+ /**
2298
+ * The single byte-stable flow serializer: 2-space indent + one trailing newline. save(),
2299
+ * saveFlow() and heal() all route through it so an unchanged flow that round-trips through any
2300
+ * of them produces byte-identical on-disk content (locked by the byte-stability tests).
2301
+ */
2302
+ #serialize(flow) {
2303
+ return `${JSON.stringify(flow, null, JSON_INDENT2)}
2304
+ `;
2305
+ }
2306
+ /**
2307
+ * Convert a CompiledProgram (testid-normalized) into an anchored, on-disk flow + write it.
2308
+ * Optionally fold structured annotations (per-step expect, dynamic[], success) onto
2309
+ * the flow before writing. Omitting `annotations` reproduces the same bytes.
2310
+ */
2311
+ async save(program, annotations) {
2312
+ if (!isValidFlowName(program.name)) {
2313
+ return { ok: false, code: FlowErrorCode.INVALID_NAME };
2314
+ }
2315
+ const steps = program.steps.map(recordedStepToFlowStep);
2316
+ const base = {
2317
+ version: FLOW_FILE_VERSION,
2318
+ name: program.name,
2319
+ createdAt: this.#clock.now(),
2320
+ steps
2321
+ };
2322
+ const flow = withAnnotations(base, annotations);
2323
+ await this.#fs.mkdir(irisDirPaths(this.#root).flows);
2324
+ await this.#fs.writeFile(flowPath(this.#root, program.name), this.#serialize(flow));
2325
+ const degraded = flow.steps.filter((s) => s.degraded === true).length;
2326
+ return {
2327
+ ok: true,
2328
+ value: {
1769
2329
  name: program.name,
1770
2330
  stepCount: flow.steps.length,
1771
2331
  degraded,
@@ -1816,19 +2376,7 @@ var FlowStore = class {
1816
2376
  if (!loaded.ok)
1817
2377
  return { ok: false, code: loaded.code };
1818
2378
  const flow = loaded.value;
1819
- const byStep = /* @__PURE__ */ new Map();
1820
- for (const change of changes)
1821
- byStep.set(change.step, change);
1822
- const applied = [];
1823
- const steps = flow.steps.map((step, index) => {
1824
- const change = byStep.get(index);
1825
- if (change === void 0 || step.anchor.kind !== AnchorKind.TESTID || step.anchor.value !== change.from) {
1826
- return step;
1827
- }
1828
- applied.push(change);
1829
- return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
1830
- });
1831
- const next = { ...flow, steps };
2379
+ const { flow: next, applied } = applyHealChanges(flow, changes);
1832
2380
  await this.#fs.writeFile(flowPath(this.#root, name), this.#serialize(next));
1833
2381
  return { ok: true, value: { name, changed: applied } };
1834
2382
  }
@@ -2056,10 +2604,10 @@ function createNodeFileSystem() {
2056
2604
 
2057
2605
  // ../server/dist/mcp.js
2058
2606
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2059
- import { z as z16 } from "zod";
2607
+ import { z as z17 } from "zod";
2060
2608
 
2061
2609
  // ../server/dist/tools/tools.js
2062
- import { z as z15 } from "zod";
2610
+ import { z as z16 } from "zod";
2063
2611
 
2064
2612
  // ../server/dist/input/real-input.js
2065
2613
  var DriveError = class extends Error {
@@ -2120,8 +2668,10 @@ async function performGesture(page, action, box, args, sleep) {
2120
2668
  }
2121
2669
  return { performed: false, center };
2122
2670
  }
2671
+ var HIDE_IRIS_CHROME_CSS = "[data-iris-overlay]{display:none !important}";
2672
+ var SCREENSHOT_DETERMINISM = { style: HIDE_IRIS_CHROME_CSS, animations: "disabled" };
2123
2673
  async function capturePage(page, opts) {
2124
- const buf = await page.screenshot(opts.clip !== void 0 ? { clip: opts.clip } : opts.fullPage === true ? { fullPage: true } : {});
2674
+ const buf = await page.screenshot(opts.clip !== void 0 ? { ...SCREENSHOT_DETERMINISM, clip: opts.clip } : opts.fullPage === true ? { ...SCREENSHOT_DETERMINISM, fullPage: true } : { ...SCREENSHOT_DETERMINISM });
2125
2675
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
2126
2676
  }
2127
2677
  var nodeSleep = (ms) => new Promise((resolve) => {
@@ -2356,6 +2906,7 @@ var PredicateSchema = z3.lazy(() => z3.discriminatedUnion("kind", [
2356
2906
  name: z3.string().optional(),
2357
2907
  dataMatches: z3.record(z3.unknown()).optional()
2358
2908
  }),
2909
+ z3.object({ kind: z3.literal("settled"), quietMs: z3.number().positive().optional() }),
2359
2910
  z3.object({ kind: z3.literal("allOf"), predicates: z3.array(PredicateSchema) }),
2360
2911
  z3.object({ kind: z3.literal("anyOf"), predicates: z3.array(PredicateSchema) }),
2361
2912
  z3.object({ kind: z3.literal("not"), predicate: PredicateSchema })
@@ -2552,6 +3103,39 @@ function evalSignal(events, p) {
2552
3103
  evidence: sameName.length > 0 ? { nearMiss: sameName } : void 0
2553
3104
  };
2554
3105
  }
3106
+ var SETTLE_ACTIVITY = /* @__PURE__ */ new Set([
3107
+ EventType.NET_REQUEST,
3108
+ EventType.DOM_ADDED,
3109
+ EventType.DOM_REMOVED,
3110
+ EventType.DOM_ATTR
3111
+ ]);
3112
+ var DEFAULT_QUIET_MS = 500;
3113
+ function evalSettled(events, p, now) {
3114
+ const quietMs = p.quietMs ?? DEFAULT_QUIET_MS;
3115
+ let lastT = -1;
3116
+ let lastType;
3117
+ for (const e of events) {
3118
+ if (SETTLE_ACTIVITY.has(e.type) && e.t > lastT) {
3119
+ lastT = e.t;
3120
+ lastType = e.type;
3121
+ }
3122
+ }
3123
+ if (lastT < 0) {
3124
+ return {
3125
+ pass: true,
3126
+ evidence: { settled: true, quietForMs: null, note: "no activity to settle" }
3127
+ };
3128
+ }
3129
+ const quietForMs = now - lastT;
3130
+ if (quietForMs >= quietMs) {
3131
+ return { pass: true, evidence: { settled: true, quietForMs, lastActivity: lastType } };
3132
+ }
3133
+ return {
3134
+ pass: false,
3135
+ failureReason: `not settled: last activity (${String(lastType)}) ${String(quietForMs)}ms ago, need ${String(quietMs)}ms quiet`,
3136
+ evidence: { quietForMs, lastActivity: lastType }
3137
+ };
3138
+ }
2555
3139
  async function evaluatePredicate(session, predicate, since = 0) {
2556
3140
  const events = session.eventsSince(since);
2557
3141
  switch (predicate.kind) {
@@ -2569,6 +3153,8 @@ async function evaluatePredicate(session, predicate, since = 0) {
2569
3153
  return evalAnimation(events, predicate);
2570
3154
  case "signal":
2571
3155
  return evalSignal(events, predicate);
3156
+ case "settled":
3157
+ return evalSettled(events, predicate, session.elapsed());
2572
3158
  case "allOf": {
2573
3159
  const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since)));
2574
3160
  const failed = results.find((r) => !r.pass);
@@ -2594,6 +3180,10 @@ async function evaluatePredicate(session, predicate, since = 0) {
2594
3180
  function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2595
3181
  return new Promise((resolve) => {
2596
3182
  let done = false;
3183
+ const failed = (error) => ({
3184
+ pass: false,
3185
+ failureReason: error instanceof Error ? error.message : String(error)
3186
+ });
2597
3187
  const finish = (result) => {
2598
3188
  if (done)
2599
3189
  return;
@@ -2607,6 +3197,8 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2607
3197
  void evaluatePredicate(session, predicate, since).then((r) => {
2608
3198
  if (r.pass)
2609
3199
  finish(r);
3200
+ }).catch((error) => {
3201
+ finish(failed(error));
2610
3202
  });
2611
3203
  };
2612
3204
  const unsub = session.onEvent(() => {
@@ -2615,146 +3207,35 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2615
3207
  const interval = setInterval(check, 150);
2616
3208
  const timer = setTimeout(() => {
2617
3209
  void evaluatePredicate(session, predicate, since).then((r) => {
2618
- finish({
2619
- pass: false,
2620
- evidence: r.evidence,
2621
- failureReason: r.failureReason ?? "timed out waiting for predicate"
2622
- });
2623
- });
2624
- }, timeoutMs);
2625
- check();
2626
- });
2627
- }
2628
-
2629
- // ../server/dist/flows/replay.js
2630
- function asString2(value) {
2631
- return typeof value === "string" ? value : void 0;
2632
- }
2633
- function asRecord2(value) {
2634
- return typeof value === "object" && value !== null ? value : {};
2635
- }
2636
- function compileActStep(args, res) {
2637
- const testid = asString2(asRecord2(res)["testid"]);
2638
- const action = asString2(args["action"]) ?? "";
2639
- const actArgs = asRecord2(args["args"]);
2640
- if (testid !== void 0) {
2641
- return {
2642
- tool: IrisTool.ACT,
2643
- stable: true,
2644
- args: { by: QueryBy.TESTID, value: testid, action, args: actArgs }
2645
- };
2646
- }
2647
- return {
2648
- tool: IrisTool.ACT,
2649
- stable: false,
2650
- args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
2651
- };
2652
- }
2653
- function compileSequenceStep(args, res) {
2654
- const inputSteps = Array.isArray(args["steps"]) ? args["steps"] : [];
2655
- const resolved = Array.isArray(asRecord2(res)["steps"]) ? asRecord2(res)["steps"] : [];
2656
- let stable = inputSteps.length > 0;
2657
- const subSteps = inputSteps.map((raw, i) => {
2658
- const step = asRecord2(raw);
2659
- const action = asString2(step["action"]) ?? "";
2660
- const stepArgs = asRecord2(step["args"]);
2661
- const testid = asString2(asRecord2(resolved[i])["testid"]);
2662
- if (testid !== void 0) {
2663
- return { by: QueryBy.TESTID, value: testid, action, args: stepArgs };
2664
- }
2665
- stable = false;
2666
- return { ref: asString2(step["ref"]) ?? "", action, args: stepArgs };
2667
- });
2668
- return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
2669
- }
2670
- async function resolveRef(session, step) {
2671
- const by = asString2(step.by);
2672
- const value = asString2(step.value);
2673
- if (by === QueryBy.TESTID && value !== void 0) {
2674
- const result = await session.command(IrisCommand.QUERY, { by, value });
2675
- if (!result.ok)
2676
- throw new Error(result.error ?? "query failed");
2677
- const elements = Array.isArray(asRecord2(result.result)["elements"]) ? asRecord2(result.result)["elements"] : [];
2678
- const ref2 = asString2(asRecord2(elements[0])["ref"]);
2679
- if (ref2 === void 0)
2680
- throw new Error(`testid '${value}' did not resolve in current page`);
2681
- return elements.length > 1 ? { ref: ref2, note: `ambiguous testid '${value}', used first match` } : { ref: ref2 };
2682
- }
2683
- const ref = asString2(step.ref);
2684
- if (ref === void 0 || ref.length === 0)
2685
- throw new Error("step has no testid or ref to resolve");
2686
- return { ref, note: "replayed by stale ref (not portable across sessions)" };
2687
- }
2688
- async function replayProgram(session, program) {
2689
- const results = [];
2690
- for (const step of program.steps) {
2691
- try {
2692
- if (step.tool === IrisTool.ACT_SEQUENCE) {
2693
- const subs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
2694
- const notes = [];
2695
- const liveSteps = [];
2696
- for (const raw of subs) {
2697
- const sub = asRecord2(raw);
2698
- const { ref, note } = await resolveRef(session, sub);
2699
- if (note !== void 0)
2700
- notes.push(note);
2701
- liveSteps.push({
2702
- ref,
2703
- action: asString2(sub["action"]) ?? "",
2704
- args: asRecord2(sub["args"])
2705
- });
2706
- }
2707
- const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps });
2708
- results.push(buildResult(step.tool, r.ok, r.error, notes));
2709
- if (!r.ok)
2710
- break;
2711
- } else {
2712
- const { ref, note } = await resolveRef(session, step.args);
2713
- const r = await session.command(IrisCommand.ACT, {
2714
- ref,
2715
- action: asString2(step.args["action"]) ?? "",
2716
- args: asRecord2(step.args["args"])
3210
+ finish({
3211
+ pass: false,
3212
+ evidence: r.evidence,
3213
+ failureReason: r.failureReason ?? "timed out waiting for predicate"
2717
3214
  });
2718
- results.push(buildResult(step.tool, r.ok, r.error, note !== void 0 ? [note] : []));
2719
- if (!r.ok)
2720
- break;
2721
- }
2722
- } catch (e) {
2723
- results.push({
2724
- tool: step.tool,
2725
- ok: false,
2726
- error: e instanceof Error ? e.message : String(e)
3215
+ }).catch((error) => {
3216
+ finish(failed(error));
2727
3217
  });
2728
- break;
2729
- }
2730
- }
2731
- return results;
2732
- }
2733
- function buildResult(tool, ok, error, notes) {
2734
- const base = { tool, ok };
2735
- if (!ok)
2736
- base.error = error ?? "command failed";
2737
- if (notes.length > 0)
2738
- base.note = notes.join("; ");
2739
- return base;
3218
+ }, timeoutMs);
3219
+ check();
3220
+ });
2740
3221
  }
2741
3222
 
2742
3223
  // ../server/dist/events/event-filters.js
2743
- function asString3(value) {
3224
+ function asString4(value) {
2744
3225
  return typeof value === "string" ? value : void 0;
2745
3226
  }
2746
- function asNumber(value) {
3227
+ function asNumber2(value) {
2747
3228
  return typeof value === "number" ? value : void 0;
2748
3229
  }
2749
3230
  function matchNet(e, method, urlContains, status) {
2750
3231
  const d = e.data;
2751
- if (method !== void 0 && asString3(d["method"])?.toUpperCase() !== method.toUpperCase()) {
3232
+ if (method !== void 0 && asString4(d["method"])?.toUpperCase() !== method.toUpperCase()) {
2752
3233
  return false;
2753
3234
  }
2754
- if (urlContains !== void 0 && !(asString3(d["url"]) ?? "").includes(urlContains)) {
3235
+ if (urlContains !== void 0 && !(asString4(d["url"]) ?? "").includes(urlContains)) {
2755
3236
  return false;
2756
3237
  }
2757
- if (status !== void 0 && asNumber(d["status"]) !== status)
3238
+ if (status !== void 0 && asNumber2(d["status"]) !== status)
2758
3239
  return false;
2759
3240
  return true;
2760
3241
  }
@@ -2771,8 +3252,8 @@ function matchConsole(e, level) {
2771
3252
  var HINT_SAMPLE_MAX = 5;
2772
3253
  function netEmptyHint(allNet) {
2773
3254
  const present = allNet.slice(-HINT_SAMPLE_MAX).reverse().map((e) => {
2774
- const status = asNumber(e.data["status"]);
2775
- const base = { method: asString3(e.data["method"]) ?? "", url: asString3(e.data["url"]) ?? "" };
3255
+ const status = asNumber2(e.data["status"]);
3256
+ const base = { method: asString4(e.data["method"]) ?? "", url: asString4(e.data["url"]) ?? "" };
2776
3257
  return status === void 0 ? base : { ...base, status };
2777
3258
  });
2778
3259
  return { totalInWindow: allNet.length, present };
@@ -2803,6 +3284,8 @@ function refuseIfThrottled(session, refuse) {
2803
3284
  }
2804
3285
 
2805
3286
  // ../server/dist/session/output-budget.js
3287
+ var LARGE_TIMELINE_EVENTS = 80;
3288
+ var LARGE_TIMELINE_BYTES = 8e3;
2806
3289
  function applyEventBudget(events, maxEvents) {
2807
3290
  if (maxEvents === void 0 || maxEvents < 0 || events.length <= maxEvents) {
2808
3291
  return { events, droppedOldest: 0 };
@@ -2813,8 +3296,92 @@ function applyEventBudget(events, maxEvents) {
2813
3296
  };
2814
3297
  }
2815
3298
  function costHint(payload, events, droppedOldest = 0) {
2816
- const bytes = JSON.stringify(payload)?.length ?? 0;
2817
- return droppedOldest > 0 ? { events, bytes, droppedOldest } : { events, bytes };
3299
+ const json = JSON.stringify(payload) ?? "";
3300
+ const bytes = json.length;
3301
+ const base = droppedOldest > 0 ? { events, bytes, droppedOldest } : { events, bytes };
3302
+ if (events >= LARGE_TIMELINE_EVENTS || bytes >= LARGE_TIMELINE_BYTES) {
3303
+ base.recommendation = `large timeline (${String(events)} events, ~${String(estimateTokens(json))} tokens) \u2014 pass filters:[...] (e.g. ["signal","net"]) or max_events to scope your next call and cut tokens`;
3304
+ }
3305
+ return base;
3306
+ }
3307
+ var CHARS_PER_TOKEN = 4;
3308
+ function estimateTokens(text) {
3309
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
3310
+ }
3311
+ function sizeCost(payload) {
3312
+ const json = JSON.stringify(payload) ?? "";
3313
+ return { bytes: json.length, tokens: estimateTokens(json) };
3314
+ }
3315
+ function withSizeCost(result) {
3316
+ if (typeof result !== "object" || result === null)
3317
+ return result;
3318
+ return { ...result, cost: sizeCost(result) };
3319
+ }
3320
+
3321
+ // ../server/dist/tools/snapshot-delta.js
3322
+ var SnapshotDeltaMode = {
3323
+ FULL: "full",
3324
+ DELTA: "delta",
3325
+ UNCHANGED: "unchanged"
3326
+ };
3327
+ function snapshotDelta(prevTree, nextTree) {
3328
+ if (prevTree === void 0)
3329
+ return { mode: SnapshotDeltaMode.FULL };
3330
+ const { added, removed } = diffLines(normalizeLines(prevTree), normalizeLines(nextTree));
3331
+ if (added.length === 0 && removed.length === 0)
3332
+ return { mode: SnapshotDeltaMode.UNCHANGED };
3333
+ return {
3334
+ mode: SnapshotDeltaMode.DELTA,
3335
+ delta: { added, removed, addedCount: added.length, removedCount: removed.length }
3336
+ };
3337
+ }
3338
+ var DEFAULT_MAX_ENTRIES = 50;
3339
+ var SnapshotCache = class {
3340
+ #map = /* @__PURE__ */ new Map();
3341
+ #max;
3342
+ constructor(max = DEFAULT_MAX_ENTRIES) {
3343
+ this.#max = max;
3344
+ }
3345
+ /** Last tree for this key IF the route still matches; undefined when absent or route changed. */
3346
+ recall(key, route) {
3347
+ const entry = this.#map.get(key);
3348
+ return entry !== void 0 && entry.route === route ? entry.tree : void 0;
3349
+ }
3350
+ remember(key, route, tree) {
3351
+ if (this.#map.size >= this.#max && !this.#map.has(key)) {
3352
+ const oldest = this.#map.keys().next().value;
3353
+ if (oldest !== void 0)
3354
+ this.#map.delete(oldest);
3355
+ }
3356
+ this.#map.set(key, { route, tree });
3357
+ }
3358
+ };
3359
+ function snapshotCacheKey(sessionId, scope, mode) {
3360
+ return `${sessionId}\0${scope}\0${mode}`;
3361
+ }
3362
+ function applySnapshotDelta(raw, opts, cache) {
3363
+ if (typeof raw !== "object" || raw === null)
3364
+ return raw;
3365
+ const r = raw;
3366
+ if (typeof r["tree"] !== "string")
3367
+ return raw;
3368
+ const tree = r["tree"];
3369
+ const status = typeof r["status"] === "object" && r["status"] !== null ? r["status"] : {};
3370
+ const route = typeof status["route"] === "string" ? status["route"] : "";
3371
+ const key = snapshotCacheKey(opts.sessionId, opts.scope, opts.mode);
3372
+ if (!opts.diff) {
3373
+ cache.remember(key, route, tree);
3374
+ return raw;
3375
+ }
3376
+ const prev = cache.recall(key, route);
3377
+ cache.remember(key, route, tree);
3378
+ const decision = snapshotDelta(prev, tree);
3379
+ if (decision.mode === SnapshotDeltaMode.FULL)
3380
+ return raw;
3381
+ if (decision.mode === SnapshotDeltaMode.UNCHANGED) {
3382
+ return { mode: SnapshotDeltaMode.UNCHANGED, status: r["status"] };
3383
+ }
3384
+ return { mode: SnapshotDeltaMode.DELTA, delta: decision.delta, status: r["status"] };
2818
3385
  }
2819
3386
 
2820
3387
  // ../server/dist/session/state-select.js
@@ -2865,25 +3432,55 @@ function capDepth(value, maxDepth) {
2865
3432
  return value;
2866
3433
  }
2867
3434
 
2868
- // ../server/dist/tools/tools-helpers.js
2869
- function parseInteractive(tree) {
2870
- const items = [];
2871
- for (const line of tree.split("\n")) {
2872
- const match = /\(ref=(e\d+)\)/.exec(line);
2873
- if (match !== null) {
2874
- items.push({ ref: match[1] ?? "", desc: line.replace(/\s*\(ref=e\d+\)/, "").trim() });
2875
- }
3435
+ // ../server/dist/tools/query-paginate.js
3436
+ function paginateQueryResult(result, limit, countOnly) {
3437
+ if (typeof result !== "object" || result === null)
3438
+ return result;
3439
+ const record = result;
3440
+ const elements = record["elements"];
3441
+ if (!Array.isArray(elements))
3442
+ return result;
3443
+ const total = elements.length;
3444
+ if (countOnly) {
3445
+ const { elements: _dropped, ...rest } = record;
3446
+ return { ...rest, count: total };
2876
3447
  }
2877
- return items;
2878
- }
2879
- function asString4(value) {
2880
- return typeof value === "string" ? value : void 0;
3448
+ if (limit !== void 0 && limit >= 0 && total > limit) {
3449
+ return { ...record, elements: elements.slice(0, limit), total, truncated: true };
3450
+ }
3451
+ return result;
2881
3452
  }
2882
- function asNumber2(value) {
2883
- return typeof value === "number" ? value : void 0;
3453
+
3454
+ // ../server/dist/tools/assert-grade.js
3455
+ var PRESENCE_ONLY_ADVICE = "This predicate only checks element/text presence, not an observable consequence. A locator healed to the wrong element (or a stale render) can satisfy it while the feature is broken. Prefer a { signal } or { net } assertion \u2014 or allOf it with one \u2014 so green means the feature actually worked.";
3456
+ function walk(predicate) {
3457
+ switch (predicate.kind) {
3458
+ case "signal":
3459
+ case "net":
3460
+ return { consequence: true, presence: false };
3461
+ case "element":
3462
+ case "text":
3463
+ return { consequence: false, presence: true };
3464
+ case "route":
3465
+ case "console":
3466
+ case "animation":
3467
+ case "settled":
3468
+ return { consequence: false, presence: false };
3469
+ case "allOf":
3470
+ case "anyOf": {
3471
+ const subs = predicate.predicates.map(walk);
3472
+ return {
3473
+ consequence: subs.some((s) => s.consequence),
3474
+ presence: subs.some((s) => s.presence)
3475
+ };
3476
+ }
3477
+ case "not":
3478
+ return walk(predicate.predicate);
3479
+ }
2884
3480
  }
2885
- function asRecord3(value) {
2886
- return typeof value === "object" && value !== null ? value : {};
3481
+ function isPresenceOnlyAssertion(predicate) {
3482
+ const kinds = walk(predicate);
3483
+ return kinds.presence && !kinds.consequence;
2887
3484
  }
2888
3485
 
2889
3486
  // ../server/dist/tools/contract-tools.js
@@ -2917,7 +3514,7 @@ var CONTRACT_TOOLS = [
2917
3514
  throw new Error(r.reason === ContractReadError.MISSING ? "no .iris/contract.json on disk \u2014 run iris_contract_save first (or omit fromDisk to read the live session)" : ".iris/contract.json is malformed \u2014 fix or regenerate it with iris_contract_save");
2918
3515
  return { ...r.capabilities, source: "disk", generatedAt: r.generatedAt };
2919
3516
  }
2920
- const caps = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3517
+ const caps = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2921
3518
  return { ...caps, source: "live" };
2922
3519
  }
2923
3520
  },
@@ -2932,7 +3529,7 @@ var CONTRACT_TOOLS = [
2932
3529
  signalCount: z4.number()
2933
3530
  },
2934
3531
  handler: async (deps, args) => {
2935
- const res = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3532
+ const res = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2936
3533
  const caps = CapabilitiesSchema.parse(res);
2937
3534
  await writeContract(deps.fs, deps.irisRoot, caps, deps.now);
2938
3535
  return {
@@ -2945,253 +3542,399 @@ var CONTRACT_TOOLS = [
2945
3542
  }
2946
3543
  ];
2947
3544
 
2948
- // ../server/dist/tools/browser-tools.js
3545
+ // ../server/dist/domain/domain-tools.js
2949
3546
  import { z as z5 } from "zod";
2950
- var sessionIdShape2 = {
2951
- sessionId: z5.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3547
+
3548
+ // ../server/dist/flows/flow-classify.js
3549
+ var FlowAssertionGrade = {
3550
+ /** At least one step (or the success end-condition) asserts a signal/network consequence. */
3551
+ ASSERTED: "asserted",
3552
+ /** Only element-presence checks — a healed-but-wrong locator could still pass. */
3553
+ PRESENCE_ONLY: "presence-only",
3554
+ /** Performs actions but asserts nothing observable — passes even if the feature is broken. */
3555
+ ASSERTION_FREE: "assertion-free"
2952
3556
  };
2953
- async function commandOrThrow2(deps, sessionId, name, args) {
2954
- const session = deps.sessions.resolve(sessionId);
2955
- const result = await session.command(name, args);
2956
- if (!result.ok)
2957
- throw new Error(result.error ?? `command '${name}' failed`);
2958
- return result.result;
3557
+ var ASSERTION_FREE_WARNING = "This flow performs actions but asserts no observable consequence \u2014 it will pass even if the feature is broken. Add a consequence assertion with iris_annotate (assert-signal / assert-net) or a success-state.";
3558
+ var PRESENCE_ONLY_WARNING = "This flow only checks element presence, not an observable consequence (signal/network). A locator healed to the wrong element can still pass it. Add a consequence assertion (assert-signal / assert-net / success-state).";
3559
+ function expectIsConsequence(e) {
3560
+ return e !== void 0 && (e.signal !== void 0 || e.net !== void 0);
2959
3561
  }
2960
- var BROWSER_TOOLS = [
2961
- {
2962
- name: IrisTool.NAVIGATE,
2963
- description: "Navigate the connected browser tab to a URL. The SDK reconnects automatically after the page loads. Use iris_sessions to confirm the new tab is connected before acting.",
2964
- inputSchema: {
2965
- url: z5.string().describe("The URL to navigate to."),
2966
- ...sessionIdShape2
2967
- },
2968
- outputSchema: {
2969
- ok: z5.boolean(),
2970
- url: z5.string().optional(),
2971
- reason: z5.string().optional()
2972
- },
2973
- handler: async (deps, args) => {
2974
- const url = asString4(args["url"]);
2975
- if (url === void 0 || url.length === 0)
2976
- return { ok: false, reason: "url required" };
2977
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.NAVIGATE, { url });
2978
- return { ok: true, url };
2979
- }
2980
- },
2981
- {
2982
- name: IrisTool.REFRESH,
2983
- description: "Reload the connected browser tab. Pass { hard: true } to bypass the browser cache (equivalent to Cmd+Shift+R). The SDK reconnects automatically after the reload.",
2984
- inputSchema: {
2985
- hard: z5.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
2986
- ...sessionIdShape2
2987
- },
2988
- outputSchema: {
2989
- ok: z5.boolean()
2990
- },
2991
- handler: async (deps, args) => {
2992
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.REFRESH, {
2993
- hard: args["hard"] === true
2994
- });
2995
- return { ok: true };
2996
- }
2997
- }
2998
- ];
2999
-
3000
- // ../server/dist/flows/flow-tools.js
3001
- import { z as z6 } from "zod";
3002
-
3003
- // ../server/dist/flows/flow-replay.js
3004
- function editDistance(a, b) {
3005
- const s = a.toLowerCase();
3006
- const t = b.toLowerCase();
3007
- const rows = s.length + 1;
3008
- const cols = t.length + 1;
3009
- const prev = new Array(cols);
3010
- const curr = new Array(cols);
3011
- for (let j = 0; j < cols; j++)
3012
- prev[j] = j;
3013
- for (let i = 1; i < rows; i++) {
3014
- curr[0] = i;
3015
- for (let j = 1; j < cols; j++) {
3016
- const cost = s[i - 1] === t[j - 1] ? 0 : 1;
3017
- curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
3018
- }
3019
- for (let j = 0; j < cols; j++)
3020
- prev[j] = curr[j] ?? 0;
3021
- }
3022
- return prev[cols - 1] ?? 0;
3562
+ function expectIsWeak(e) {
3563
+ return e !== void 0 && e.element !== void 0 && e.signal === void 0 && e.net === void 0;
3023
3564
  }
3024
- function nearestTestid(missing, present) {
3025
- let best = null;
3026
- let bestDistance = Number.POSITIVE_INFINITY;
3027
- for (const candidate of present) {
3028
- const distance = editDistance(missing, candidate);
3029
- if (distance < bestDistance || distance === bestDistance && best !== null && candidate.length < best.length || distance === bestDistance && best !== null && candidate.length === best.length && candidate < best) {
3030
- best = candidate;
3031
- bestDistance = distance;
3032
- }
3565
+ function flattenSteps(steps) {
3566
+ const out = [];
3567
+ for (const s of steps) {
3568
+ out.push(s);
3569
+ if (s.steps !== void 0)
3570
+ out.push(...flattenSteps(s.steps));
3033
3571
  }
3034
- return best;
3572
+ return out;
3035
3573
  }
3036
- function readQuery(result) {
3037
- if (!result.ok)
3038
- return { refs: [] };
3039
- const payload = asRecord3(result.result);
3040
- const elements = Array.isArray(payload["elements"]) ? payload["elements"] : [];
3041
- const refs = elements.map((e) => asString4(asRecord3(e)["ref"]) ?? "").filter((r) => r.length > 0);
3042
- const rawHint = payload["hint"];
3043
- if (typeof rawHint === "object" && rawHint !== null) {
3044
- const hint = asRecord3(rawHint);
3045
- const present = Array.isArray(hint["presentTestids"]) ? hint["presentTestids"].filter((t) => typeof t === "string") : [];
3046
- return {
3047
- refs,
3048
- hint: {
3049
- route: asString4(hint["route"]) ?? "",
3050
- presentTestids: present,
3051
- presentRegions: [],
3052
- knownEmptyState: hint["knownEmptyState"] === true
3053
- }
3054
- };
3574
+ function classifyFlowAssertions(flow) {
3575
+ const all = flattenSteps(flow.steps);
3576
+ let consequenceSteps = 0;
3577
+ let weakSteps = 0;
3578
+ for (const s of all) {
3579
+ if (expectIsConsequence(s.expect))
3580
+ consequenceSteps++;
3581
+ else if (expectIsWeak(s.expect))
3582
+ weakSteps++;
3583
+ }
3584
+ const successIsConsequence = expectIsConsequence(flow.success);
3585
+ const successIsWeak = expectIsWeak(flow.success);
3586
+ const hasConsequenceAssertion = consequenceSteps > 0 || successIsConsequence;
3587
+ const hasAnyAssertion = hasConsequenceAssertion || weakSteps > 0 || successIsWeak;
3588
+ let grade;
3589
+ let warning;
3590
+ if (hasConsequenceAssertion) {
3591
+ grade = FlowAssertionGrade.ASSERTED;
3592
+ } else if (hasAnyAssertion) {
3593
+ grade = FlowAssertionGrade.PRESENCE_ONLY;
3594
+ warning = PRESENCE_ONLY_WARNING;
3595
+ } else {
3596
+ grade = FlowAssertionGrade.ASSERTION_FREE;
3597
+ warning = ASSERTION_FREE_WARNING;
3055
3598
  }
3056
- return { refs };
3057
- }
3058
- function testidDrift(value, hint) {
3059
3599
  return {
3060
- reasonKind: DriftReason.TESTID_NOT_FOUND,
3061
- reason: `testid "${value}" not found`,
3062
- anchor: value,
3063
- nearest: nearestTestid(value, hint?.presentTestids ?? [])
3600
+ grade,
3601
+ hasConsequenceAssertion,
3602
+ totalSteps: all.length,
3603
+ consequenceSteps,
3604
+ weakSteps,
3605
+ successIsConsequence,
3606
+ ...warning !== void 0 ? { warning } : {}
3064
3607
  };
3065
3608
  }
3066
- function anchorLabel(anchor) {
3067
- if (anchor.kind === AnchorKind.TESTID)
3068
- return anchor.value;
3069
- if (anchor.kind === AnchorKind.SIGNAL)
3070
- return anchor.name;
3071
- return anchor.name ?? anchor.role;
3609
+
3610
+ // ../server/dist/flows/flow-success.js
3611
+ function dynamicTestids(flow) {
3612
+ return new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
3072
3613
  }
3073
- async function runTestidStep(session, step, index, value, dynamic) {
3074
- const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value });
3075
- const { refs, hint } = readQuery(queryResult);
3076
- if (refs.length === 0) {
3077
- return {
3078
- step: index,
3079
- tool: step.tool,
3080
- anchor: value,
3081
- ok: false,
3082
- drift: testidDrift(value, hint)
3083
- };
3614
+ function successLabel(success) {
3615
+ if (success.signal !== void 0)
3616
+ return success.signal;
3617
+ if (success.net !== void 0)
3618
+ return success.net.urlContains ?? success.net.method ?? "net";
3619
+ return success.element?.testid ?? success.element?.name ?? success.element?.role ?? "success";
3620
+ }
3621
+ function successToPredicate(success, dynamic) {
3622
+ const parts = [];
3623
+ if (success.signal !== void 0) {
3624
+ parts.push(success.signalData !== void 0 ? { kind: "signal", name: success.signal, dataMatches: success.signalData } : { kind: "signal", name: success.signal });
3084
3625
  }
3085
- const ref = refs[0] ?? "";
3086
- const note = refs.length > 1 ? `ambiguous testid '${value}', used first match` : void 0;
3087
- const act = await session.command(IrisCommand.ACT, {
3088
- ref,
3089
- action: step.action ?? "",
3090
- args: step.args ?? {}
3091
- });
3092
- const result = { step: index, tool: step.tool, anchor: value, ok: act.ok };
3093
- if (!act.ok) {
3094
- result.error = act.error ?? "command failed";
3095
- if (note !== void 0)
3096
- result.note = note;
3097
- return result;
3626
+ if (success.net !== void 0) {
3627
+ const net = { kind: "net" };
3628
+ if (success.net.method !== void 0)
3629
+ net.method = success.net.method;
3630
+ if (success.net.urlContains !== void 0)
3631
+ net.urlContains = success.net.urlContains;
3632
+ if (success.net.status !== void 0)
3633
+ net.status = success.net.status;
3634
+ parts.push(net);
3098
3635
  }
3099
- const expectTestid = step.expect?.element?.testid;
3100
- if (expectTestid !== void 0 && !dynamic.has(expectTestid)) {
3101
- const expectQuery = await session.command(IrisCommand.QUERY, {
3102
- by: QueryBy.TESTID,
3103
- value: expectTestid
3104
- });
3105
- const expectRefs = readQuery(expectQuery);
3106
- if (expectRefs.refs.length === 0) {
3107
- return {
3108
- step: index,
3109
- tool: step.tool,
3110
- anchor: expectTestid,
3111
- ok: false,
3112
- drift: testidDrift(expectTestid, expectRefs.hint)
3113
- };
3636
+ const element = success.element;
3637
+ if (element !== void 0) {
3638
+ const testid = element.testid;
3639
+ if (testid === void 0 || !dynamic.has(testid)) {
3640
+ const query = {};
3641
+ if (testid !== void 0)
3642
+ query["testid"] = testid;
3643
+ if (element.role !== void 0)
3644
+ query["role"] = element.role;
3645
+ if (element.name !== void 0)
3646
+ query["name"] = element.name;
3647
+ if (Object.keys(query).length > 0)
3648
+ parts.push({ kind: "element", query });
3114
3649
  }
3115
3650
  }
3116
- if (note !== void 0)
3117
- result.note = note;
3118
- return result;
3651
+ const [first] = parts;
3652
+ if (parts.length === 0)
3653
+ return void 0;
3654
+ if (parts.length === 1 && first !== void 0)
3655
+ return first;
3656
+ return { kind: "allOf", predicates: parts };
3119
3657
  }
3120
- async function runSignalStep(session, step, index, name, waitForSignal, signalTimeoutMs) {
3121
- const verdict = await waitForSignal(session, { kind: "signal", name }, signalTimeoutMs);
3122
- if (verdict.pass)
3123
- return { step: index, tool: step.tool, anchor: name, ok: true };
3124
- return {
3125
- step: index,
3126
- tool: step.tool,
3127
- anchor: name,
3128
- ok: false,
3129
- drift: {
3130
- reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
3131
- reason: `signal "${name}" not observed`,
3132
- anchor: name,
3133
- nearest: null
3134
- }
3135
- };
3658
+ async function assertSuccess(session, success, dynamic, waitForSignal, timeoutMs, since = 0) {
3659
+ if (success === void 0)
3660
+ return { pass: true };
3661
+ const predicate = successToPredicate(success, dynamic);
3662
+ if (predicate === void 0)
3663
+ return { pass: true };
3664
+ return waitForSignal(session, predicate, timeoutMs, since);
3136
3665
  }
3137
- async function replayFlow(session, flow, waitForSignal, signalTimeoutMs) {
3138
- const results = [];
3139
- const dynamic = new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
3140
- let index = 0;
3141
- for (const step of flow.steps) {
3142
- const label = anchorLabel(step.anchor);
3143
- let result;
3144
- if (step.anchor.kind === AnchorKind.SIGNAL) {
3145
- result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs);
3146
- } else {
3147
- result = await runTestidStep(session, step, index, label, dynamic);
3148
- }
3149
- results.push(result);
3150
- if (result.drift !== void 0 || !result.ok)
3151
- break;
3152
- index += 1;
3666
+
3667
+ // ../server/dist/domain/flow-risk.js
3668
+ var RiskLevel = {
3669
+ HIGH: "high",
3670
+ MEDIUM: "medium",
3671
+ LOW: "low",
3672
+ UNKNOWN: "unknown"
3673
+ };
3674
+ var RANK = {
3675
+ [RiskLevel.HIGH]: 3,
3676
+ [RiskLevel.MEDIUM]: 2,
3677
+ [RiskLevel.UNKNOWN]: 1,
3678
+ [RiskLevel.LOW]: 0
3679
+ };
3680
+ function latestRun(name, runs) {
3681
+ let best;
3682
+ for (const run of runs) {
3683
+ if (run.name === name && (best === void 0 || run.at > best.at))
3684
+ best = run;
3153
3685
  }
3154
- return results;
3686
+ return best;
3687
+ }
3688
+ function runRisk(run) {
3689
+ if (run === void 0)
3690
+ return { level: RiskLevel.UNKNOWN, reason: "never run" };
3691
+ if (run.status === RunStatus.ERROR || run.status === RunStatus.FAIL) {
3692
+ return { level: RiskLevel.HIGH, reason: "last run failed" };
3693
+ }
3694
+ if (run.status === RunStatus.DRIFT)
3695
+ return { level: RiskLevel.HIGH, reason: "last run drifted" };
3696
+ const errors = (run.evidence?.consoleErrors ?? 0) + (run.evidence?.networkErrors ?? 0);
3697
+ if (errors > 0) {
3698
+ return {
3699
+ level: RiskLevel.MEDIUM,
3700
+ reason: `last run passed but logged ${String(errors)} error(s)`
3701
+ };
3702
+ }
3703
+ return { level: RiskLevel.LOW, reason: "last run passed clean" };
3704
+ }
3705
+ function gradeRisk(grade) {
3706
+ if (grade === FlowAssertionGrade.ASSERTION_FREE) {
3707
+ return {
3708
+ level: RiskLevel.MEDIUM,
3709
+ reason: "asserts no consequence \u2014 a green run proves little"
3710
+ };
3711
+ }
3712
+ if (grade === FlowAssertionGrade.PRESENCE_ONLY) {
3713
+ return { level: RiskLevel.LOW, reason: "presence-only assertion" };
3714
+ }
3715
+ return { level: RiskLevel.LOW, reason: "asserts a consequence" };
3716
+ }
3717
+ function flowRisk(grade, run) {
3718
+ const r = runRisk(run);
3719
+ const g = gradeRisk(grade);
3720
+ const top = RANK[r.level] >= RANK[g.level] ? r : g;
3721
+ return run === void 0 ? { level: top.level, reason: top.reason } : { level: top.level, reason: top.reason, lastStatus: run.status };
3722
+ }
3723
+ function rankByRisk(entries) {
3724
+ return [...entries].sort((a, b) => RANK[b.risk.level] - RANK[a.risk.level] || a.name.localeCompare(b.name)).map((e) => e.name);
3155
3725
  }
3156
3726
 
3157
- // ../server/dist/flows/heal.js
3158
- function confidenceFor(from, to) {
3159
- if (from === to)
3160
- return 1;
3161
- const span = Math.max(from.length, to.length);
3162
- if (span === 0)
3163
- return 1;
3164
- const raw = 1 - editDistance(from, to) / span;
3165
- if (raw >= 1)
3166
- return 1;
3167
- if (raw <= 0)
3168
- return Number.EPSILON;
3169
- return raw;
3727
+ // ../server/dist/domain/domain-model.js
3728
+ function flatten(steps) {
3729
+ const out = [];
3730
+ for (const s of steps) {
3731
+ out.push(s);
3732
+ if (s.steps !== void 0)
3733
+ out.push(...flatten(s.steps));
3734
+ }
3735
+ return out;
3170
3736
  }
3171
- function proposeRebindWith(drift, step, minConfidence) {
3172
- if (drift.reasonKind !== DriftReason.TESTID_NOT_FOUND)
3173
- return void 0;
3174
- const to = drift.nearest;
3175
- if (to === null)
3176
- return void 0;
3177
- const confidence = confidenceFor(drift.anchor, to);
3178
- if (confidence < minConfidence)
3179
- return void 0;
3180
- return { step, from: drift.anchor, to, confidence };
3737
+ function flowSignals(flow) {
3738
+ const set = /* @__PURE__ */ new Set();
3739
+ for (const step of flatten(flow.steps)) {
3740
+ if (step.anchor.kind === AnchorKind.SIGNAL)
3741
+ set.add(step.anchor.name);
3742
+ if (step.expect?.signal !== void 0)
3743
+ set.add(step.expect.signal);
3744
+ }
3745
+ if (flow.success?.signal !== void 0)
3746
+ set.add(flow.success.signal);
3747
+ return [...set];
3748
+ }
3749
+ function flowTestids(flow) {
3750
+ const set = /* @__PURE__ */ new Set();
3751
+ for (const step of flatten(flow.steps)) {
3752
+ if (step.anchor.kind === AnchorKind.TESTID)
3753
+ set.add(step.anchor.value);
3754
+ if (step.expect?.element?.testid !== void 0)
3755
+ set.add(step.expect.element.testid);
3756
+ }
3757
+ if (flow.success?.element?.testid !== void 0)
3758
+ set.add(flow.success.element.testid);
3759
+ return [...set];
3760
+ }
3761
+ var EMPTY_CONTRACT = { testids: [], signals: [], stores: [], flows: [] };
3762
+ function buildDomainModel(flows, contract, runs = []) {
3763
+ const caps = contract ?? EMPTY_CONTRACT;
3764
+ const hasHistory = runs.length > 0;
3765
+ const flowSummaries = flows.map((flow) => {
3766
+ const c = classifyFlowAssertions(flow);
3767
+ const summary = {
3768
+ name: flow.name,
3769
+ steps: c.totalSteps,
3770
+ grade: c.grade,
3771
+ asserts: c.hasConsequenceAssertion,
3772
+ signals: flowSignals(flow),
3773
+ testids: flowTestids(flow)
3774
+ };
3775
+ if (flow.success !== void 0)
3776
+ summary.mustHold = successLabel(flow.success);
3777
+ if (c.warning !== void 0)
3778
+ summary.warning = c.warning;
3779
+ if (hasHistory)
3780
+ summary.risk = flowRisk(c.grade, latestRun(flow.name, runs));
3781
+ return summary;
3782
+ });
3783
+ const testedSignals = new Set(flowSummaries.flatMap((f) => f.signals));
3784
+ const testedTestids = new Set(flowSummaries.flatMap((f) => f.testids));
3785
+ const coverage = {
3786
+ asserted: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.ASSERTED).length,
3787
+ presenceOnly: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.PRESENCE_ONLY).length,
3788
+ assertionFree: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.ASSERTION_FREE).length
3789
+ };
3790
+ const gaps = {
3791
+ unassertedFlows: flowSummaries.filter((f) => !f.asserts).map((f) => f.name),
3792
+ declaredUntestedSignals: caps.signals.filter((s) => !testedSignals.has(s)),
3793
+ declaredUntestedTestids: caps.testids.filter((t) => !testedTestids.has(t))
3794
+ };
3795
+ const riskRanked = hasHistory ? rankByRisk(flowSummaries.filter((f) => f.risk !== void 0).map((f) => ({ name: f.name, risk: f.risk }))) : [];
3796
+ const top = riskRanked[0];
3797
+ const topFlow = top === void 0 ? void 0 : flowSummaries.find((f) => f.name === top);
3798
+ const topRisk = topFlow?.risk !== void 0 && (topFlow.risk.level === RiskLevel.HIGH || topFlow.risk.level === RiskLevel.MEDIUM) ? { name: topFlow.name, reason: topFlow.risk.reason } : void 0;
3799
+ return {
3800
+ flowCount: flows.length,
3801
+ flows: flowSummaries,
3802
+ declared: { testids: caps.testids.length, signals: caps.signals, stores: caps.stores },
3803
+ coverage,
3804
+ gaps,
3805
+ riskRanked,
3806
+ summary: buildSummary(flows.length, coverage, gaps, topRisk)
3807
+ };
3181
3808
  }
3182
- function collectProposals(steps, minConfidence = HEAL_CONFIDENCE_MIN) {
3183
- const proposals = [];
3184
- for (const step of steps) {
3185
- if (step.drift === void 0)
3186
- continue;
3187
- const proposal = proposeRebindWith(step.drift, step.step, minConfidence);
3188
- if (proposal !== void 0)
3189
- proposals.push(proposal);
3809
+ function buildSummary(flowCount, coverage, gaps, topRisk) {
3810
+ if (flowCount === 0) {
3811
+ return "No saved flows yet \u2014 record the critical journeys (iris_record_start) so the agent learns the app.";
3190
3812
  }
3191
- return proposals;
3813
+ const parts = [
3814
+ `${String(flowCount)} flow${flowCount === 1 ? "" : "s"}: ${String(coverage.asserted)} asserted, ${String(coverage.presenceOnly)} presence-only, ${String(coverage.assertionFree)} assertion-free`
3815
+ ];
3816
+ if (topRisk !== void 0) {
3817
+ parts.push(`test first: ${topRisk.name} (${topRisk.reason})`);
3818
+ }
3819
+ if (gaps.declaredUntestedSignals.length > 0) {
3820
+ parts.push(`${String(gaps.declaredUntestedSignals.length)} declared signal(s) no flow asserts (${gaps.declaredUntestedSignals.join(", ")})`);
3821
+ }
3822
+ if (gaps.unassertedFlows.length > 0) {
3823
+ parts.push(`${String(gaps.unassertedFlows.length)} flow(s) assert no consequence`);
3824
+ }
3825
+ return parts.join(". ") + ".";
3826
+ }
3827
+
3828
+ // ../server/dist/domain/domain-tools.js
3829
+ var DOMAIN_TOOLS = [
3830
+ {
3831
+ name: IrisTool.DOMAIN,
3832
+ description: "Read the app domain model BEFORE testing: every saved flow with its assertion grade, the consequence that MUST hold for it (mustHold = what it actually tests), the anchors/signals it exercises, plus GAPS \u2014 declared signals/testids that NO flow asserts (untested intent), and flows that assert no observable consequence. Use this to decide what to test and where the real risk is, instead of crawling the whole app. Reads .iris/flows/ + .iris/contract.json (no browser needed).",
3833
+ inputSchema: {},
3834
+ outputSchema: {
3835
+ flowCount: z5.number(),
3836
+ flows: z5.array(z5.object({
3837
+ name: z5.string(),
3838
+ steps: z5.number(),
3839
+ grade: z5.string(),
3840
+ asserts: z5.boolean(),
3841
+ mustHold: z5.string().optional().describe("The success consequence that must hold for this flow (what it actually tests)."),
3842
+ warning: z5.string().optional(),
3843
+ signals: z5.array(z5.string()),
3844
+ testids: z5.array(z5.string())
3845
+ })),
3846
+ declared: z5.object({
3847
+ testids: z5.number(),
3848
+ signals: z5.array(z5.string()),
3849
+ stores: z5.array(z5.string())
3850
+ }),
3851
+ coverage: z5.object({
3852
+ asserted: z5.number(),
3853
+ presenceOnly: z5.number(),
3854
+ assertionFree: z5.number()
3855
+ }),
3856
+ gaps: z5.object({
3857
+ unassertedFlows: z5.array(z5.string()),
3858
+ declaredUntestedSignals: z5.array(z5.string()),
3859
+ declaredUntestedTestids: z5.array(z5.string())
3860
+ }),
3861
+ riskRanked: z5.array(z5.string()).describe("Flow names worst-risk first (run history + assertion quality). Test these first."),
3862
+ summary: z5.string()
3863
+ },
3864
+ handler: async (deps) => {
3865
+ const names = await deps.flows.list();
3866
+ const flows = [];
3867
+ for (const name of names) {
3868
+ const loaded = await deps.flows.load(name);
3869
+ if (loaded.ok)
3870
+ flows.push(loaded.value);
3871
+ }
3872
+ const contract = await readContract(deps.fs, deps.irisRoot);
3873
+ const project = await deps.project.read();
3874
+ const runs = project.ok ? project.file.runs : [];
3875
+ return buildDomainModel(flows, contract.ok ? contract.capabilities : null, runs);
3876
+ }
3877
+ }
3878
+ ];
3879
+
3880
+ // ../server/dist/tools/browser-tools.js
3881
+ import { z as z6 } from "zod";
3882
+ var sessionIdShape2 = {
3883
+ sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3884
+ };
3885
+ async function commandOrThrow2(deps, sessionId, name, args) {
3886
+ const session = deps.sessions.resolve(sessionId);
3887
+ const result = await session.command(name, args);
3888
+ if (!result.ok)
3889
+ throw new Error(result.error ?? `command '${name}' failed`);
3890
+ return result.result;
3192
3891
  }
3892
+ var BROWSER_TOOLS = [
3893
+ {
3894
+ name: IrisTool.NAVIGATE,
3895
+ description: "Navigate the connected browser tab to a URL. The SDK reconnects automatically after the page loads. Use iris_sessions to confirm the new tab is connected before acting.",
3896
+ inputSchema: {
3897
+ url: z6.string().describe("The URL to navigate to."),
3898
+ ...sessionIdShape2
3899
+ },
3900
+ outputSchema: {
3901
+ ok: z6.boolean(),
3902
+ url: z6.string().optional(),
3903
+ reason: z6.string().optional()
3904
+ },
3905
+ handler: async (deps, args) => {
3906
+ const url = asString(args["url"]);
3907
+ if (url === void 0 || url.length === 0)
3908
+ return { ok: false, reason: "url required" };
3909
+ const result = await commandOrThrow2(deps, asString(args["sessionId"]), IrisCommand.NAVIGATE, { url });
3910
+ return {
3911
+ ok: result.ok === true,
3912
+ ...typeof result.url === "string" ? { url: result.url } : {},
3913
+ ...typeof result.reason === "string" ? { reason: result.reason } : {}
3914
+ };
3915
+ }
3916
+ },
3917
+ {
3918
+ name: IrisTool.REFRESH,
3919
+ description: "Reload the connected browser tab. Pass { hard: true } to bypass the browser cache (equivalent to Cmd+Shift+R). The SDK reconnects automatically after the reload.",
3920
+ inputSchema: {
3921
+ hard: z6.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
3922
+ ...sessionIdShape2
3923
+ },
3924
+ outputSchema: {
3925
+ ok: z6.boolean()
3926
+ },
3927
+ handler: async (deps, args) => {
3928
+ await commandOrThrow2(deps, asString(args["sessionId"]), IrisCommand.REFRESH, {
3929
+ hard: args["hard"] === true
3930
+ });
3931
+ return { ok: true };
3932
+ }
3933
+ }
3934
+ ];
3193
3935
 
3194
3936
  // ../server/dist/flows/flow-tools.js
3937
+ import { z as z7 } from "zod";
3195
3938
  function latestRecordedFlow(events) {
3196
3939
  for (let i = events.length - 1; i >= 0; i--) {
3197
3940
  const event = events[i];
@@ -3237,18 +3980,26 @@ async function recordReplayRun(deps, name, status, driftSteps, durationMs) {
3237
3980
  var FLOW_TOOLS = [
3238
3981
  {
3239
3982
  name: IrisTool.FLOW_SAVE,
3240
- description: 'Persist the last/active recording (by name) as a git-checked, anchor-resolved flow at .iris/flows/<name>.json. Each step is bound to a SEMANTIC anchor (testid/role/signal), never a volatile ref; steps without a resolvable testid are kept with degraded:true (a "add a data-testid here" marker) rather than dropped. Returns { name, stepCount, degraded, empty } or { error, code }.',
3983
+ description: 'Persist the last/active recording (by name) as a git-checked, anchor-resolved flow at .iris/flows/<name>.json. Each step is bound to a SEMANTIC anchor (testid/role/signal), never a volatile ref; steps without a resolvable testid are kept with degraded:true (a "add a data-testid here" marker) rather than dropped. Returns { name, stepCount, degraded, empty, assertions } \u2014 `assertions.grade` is asserted | presence-only | assertion-free: a flow that only acts (or only checks element presence) will pass even if the feature breaks, so when grade is not "asserted" follow assertions.warning and add a consequence assertion via iris_annotate (assert-signal / assert-net / success-state).',
3241
3984
  inputSchema: {
3242
- flowName: z6.string().describe("Name for the flow file (saved to .iris/flows/<flowName>.json). Use again in iris_flow_load/iris_flow_replay.")
3985
+ flowName: z7.string().describe("Name for the flow file (saved to .iris/flows/<flowName>.json). Use again in iris_flow_load/iris_flow_replay.")
3243
3986
  },
3244
3987
  outputSchema: {
3245
- saved: z6.boolean(),
3246
- path: z6.string(),
3247
- stepCount: z6.number().optional(),
3248
- degraded: z6.number().optional()
3988
+ saved: z7.boolean(),
3989
+ path: z7.string(),
3990
+ stepCount: z7.number().optional(),
3991
+ degraded: z7.number().optional(),
3992
+ assertions: z7.object({
3993
+ grade: z7.string().describe("asserted | presence-only | assertion-free"),
3994
+ hasConsequenceAssertion: z7.boolean(),
3995
+ totalSteps: z7.number(),
3996
+ consequenceSteps: z7.number(),
3997
+ weakSteps: z7.number(),
3998
+ warning: z7.string().optional()
3999
+ }).optional()
3249
4000
  },
3250
4001
  handler: (deps, args) => {
3251
- const name = asString4(args["flowName"]) ?? "";
4002
+ const name = asString(args["flowName"]) ?? "";
3252
4003
  const program = deps.recordings.getCompiled(name);
3253
4004
  if (program === void 0) {
3254
4005
  return Promise.resolve({
@@ -3262,10 +4013,12 @@ var FLOW_TOOLS = [
3262
4013
  dynamic: deps.annotations.dynamic(name),
3263
4014
  ...success !== void 0 ? { success } : {}
3264
4015
  };
3265
- return deps.flows.save(program, annotations).then((res) => {
3266
- if (res.ok)
3267
- deps.annotations.clear(name);
3268
- return res.ok ? res.value : { error: flowErrorMessage(res.code), code: res.code };
4016
+ return deps.flows.save(program, annotations).then(async (res) => {
4017
+ if (!res.ok)
4018
+ return { error: flowErrorMessage(res.code), code: res.code };
4019
+ deps.annotations.clear(name);
4020
+ const loaded = await deps.flows.load(res.value.name);
4021
+ return loaded.ok ? { ...res.value, assertions: classifyFlowAssertions(loaded.value) } : res.value;
3269
4022
  });
3270
4023
  }
3271
4024
  },
@@ -3274,22 +4027,27 @@ var FLOW_TOOLS = [
3274
4027
  description: "List saved flow names under .iris/flows (a fresh agent learns the demonstrated journeys without a browser).",
3275
4028
  inputSchema: {},
3276
4029
  outputSchema: {
3277
- flows: z6.array(z6.object({ name: z6.string(), path: z6.string(), createdAt: z6.number().optional() }))
4030
+ flows: z7.array(z7.object({ name: z7.string(), path: z7.string(), createdAt: z7.number().optional() }))
3278
4031
  },
3279
- handler: (deps) => deps.flows.list().then((flows) => ({ flows }))
4032
+ // Return {name, path} objects to MATCH the declared outputSchema. Returning bare name strings
4033
+ // (the prior bug) made schema-validating MCP clients reject the result ("expected object,
4034
+ // received string") — caught driving the live demo.
4035
+ handler: (deps) => deps.flows.list().then((names) => ({
4036
+ flows: names.map((name) => ({ name, path: flowPath(deps.irisRoot, name) }))
4037
+ }))
3280
4038
  },
3281
4039
  {
3282
4040
  name: IrisTool.FLOW_LOAD,
3283
4041
  description: "Read + validate a saved flow by flowName from .iris/flows/<flowName>.json. Returns the FlowFile (version, flowName, createdAt, anchored steps) or a structured { error, code }.",
3284
4042
  inputSchema: {
3285
- flowName: z6.string().describe("Flow file name (without .json extension) from iris_flow_list.")
4043
+ flowName: z7.string().describe("Flow file name (without .json extension) from iris_flow_list.")
3286
4044
  },
3287
4045
  outputSchema: {
3288
- flowName: z6.string(),
3289
- steps: z6.array(z6.unknown()),
3290
- createdAt: z6.number().optional()
4046
+ flowName: z7.string(),
4047
+ steps: z7.array(z7.unknown()),
4048
+ createdAt: z7.number().optional()
3291
4049
  },
3292
- handler: (deps, args) => deps.flows.load(asString4(args["flowName"]) ?? "").then((res) => {
4050
+ handler: (deps, args) => deps.flows.load(asString(args["flowName"]) ?? "").then((res) => {
3293
4051
  if (!res.ok)
3294
4052
  return { error: flowErrorMessage(res.code), code: res.code };
3295
4053
  const { name, ...rest } = res.value;
@@ -3298,20 +4056,21 @@ var FLOW_TOOLS = [
3298
4056
  },
3299
4057
  {
3300
4058
  name: IrisTool.FLOW_REPLAY,
3301
- description: `Replay a git-checked flow from .iris/flows/<name>.json. RE-RESOLVES each step's semantic anchor (testid via iris_query; signal via predicate) against the LIVE DOM \u2014 never reuses a stale ref. On an anchor MISS returns legible DRIFT { step, anchor, drift:{ reasonKind, reason, nearest } } (the closest surviving testid) and stops \u2014 the "whose fault is it" contract. Returns { name, status: ok|drift|error, steps:[...] }; a missing/malformed file is status:error with a structured code (distinct from a contract-changed drift).`,
4059
+ description: `Replay a git-checked flow from .iris/flows/<name>.json. RE-RESOLVES each step's semantic anchor (testid via iris_query; signal via predicate) against the LIVE DOM \u2014 never reuses a stale ref. On an anchor MISS returns legible DRIFT { step, anchor, drift:{ reasonKind, reason, nearest } } (the closest surviving testid) and stops \u2014 the "whose fault is it" contract. Returns { name, status: ok|drift|error, steps:[...] }; missing/malformed files and action failures are status:error with a structured code (distinct from contract-changed drift).`,
3302
4060
  inputSchema: {
3303
- flowName: z6.string().describe("Flow file name (without .json extension) from iris_flow_list."),
3304
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4061
+ flowName: z7.string().describe("Flow file name (without .json extension) from iris_flow_list."),
4062
+ confirmDangerous: z7.boolean().optional().describe("Set true to allow destructive controls during this replay only."),
4063
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3305
4064
  },
3306
4065
  outputSchema: {
3307
- status: z6.string().describe("ok | drift | error"),
3308
- steps: z6.array(z6.unknown()),
3309
- proposals: z6.array(z6.unknown()).optional(),
3310
- error: z6.object({ code: z6.string(), message: z6.string() }).optional()
4066
+ status: z7.string().describe("ok | drift | error"),
4067
+ steps: z7.array(z7.unknown()),
4068
+ proposals: z7.array(z7.unknown()).optional(),
4069
+ error: z7.object({ code: z7.string(), message: z7.string() }).optional()
3311
4070
  },
3312
4071
  handler: async (deps, args) => {
3313
4072
  const startedAt = deps.now();
3314
- const name = asString4(args["flowName"]) ?? "";
4073
+ const name = asString(args["flowName"]) ?? "";
3315
4074
  const loaded = await deps.flows.load(name);
3316
4075
  if (!loaded.ok) {
3317
4076
  await recordReplayRun(deps, name, ReplayStatus.ERROR, 0, deps.now() - startedAt);
@@ -3322,12 +4081,35 @@ var FLOW_TOOLS = [
3322
4081
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3323
4082
  };
3324
4083
  }
3325
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3326
- const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS);
4084
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4085
+ const replayFloor = session.elapsed();
4086
+ const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
4087
+ const stepsClean = steps.length > 0 && steps.every((s) => s.ok && s.drift === void 0);
4088
+ if (stepsClean && loaded.value.success !== void 0) {
4089
+ const verdict = await assertSuccess(session, loaded.value.success, dynamicTestids(loaded.value), waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, replayFloor);
4090
+ const row = {
4091
+ step: steps.length,
4092
+ tool: "success",
4093
+ anchor: successLabel(loaded.value.success),
4094
+ ok: verdict.pass,
4095
+ ...verdict.pass ? {} : { error: verdict.failureReason ?? "flow.success not satisfied" }
4096
+ };
4097
+ steps.push(row);
4098
+ }
3327
4099
  const driftSteps = steps.filter((s) => s.drift !== void 0).length;
3328
4100
  const allOk = steps.every((s) => s.ok);
3329
- const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.DRIFT;
4101
+ const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.ERROR;
3330
4102
  await recordReplayRun(deps, name, status, driftSteps, deps.now() - startedAt);
4103
+ const failed = steps.find((step) => !step.ok && step.drift === void 0);
4104
+ if (failed !== void 0) {
4105
+ const message = failed.error ?? "flow action failed";
4106
+ return {
4107
+ name,
4108
+ status,
4109
+ steps,
4110
+ error: { code: ReplayStatus.ERROR, message }
4111
+ };
4112
+ }
3331
4113
  return { name, status, steps };
3332
4114
  }
3333
4115
  },
@@ -3335,20 +4117,20 @@ var FLOW_TOOLS = [
3335
4117
  name: IrisTool.FLOW_SAVE_RECORDED,
3336
4118
  description: "Persist the HUMAN-recorded flow from the live tab. The recorder toolbar compiles the human's real clicks/inputs into a semantically anchored FlowFile in-page and emits it; this tool reads the LATEST recorded-flow from the session and writes it to .iris/flows/<name>.json (no recompilation \u2014 the browser already resolved every anchor). Pass `name` to override the recorded name. Returns { name, stepCount, degraded, empty } or { error, code } (code flow_no_recorded when no recording is present).",
3337
4119
  inputSchema: {
3338
- flowName: z6.string().optional().describe("Override the flow name embedded in the recorded flow. Omit to use the recorder-assigned name."),
4120
+ flowName: z7.string().optional().describe("Override the flow name embedded in the recorded flow. Omit to use the recorder-assigned name."),
3339
4121
  ...{
3340
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4122
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3341
4123
  }
3342
4124
  },
3343
4125
  outputSchema: {
3344
- flowName: z6.string().optional(),
3345
- stepCount: z6.number().optional(),
3346
- degraded: z6.number().optional(),
3347
- error: z6.string().optional(),
3348
- code: z6.string().optional()
4126
+ flowName: z7.string().optional(),
4127
+ stepCount: z7.number().optional(),
4128
+ degraded: z7.number().optional(),
4129
+ error: z7.string().optional(),
4130
+ code: z7.string().optional()
3349
4131
  },
3350
4132
  handler: async (deps, args) => {
3351
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4133
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
3352
4134
  const recorded = latestRecordedFlow(session.eventsSince(0));
3353
4135
  if (recorded === void 0) {
3354
4136
  return {
@@ -3356,7 +4138,7 @@ var FLOW_TOOLS = [
3356
4138
  code: RecordedSaveError.NO_RECORDED_FLOW
3357
4139
  };
3358
4140
  }
3359
- const override = asString4(args["flowName"]);
4141
+ const override = asString(args["flowName"]);
3360
4142
  const flow = override !== void 0 ? { ...recorded.flow, name: override } : recorded.flow;
3361
4143
  const res = await deps.flows.saveFlow(flow);
3362
4144
  if (!res.ok)
@@ -3367,35 +4149,38 @@ var FLOW_TOOLS = [
3367
4149
  },
3368
4150
  {
3369
4151
  name: IrisTool.FLOW_HEAL,
3370
- description: "Self-healing replay. Re-runs iris_flow_replay; on testid DRIFT computes confidence-scored nearest-match rebind PROPOSALS. With apply:false (default) returns the proposed diff WITHOUT writing. With apply:true, writes the confident rebind(s) back into .iris/flows/<name>.json and returns what changed \u2014 never silently. A drift with no proposal above the confidence floor is status:unhealable (file untouched). Returns { name, status: healed|drift|unhealable|nothing_to_heal|error, applied, proposals[], changed[], message }.",
4152
+ description: "Self-healing replay. Re-runs iris_flow_replay; on testid DRIFT computes confidence-scored nearest-match rebind PROPOSALS. With apply:false (default) returns the proposed diff WITHOUT writing. With apply:true, writes the confident rebind(s) back into .iris/flows/<name>.json and returns what changed \u2014 never silently. Before writing, apply re-replays the healed flow and re-asserts its success consequence: if the rebound locator resolves but the consequence no longer fires, the write is REFUSED (status:consequence_broken) \u2014 it heals the locator, never the intent. A drift with no proposal above the confidence floor is status:unhealable (file untouched). Returns { name, status: healed|drift|unhealable|consequence_broken|nothing_to_heal|error, applied, proposals[], changed[], message }.",
3371
4153
  inputSchema: {
3372
- flowName: z6.string().describe("Flow file name to heal (from iris_flow_list)."),
3373
- apply: z6.boolean().optional(),
3374
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4154
+ flowName: z7.string().describe("Flow file name to heal (from iris_flow_list)."),
4155
+ apply: z7.boolean().optional(),
4156
+ confirmDangerous: z7.boolean().optional().describe("Set true to allow destructive controls during this heal replay only."),
4157
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3375
4158
  },
3376
4159
  outputSchema: {
3377
- flowName: z6.string(),
3378
- status: z6.string(),
3379
- applied: z6.boolean(),
3380
- proposals: z6.array(z6.unknown()),
3381
- changed: z6.array(z6.unknown()),
3382
- message: z6.string(),
3383
- error: z6.object({ code: z6.string(), message: z6.string() }).optional()
4160
+ flowName: z7.string(),
4161
+ status: z7.string(),
4162
+ applied: z7.boolean(),
4163
+ proposals: z7.array(z7.unknown()),
4164
+ changed: z7.array(z7.unknown()),
4165
+ message: z7.string(),
4166
+ error: z7.object({ code: z7.string(), message: z7.string() }).optional()
3384
4167
  },
3385
4168
  handler: (deps, args) => healFlow(deps, args).then(({ name, ...rest }) => ({ flowName: name, ...rest }))
3386
4169
  }
3387
4170
  ];
3388
4171
  var HEAL_MESSAGES = {
3389
4172
  NOTHING: "nothing to heal \u2014 every anchor resolved on replay",
3390
- HEALED: "rewrote drifted testid anchors to their nearest surviving match",
4173
+ HEALED: "rewrote drifted testid anchors to their nearest surviving match and re-verified the flow's success consequence still fires",
3391
4174
  DRIFT_DRY: "confident rebind(s) proposed \u2014 re-run with apply:true to write them to disk",
3392
- UNHEALABLE: `drift found, but no nearest match cleared the confidence floor (HEAL_CONFIDENCE_MIN=${HEAL_CONFIDENCE_MIN}); file left untouched \u2014 add a data-testid or fix the flow by hand`
4175
+ UNHEALABLE: `drift found, but no nearest match cleared the confidence floor (HEAL_CONFIDENCE_MIN=${HEAL_CONFIDENCE_MIN}); file left untouched \u2014 add a data-testid or fix the flow by hand`,
4176
+ HEALED_UNVERIFIED: "rewrote drifted testid anchors \u2014 but this flow declares no success consequence, so the rebind resolves a locator without proving the intent still holds. Add a success-state assertion (iris_annotate) so future heals can be verified.",
4177
+ CONSEQUENCE_BROKEN: "rebind resolves the drifted locator to a surviving element, but the healed flow no longer satisfies its success consequence \u2014 refusing to write (a heal that loses the intent would ship a green-but-dead test). Fix by hand and verify"
3393
4178
  };
3394
4179
  function toChange(proposal) {
3395
4180
  return { step: proposal.step, from: proposal.from, to: proposal.to };
3396
4181
  }
3397
4182
  async function healFlow(deps, args) {
3398
- const name = asString4(args["flowName"]) ?? "";
4183
+ const name = asString(args["flowName"]) ?? "";
3399
4184
  const apply = args["apply"] === true;
3400
4185
  const loaded = await deps.flows.load(name);
3401
4186
  if (!loaded.ok) {
@@ -3409,9 +4194,22 @@ async function healFlow(deps, args) {
3409
4194
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3410
4195
  };
3411
4196
  }
3412
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3413
- const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS);
4197
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4198
+ const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
3414
4199
  const drifted = steps.some((s) => s.drift !== void 0);
4200
+ const failed = steps.find((s) => !s.ok && s.drift === void 0);
4201
+ if (failed !== void 0) {
4202
+ const message = failed.error ?? "flow replay failed before an anchor could be healed";
4203
+ return {
4204
+ name,
4205
+ status: HealStatus.ERROR,
4206
+ applied: false,
4207
+ proposals: [],
4208
+ changed: [],
4209
+ message,
4210
+ error: { code: ReplayStatus.ERROR, message }
4211
+ };
4212
+ }
3415
4213
  if (!drifted) {
3416
4214
  return {
3417
4215
  name,
@@ -3443,6 +4241,23 @@ async function healFlow(deps, args) {
3443
4241
  message: HEAL_MESSAGES.DRIFT_DRY
3444
4242
  };
3445
4243
  }
4244
+ const { flow: healed } = applyHealChanges(loaded.value, proposals.map(toChange));
4245
+ if (healed.success !== void 0) {
4246
+ const verifyFloor = session.elapsed();
4247
+ const verifySteps = await replayFlow(session, healed, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
4248
+ const verifyClean = verifySteps.length > 0 && verifySteps.every((s) => s.ok && s.drift === void 0);
4249
+ const verdict = verifyClean ? await assertSuccess(session, healed.success, dynamicTestids(healed), waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, verifyFloor) : { pass: false, failureReason: "healed flow did not replay cleanly" };
4250
+ if (!verdict.pass) {
4251
+ return {
4252
+ name,
4253
+ status: HealStatus.CONSEQUENCE_BROKEN,
4254
+ applied: false,
4255
+ proposals,
4256
+ changed: [],
4257
+ message: `${HEAL_MESSAGES.CONSEQUENCE_BROKEN} (${successLabel(healed.success)}: ${verdict.failureReason ?? "not satisfied"})`
4258
+ };
4259
+ }
4260
+ }
3446
4261
  const written = await deps.flows.heal(name, proposals.map(toChange));
3447
4262
  if (!written.ok) {
3448
4263
  return {
@@ -3461,14 +4276,14 @@ async function healFlow(deps, args) {
3461
4276
  applied: written.value.changed.length > 0,
3462
4277
  proposals,
3463
4278
  changed: written.value.changed,
3464
- message: HEAL_MESSAGES.HEALED
4279
+ message: loaded.value.success !== void 0 ? HEAL_MESSAGES.HEALED : HEAL_MESSAGES.HEALED_UNVERIFIED
3465
4280
  };
3466
4281
  }
3467
4282
 
3468
4283
  // ../server/dist/project/project-tools.js
3469
- import { z as z7 } from "zod";
4284
+ import { z as z8 } from "zod";
3470
4285
  var sessionIdShape3 = {
3471
- sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4286
+ sessionId: z8.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3472
4287
  };
3473
4288
  var REGRESSION_STATUSES = /* @__PURE__ */ new Set([
3474
4289
  RunStatus.FAIL,
@@ -3509,12 +4324,12 @@ var PROJECT_TOOLS = [
3509
4324
  name: IrisTool.PROJECT,
3510
4325
  description: 'Read cross-run history from .iris/project.json \u2014 the memory of how past runs behaved. With { name } it also returns the last run for that flow plus a diff-vs-last summary (status change, regressed flag, consoleErrors/driftSteps deltas) so you can answer "did it behave like last time?". Returns { runs, learned?, lastRun?, diff? } or { error, reason } when no/invalid history exists.',
3511
4326
  inputSchema: {
3512
- name: z7.string().optional().describe("Filter runs by this name. Omit to return all runs."),
4327
+ name: z8.string().optional().describe("Filter runs by this name. Omit to return all runs."),
3513
4328
  ...sessionIdShape3
3514
4329
  },
3515
4330
  outputSchema: {
3516
- runs: z7.array(z7.unknown()),
3517
- diff: z7.unknown().optional()
4331
+ runs: z8.array(z8.unknown()),
4332
+ diff: z8.unknown().optional()
3518
4333
  },
3519
4334
  handler: async (deps, args) => {
3520
4335
  const read = await deps.project.read();
@@ -3524,7 +4339,7 @@ var PROJECT_TOOLS = [
3524
4339
  reason: read.reason
3525
4340
  };
3526
4341
  }
3527
- const name = asString4(args["name"]);
4342
+ const name = asString(args["name"]);
3528
4343
  if (name === void 0) {
3529
4344
  return { runs: read.file.runs, learned: read.file.learned };
3530
4345
  }
@@ -3542,22 +4357,22 @@ var PROJECT_TOOLS = [
3542
4357
  name: IrisTool.RUN_RECORD,
3543
4358
  description: "Explicitly record a run outcome into .iris/project.json (the manual companion to the auto-record on iris_flow_replay). Use it to log the result of an assertion sequence or a manual journey so future runs can diff against it. Returns { recorded:true, name, status }.",
3544
4359
  inputSchema: {
3545
- name: z7.string().describe("Run name for grouping in iris_project history."),
3546
- status: z7.nativeEnum(RunStatus).describe("Outcome: pass | fail | drift | error"),
3547
- kind: z7.nativeEnum(RunKind).optional(),
3548
- summary: z7.string().optional().describe("One-line human summary of what this run covered."),
4360
+ name: z8.string().describe("Run name for grouping in iris_project history."),
4361
+ status: z8.nativeEnum(RunStatus).describe("Outcome: pass | fail | drift | error"),
4362
+ kind: z8.nativeEnum(RunKind).optional(),
4363
+ summary: z8.string().optional().describe("One-line human summary of what this run covered."),
3549
4364
  ...sessionIdShape3
3550
4365
  },
3551
4366
  outputSchema: {
3552
- recorded: z7.boolean(),
3553
- runName: z7.string(),
3554
- status: z7.string()
4367
+ recorded: z8.boolean(),
4368
+ runName: z8.string(),
4369
+ status: z8.string()
3555
4370
  },
3556
4371
  handler: async (deps, args) => {
3557
- const name = asString4(args["name"]) ?? "";
4372
+ const name = asString(args["name"]) ?? "";
3558
4373
  const status = args["status"];
3559
4374
  const kindArg = args["kind"];
3560
- const summary = asString4(args["summary"]);
4375
+ const summary = asString(args["summary"]);
3561
4376
  await deps.project.recordRun({
3562
4377
  kind: typeof kindArg === "string" ? kindArg : RunKind.MANUAL,
3563
4378
  name,
@@ -3570,7 +4385,7 @@ var PROJECT_TOOLS = [
3570
4385
  ];
3571
4386
 
3572
4387
  // ../server/dist/visual/visual-tools.js
3573
- import { z as z8 } from "zod";
4388
+ import { z as z9 } from "zod";
3574
4389
 
3575
4390
  // ../server/dist/visual/visual-diff.js
3576
4391
  async function loadDeps() {
@@ -3721,13 +4536,13 @@ var VisualStore = class {
3721
4536
 
3722
4537
  // ../server/dist/visual/visual-tools.js
3723
4538
  var sessionIdShape4 = {
3724
- sessionId: z8.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4539
+ sessionId: z9.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3725
4540
  };
3726
- var rectShape = z8.object({
3727
- x: z8.number(),
3728
- y: z8.number(),
3729
- width: z8.number(),
3730
- height: z8.number()
4541
+ var rectShape = z9.object({
4542
+ x: z9.number(),
4543
+ y: z9.number(),
4544
+ width: z9.number(),
4545
+ height: z9.number()
3731
4546
  });
3732
4547
  function screenshotProvider(deps) {
3733
4548
  const p = deps.realInput;
@@ -3739,11 +4554,11 @@ var noProvider = {
3739
4554
  recommendation: VISUAL_NO_PROVIDER_RECOMMENDATION
3740
4555
  };
3741
4556
  function asBox(value) {
3742
- const b = asRecord3(asRecord3(value)["box"]);
3743
- const x = asNumber2(b["x"]);
3744
- const y = asNumber2(b["y"]);
3745
- const w = asNumber2(b["width"]);
3746
- const h = asNumber2(b["height"]);
4557
+ const b = asRecord(asRecord(value)["box"]);
4558
+ const x = asNumber(b["x"]);
4559
+ const y = asNumber(b["y"]);
4560
+ const w = asNumber(b["width"]);
4561
+ const h = asNumber(b["height"]);
3747
4562
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
3748
4563
  return void 0;
3749
4564
  if (w <= 0 || h <= 0)
@@ -3753,12 +4568,12 @@ function asBox(value) {
3753
4568
  async function buildOpts(deps, sessionId, args) {
3754
4569
  const clipArg = args["clip"];
3755
4570
  if (clipArg !== void 0) {
3756
- const c = asRecord3(clipArg);
4571
+ const c = asRecord(clipArg);
3757
4572
  const box = asBox({ box: c });
3758
4573
  if (box !== void 0)
3759
4574
  return { clip: box };
3760
4575
  }
3761
- const ref = asString4(args["ref"]);
4576
+ const ref = asString(args["ref"]);
3762
4577
  if (ref !== void 0) {
3763
4578
  const session = deps.sessions.resolve(sessionId);
3764
4579
  const res = await session.command(IrisCommand.INSPECT, { ref });
@@ -3784,31 +4599,31 @@ var VISUAL_TOOLS = [
3784
4599
  name: IrisTool.SCREENSHOT,
3785
4600
  description: "Capture a pixel screenshot of the DRIVEN page (needs `iris drive`/IRIS_CDP_URL \u2014 the SDK has no screenshotter) and save it as a visual baseline at .iris/visual/<name>.png. { fullPage } for the whole scroll height, { ref } or { clip:{x,y,width,height} } for one element/region. Returns { saved:true, name, path, bytes } or { ok:false, reason } when no driven browser is attached.",
3786
4601
  inputSchema: {
3787
- name: z8.string().describe("Baseline name \u2014 saved as .iris/visual/<name>.png. Use the same name in iris_visual_diff to compare."),
3788
- fullPage: z8.boolean().optional().describe("Capture the full scroll height. Default: viewport only."),
3789
- ref: z8.string().optional().describe("Element ref to screenshot (scopes to element bounding box). Omit for full page."),
4602
+ name: z9.string().describe("Baseline name \u2014 saved as .iris/visual/<name>.png. Use the same name in iris_visual_diff to compare."),
4603
+ fullPage: z9.boolean().optional().describe("Capture the full scroll height. Default: viewport only."),
4604
+ ref: z9.string().optional().describe("Element ref to screenshot (scopes to element bounding box). Omit for full page."),
3790
4605
  clip: rectShape.optional().describe("Explicit { x, y, width, height } clip rectangle in page coordinates."),
3791
4606
  ...sessionIdShape4
3792
4607
  },
3793
4608
  outputSchema: {
3794
- ok: z8.boolean(),
3795
- saved: z8.boolean().optional(),
3796
- name: z8.string().optional(),
3797
- path: z8.string().optional(),
3798
- bytes: z8.number().optional(),
3799
- reason: z8.string().optional(),
3800
- recommendation: z8.string().optional()
4609
+ ok: z9.boolean(),
4610
+ saved: z9.boolean().optional(),
4611
+ name: z9.string().optional(),
4612
+ path: z9.string().optional(),
4613
+ bytes: z9.number().optional(),
4614
+ reason: z9.string().optional(),
4615
+ recommendation: z9.string().optional()
3801
4616
  },
3802
4617
  handler: async (deps, args) => {
3803
4618
  const provider = screenshotProvider(deps);
3804
4619
  if (provider === void 0)
3805
4620
  return noProvider;
3806
- const sessionId = asString4(args["sessionId"]);
4621
+ const sessionId = asString(args["sessionId"]);
3807
4622
  const session = deps.sessions.resolve(sessionId);
3808
4623
  const png = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3809
4624
  if (png === void 0)
3810
4625
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3811
- const name = asString4(args["name"]) ?? "default";
4626
+ const name = asString(args["name"]) ?? "default";
3812
4627
  const store = new VisualStore(deps.fs, deps.irisRoot);
3813
4628
  const path = await store.saveBaseline(name, png);
3814
4629
  return { ok: true, saved: true, name, path, bytes: png.length };
@@ -3818,39 +4633,39 @@ var VISUAL_TOOLS = [
3818
4633
  name: IrisTool.VISUAL_DIFF,
3819
4634
  description: "Perceptually diff the DRIVEN page against a saved visual baseline (see iris_screenshot). { masks:[{x,y,width,height}] } neutralizes volatile regions; { maxRatio } sets the pass tolerance (default 0). Returns { matched, changedPixels, totalPixels, ratio, region?, diffPath, dimensionMismatch } \u2014 the overlay diff is written to .iris/visual/<baseline>.diff.png \u2014 or { ok:false, reason } (no-provider / baseline-missing).",
3820
4635
  inputSchema: {
3821
- baseline: z8.string().describe("Baseline screenshot name (from iris_screenshot). Used to compare with the current screenshot."),
3822
- fullPage: z8.boolean().optional(),
3823
- ref: z8.string().optional(),
4636
+ baseline: z9.string().describe("Baseline screenshot name (from iris_screenshot). Used to compare with the current screenshot."),
4637
+ fullPage: z9.boolean().optional(),
4638
+ ref: z9.string().optional(),
3824
4639
  clip: rectShape.optional(),
3825
- masks: z8.array(rectShape).optional(),
3826
- maxRatio: z8.number().optional(),
3827
- threshold: z8.number().optional().describe("Pixel difference threshold (0\u20131). Default: 0.01."),
4640
+ masks: z9.array(rectShape).optional(),
4641
+ maxRatio: z9.number().optional(),
4642
+ threshold: z9.number().optional().describe("Pixel difference threshold (0\u20131). Default: 0.01."),
3828
4643
  ...sessionIdShape4
3829
4644
  },
3830
4645
  outputSchema: {
3831
- ok: z8.boolean(),
3832
- match: z8.boolean().optional(),
3833
- diffPct: z8.number().optional(),
3834
- diffPath: z8.string().optional(),
3835
- reason: z8.string().optional()
4646
+ ok: z9.boolean(),
4647
+ match: z9.boolean().optional(),
4648
+ diffPct: z9.number().optional(),
4649
+ diffPath: z9.string().optional(),
4650
+ reason: z9.string().optional()
3836
4651
  },
3837
4652
  handler: async (deps, args) => {
3838
4653
  const provider = screenshotProvider(deps);
3839
4654
  if (provider === void 0)
3840
4655
  return noProvider;
3841
- const baseline = asString4(args["baseline"]) ?? "";
4656
+ const baseline = asString(args["baseline"]) ?? "";
3842
4657
  const store = new VisualStore(deps.fs, deps.irisRoot);
3843
4658
  const baselineBytes = await store.readBaseline(baseline);
3844
4659
  if (baselineBytes === void 0)
3845
4660
  return { ok: false, reason: VisualReason.BASELINE_MISSING };
3846
- const sessionId = asString4(args["sessionId"]);
4661
+ const sessionId = asString(args["sessionId"]);
3847
4662
  const session = deps.sessions.resolve(sessionId);
3848
4663
  const current = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3849
4664
  if (current === void 0)
3850
4665
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3851
4666
  const masks = rectsFrom(args["masks"]);
3852
- const threshold = asNumber2(args["threshold"]);
3853
- const maxRatio = asNumber2(args["maxRatio"]);
4667
+ const threshold = asNumber(args["threshold"]);
4668
+ const maxRatio = asNumber(args["maxRatio"]);
3854
4669
  const result = await diffPng(baselineBytes, current, {
3855
4670
  ...threshold !== void 0 ? { threshold } : {},
3856
4671
  ...maxRatio !== void 0 ? { maxRatio } : {},
@@ -3867,7 +4682,7 @@ var VISUAL_TOOLS = [
3867
4682
  ];
3868
4683
 
3869
4684
  // ../server/dist/crawl/crawl-tools.js
3870
- import { z as z9 } from "zod";
4685
+ import { z as z10 } from "zod";
3871
4686
 
3872
4687
  // ../server/dist/crawl/crawl.js
3873
4688
  function isActivity(e) {
@@ -3880,7 +4695,7 @@ function failedRequests(events, floor) {
3880
4695
  return events.filter((e) => {
3881
4696
  if (e.type !== EventType.NET_REQUEST)
3882
4697
  return false;
3883
- const status = asNumber2(e.data["status"]);
4698
+ const status = asNumber(e.data["status"]);
3884
4699
  return status !== void 0 && status >= floor;
3885
4700
  });
3886
4701
  }
@@ -3910,7 +4725,7 @@ async function crawl(session, opts, sleep) {
3910
4725
  const act = await session.command(IrisCommand.ACT, {
3911
4726
  ref: item.ref,
3912
4727
  action: ActionType.CLICK,
3913
- args: {}
4728
+ args: opts.confirmDangerous === true ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {}
3914
4729
  });
3915
4730
  await sleep(settleMs);
3916
4731
  const events = session.eventsSince(since);
@@ -3921,14 +4736,14 @@ async function crawl(session, opts, sleep) {
3921
4736
  kind: CrawlAnomalyKind.CONSOLE_ERROR,
3922
4737
  ref: item.ref,
3923
4738
  desc: item.desc,
3924
- detail: asString4(e.data["message"]) ?? e.type
4739
+ detail: asString(e.data["message"]) ?? e.type
3925
4740
  });
3926
4741
  }
3927
4742
  for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
3928
4743
  counts.failedRequests += 1;
3929
- const method = asString4(e.data["method"]) ?? "";
3930
- const url = asString4(e.data["url"]) ?? "";
3931
- const status = asNumber2(e.data["status"]);
4744
+ const method = asString(e.data["method"]) ?? "";
4745
+ const url = asString(e.data["url"]) ?? "";
4746
+ const status = asNumber(e.data["status"]);
3932
4747
  anomalies.push({
3933
4748
  kind: CrawlAnomalyKind.FAILED_REQUEST,
3934
4749
  ref: item.ref,
@@ -3936,7 +4751,7 @@ async function crawl(session, opts, sleep) {
3936
4751
  detail: `${method} ${url} \u2192 ${status ?? ""}`.trim()
3937
4752
  });
3938
4753
  }
3939
- const dispatched = asRecord3(act.result)["dispatched"] !== false && act.ok;
4754
+ const dispatched = asRecord(act.result)["dispatched"] !== false && act.ok;
3940
4755
  if (dispatched && errs.length === 0 && !events.some(isActivity)) {
3941
4756
  counts.deadControls += 1;
3942
4757
  anomalies.push({
@@ -3964,32 +4779,34 @@ var CRAWL_TOOLS = [
3964
4779
  name: IrisTool.CRAWL,
3965
4780
  description: "Autonomously click every reachable interactive control (bounded by maxSteps, default 25) and report anomalies WITHOUT a script: console errors, failed requests (status \u2265 400), and DEAD controls (dispatched but the app did nothing). DESTRUCTIVE \u2014 it really clicks (may navigate/mutate state); use iris_explore first for a non-destructive list. Returns { interactiveFound, stepsRun, anomalies[{kind,ref,desc,detail}], counts, visited, truncated }.",
3966
4781
  inputSchema: {
3967
- maxSteps: z9.number().optional().describe("Maximum number of controls to click. Default: 25."),
3968
- settleMs: z9.number().optional().describe("Milliseconds to wait after each click for the app to react. Default: 500."),
3969
- scope: z9.string().optional().describe("CSS selector or element ref to restrict crawling to a subtree."),
3970
- sessionId: z9.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4782
+ maxSteps: z10.number().optional().describe("Maximum number of controls to click. Default: 25."),
4783
+ settleMs: z10.number().optional().describe("Milliseconds to wait after each click for the app to react. Default: 500."),
4784
+ scope: z10.string().optional().describe("CSS selector or element ref to restrict crawling to a subtree."),
4785
+ confirmDangerous: z10.boolean().optional().describe("Set true to allow controls classified as destructive. Default false; those controls are blocked by the browser."),
4786
+ sessionId: z10.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3971
4787
  },
3972
4788
  outputSchema: {
3973
- interactiveFound: z9.number(),
3974
- stepsRun: z9.number(),
3975
- anomalies: z9.array(z9.object({
3976
- kind: z9.string(),
3977
- ref: z9.string(),
3978
- desc: z9.string(),
3979
- detail: z9.string().optional()
4789
+ interactiveFound: z10.number(),
4790
+ stepsRun: z10.number(),
4791
+ anomalies: z10.array(z10.object({
4792
+ kind: z10.string(),
4793
+ ref: z10.string(),
4794
+ desc: z10.string(),
4795
+ detail: z10.string().optional()
3980
4796
  })),
3981
- counts: z9.record(z9.number()),
3982
- truncated: z9.boolean()
4797
+ counts: z10.record(z10.number()),
4798
+ truncated: z10.boolean()
3983
4799
  },
3984
4800
  handler: (deps, args) => {
3985
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3986
- const maxSteps = asNumber2(args["maxSteps"]);
3987
- const settleMs = asNumber2(args["settleMs"]);
3988
- const scope = asString4(args["scope"]);
4801
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4802
+ const maxSteps = asNumber(args["maxSteps"]);
4803
+ const settleMs = asNumber(args["settleMs"]);
4804
+ const scope = asString(args["scope"]);
3989
4805
  const opts = {
3990
4806
  ...maxSteps !== void 0 ? { maxSteps } : {},
3991
4807
  ...settleMs !== void 0 ? { settleMs } : {},
3992
- ...scope !== void 0 ? { scope } : {}
4808
+ ...scope !== void 0 ? { scope } : {},
4809
+ ...args["confirmDangerous"] === true ? { confirmDangerous: true } : {}
3993
4810
  };
3994
4811
  return crawl(session, opts, nodeSleep2);
3995
4812
  }
@@ -3997,7 +4814,7 @@ var CRAWL_TOOLS = [
3997
4814
  ];
3998
4815
 
3999
4816
  // ../server/dist/input/scroll-tools.js
4000
- import { z as z10 } from "zod";
4817
+ import { z as z11 } from "zod";
4001
4818
 
4002
4819
  // ../server/dist/input/scroll-find.js
4003
4820
  async function queryFirst(session, q) {
@@ -4006,9 +4823,9 @@ async function queryFirst(session, q) {
4006
4823
  value: q.value,
4007
4824
  ...q.name !== void 0 ? { name: q.name } : {}
4008
4825
  });
4009
- const elements = asRecord3(res.result)["elements"];
4826
+ const elements = asRecord(res.result)["elements"];
4010
4827
  if (Array.isArray(elements) && elements.length > 0)
4011
- return asRecord3(elements[0]);
4828
+ return asRecord(elements[0]);
4012
4829
  return void 0;
4013
4830
  }
4014
4831
  async function scrollToFind(session, q, opts = {}) {
@@ -4027,7 +4844,7 @@ async function scrollToFind(session, q, opts = {}) {
4027
4844
  const hit = await queryFirst(session, q);
4028
4845
  if (hit !== void 0)
4029
4846
  return { found: true, element: hit, scrolls, exhausted: false };
4030
- const data = asRecord3(sr.result);
4847
+ const data = asRecord(sr.result);
4031
4848
  if (data["atEnd"] === true || data["scrolled"] === false) {
4032
4849
  return { found: false, scrolls, exhausted: true };
4033
4850
  }
@@ -4035,7 +4852,7 @@ async function scrollToFind(session, q, opts = {}) {
4035
4852
  for (let i = 0; i < max; i += 1) {
4036
4853
  const sr = await session.command(IrisCommand.SCROLL, q.container !== void 0 ? { ref: q.container } : {});
4037
4854
  scrolls += 1;
4038
- const data = asRecord3(sr.result);
4855
+ const data = asRecord(sr.result);
4039
4856
  const hit = await queryFirst(session, q);
4040
4857
  if (hit !== void 0)
4041
4858
  return { found: true, element: hit, scrolls, exhausted: false };
@@ -4052,58 +4869,58 @@ var SCROLL_TOOLS = [
4052
4869
  name: IrisTool.SCROLL_TO,
4053
4870
  description: "Find an element in a VIRTUALIZED list that has not rendered yet. Pass `by` (role|text|testid|label|placeholder|alt) and `value` (query string) to identify the target row. Scrolls the container until the row mounts, the list ends, or maxScrolls (default 20) is spent. Pass targetIndex + totalCount for bisection \u2014 jumps directly to the estimated offset in one scroll (e.g. targetIndex:800 totalCount:1000 jumps to 80% of scrollHeight). Returns { found, element?, scrolls, exhausted }.",
4054
4871
  inputSchema: {
4055
- by: z10.string().describe("Query strategy for finding the target: role | text | testid | label | placeholder | alt"),
4056
- value: z10.string().describe("Query value for the selected strategy (the element to scroll into view)."),
4057
- name: z10.string().optional().describe("Optional accessible name filter when using by=role."),
4058
- container: z10.string().optional().describe("Element ref for the scrollable container. Omit to scroll the document."),
4059
- maxScrolls: z10.number().optional().describe("Maximum number of scroll steps before giving up. Default: 20."),
4060
- targetIndex: z10.number().optional().describe("Known row index of the target in the list. Combine with totalCount for bisection \u2014 jumps directly to the estimated offset."),
4061
- totalCount: z10.number().optional().describe("Total item count in the virtualized list. Required for bisection with targetIndex."),
4062
- sessionId: z10.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4872
+ by: z11.string().describe("Query strategy for finding the target: role | text | testid | label | placeholder | alt"),
4873
+ value: z11.string().describe("Query value for the selected strategy (the element to scroll into view)."),
4874
+ name: z11.string().optional().describe("Optional accessible name filter when using by=role."),
4875
+ container: z11.string().optional().describe("Element ref for the scrollable container. Omit to scroll the document."),
4876
+ maxScrolls: z11.number().optional().describe("Maximum number of scroll steps before giving up. Default: 20."),
4877
+ targetIndex: z11.number().optional().describe("Known row index of the target in the list. Combine with totalCount for bisection \u2014 jumps directly to the estimated offset."),
4878
+ totalCount: z11.number().optional().describe("Total item count in the virtualized list. Required for bisection with targetIndex."),
4879
+ sessionId: z11.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4063
4880
  },
4064
4881
  outputSchema: {
4065
- found: z10.boolean(),
4066
- element: z10.object({ ref: z10.string(), role: z10.string(), name: z10.string() }).optional(),
4067
- scrolls: z10.number(),
4068
- exhausted: z10.boolean()
4882
+ found: z11.boolean(),
4883
+ element: z11.object({ ref: z11.string(), role: z11.string(), name: z11.string() }).optional(),
4884
+ scrolls: z11.number(),
4885
+ exhausted: z11.boolean()
4069
4886
  },
4070
4887
  handler: (deps, args) => {
4071
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4072
- const name = asString4(args["name"]);
4073
- const container = asString4(args["container"]);
4074
- const targetIndex = asNumber2(args["targetIndex"]);
4075
- const totalCount = asNumber2(args["totalCount"]);
4888
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4889
+ const name = asString(args["name"]);
4890
+ const container = asString(args["container"]);
4891
+ const targetIndex = asNumber(args["targetIndex"]);
4892
+ const totalCount = asNumber(args["totalCount"]);
4076
4893
  const q = {
4077
- by: asString4(args["by"]) ?? "",
4078
- value: asString4(args["value"]) ?? "",
4894
+ by: asString(args["by"]) ?? "",
4895
+ value: asString(args["value"]) ?? "",
4079
4896
  ...name !== void 0 ? { name } : {},
4080
4897
  ...container !== void 0 ? { container } : {},
4081
4898
  ...targetIndex !== void 0 ? { targetIndex } : {},
4082
4899
  ...totalCount !== void 0 ? { totalCount } : {}
4083
4900
  };
4084
- const maxScrolls = asNumber2(args["maxScrolls"]);
4901
+ const maxScrolls = asNumber(args["maxScrolls"]);
4085
4902
  return scrollToFind(session, q, maxScrolls !== void 0 ? { maxScrolls } : {});
4086
4903
  }
4087
4904
  }
4088
4905
  ];
4089
4906
 
4090
4907
  // ../server/dist/session/session-tools.js
4091
- import { z as z11 } from "zod";
4908
+ import { z as z12 } from "zod";
4092
4909
  var SESSION_TOOLS = [
4093
4910
  {
4094
4911
  name: IrisTool.SESSION,
4095
4912
  description: "Tune the presenter session for this app. { idleEndMs } sets how long the session stays open after you go quiet before it AUTO-ENDS (page glow off, the floating panel is kept so the human can read + Copy/Export the run). Default 5min. Raise it for slow apps, lower it for quick checks. The auto-end is enforced SERVER-SIDE (immune to background-tab throttling) and also fires if you (the MCP client) disconnect \u2014 so a forgotten or crashed session never leaves the HUD running forever. If you go quiet and then act again, the session revives automatically. Returns { applied, idleEndMs }.",
4096
4913
  inputSchema: {
4097
- idleEndMs: z11.number().optional().describe("Idle window in milliseconds after which the presenter session auto-ends. Default: 300000 (5 min). Raise for slow apps."),
4098
- sessionId: z11.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4914
+ idleEndMs: z12.number().optional().describe("Idle window in milliseconds after which the presenter session auto-ends. Default: 300000 (5 min). Raise for slow apps."),
4915
+ sessionId: z12.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4099
4916
  },
4100
4917
  outputSchema: {
4101
- applied: z11.boolean(),
4102
- idleEndMs: z11.number().optional()
4918
+ applied: z12.boolean(),
4919
+ idleEndMs: z12.number().optional()
4103
4920
  },
4104
4921
  handler: async (deps, args) => {
4105
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4106
- const idleEndMs = asNumber2(args["idleEndMs"]);
4922
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4923
+ const idleEndMs = asNumber(args["idleEndMs"]);
4107
4924
  if (idleEndMs !== void 0)
4108
4925
  session.setIdleEndMs(idleEndMs);
4109
4926
  const res = await session.command(IrisCommand.SESSION_CONFIG, idleEndMs !== void 0 ? { idleEndMs } : {});
@@ -4115,7 +4932,7 @@ var SESSION_TOOLS = [
4115
4932
  ];
4116
4933
 
4117
4934
  // ../server/dist/flows/annotate-tools.js
4118
- import { z as z12 } from "zod";
4935
+ import { z as z13 } from "zod";
4119
4936
 
4120
4937
  // ../server/dist/flows/annotate.js
4121
4938
  function compileAnnotation(a, stepCount) {
@@ -4192,23 +5009,23 @@ var ANNOTATE_TOOLS = [
4192
5009
  description: "Attach a STRUCTURED annotation to the active recording, compiling it into the flow. kind: assert-signal { name, dataMatches? } \u2192 the last step asserts that signal; assert-visible { testid } \u2192 the last step asserts that element is present; mark-dynamic { testid } \u2192 the flow records that region as LLM-dynamic (replay won't assert its content); success-state { signal | testid } \u2192 the flow golden end-condition. Folded onto disk by iris_flow_save. Returns { ok:true, target:step|flow, compiled } (e.g. \"will assert signal diff:shown\") or { ok:false, code } (annotate_no_recording | annotate_no_step | annotate_unknown_kind | annotate_missing_field). FIRST CUT: structured only \u2014 a free natural-language string is rejected (annotate_unknown_kind), never guessed into a predicate. Pass `flow` to target a named recording (defaults to 'default'); `name` is the assert-signal's SIGNAL name, not the recording.",
4193
5010
  inputSchema: {
4194
5011
  // `flow` selects the recording; `name`/`signal`/`testid`/`dataMatches` are the annotation fields.
4195
- flow: z12.string().optional().describe("Named recording to annotate. Defaults to 'default'."),
4196
- kind: z12.string().describe("Annotation kind: assert-signal | assert-visible | mark-dynamic | success-state."),
4197
- name: z12.string().optional().describe("Signal name for assert-signal annotations."),
4198
- testid: z12.string().optional().describe("data-testid value for assert-visible / mark-dynamic / success-state annotations."),
4199
- signal: z12.string().optional().describe("Signal name for success-state annotations."),
4200
- dataMatches: z12.record(z12.unknown()).optional().describe("Key/value pairs the signal payload must match (assert-signal only)."),
4201
- sessionId: z12.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open."),
4202
- annotation: z12.unknown().optional().describe("Structured annotation: { kind, name, dataMatches? } for assert-signal; { kind, testid } for assert-visible / mark-dynamic; { kind, signal?, testid? } for success-state.")
5012
+ flow: z13.string().optional().describe("Named recording to annotate. Defaults to 'default'."),
5013
+ kind: z13.string().describe("Annotation kind: assert-signal | assert-visible | mark-dynamic | success-state."),
5014
+ name: z13.string().optional().describe("Signal name for assert-signal annotations."),
5015
+ testid: z13.string().optional().describe("data-testid value for assert-visible / mark-dynamic / success-state annotations."),
5016
+ signal: z13.string().optional().describe("Signal name for success-state annotations."),
5017
+ dataMatches: z13.record(z13.unknown()).optional().describe("Key/value pairs the signal payload must match (assert-signal only)."),
5018
+ sessionId: z13.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open."),
5019
+ annotation: z13.unknown().optional().describe("Structured annotation: { kind, name, dataMatches? } for assert-signal; { kind, testid } for assert-visible / mark-dynamic; { kind, signal?, testid? } for success-state.")
4203
5020
  },
4204
5021
  outputSchema: {
4205
- ok: z12.boolean(),
4206
- target: z12.string().optional(),
4207
- compiled: z12.string().optional(),
4208
- code: z12.string().optional()
5022
+ ok: z13.boolean(),
5023
+ target: z13.string().optional(),
5024
+ compiled: z13.string().optional(),
5025
+ code: z13.string().optional()
4209
5026
  },
4210
5027
  handler: (deps, args) => {
4211
- const name = asString4(args["flow"]) ?? DEFAULT_RECORDING;
5028
+ const name = asString(args["flow"]) ?? DEFAULT_RECORDING;
4212
5029
  const parsed = AnnotationSchema.safeParse(args);
4213
5030
  if (!parsed.success) {
4214
5031
  return Promise.resolve({ ok: false, code: AnnotationErrorCode.UNKNOWN_KIND });
@@ -4237,19 +5054,19 @@ var ANNOTATE_TOOLS = [
4237
5054
  ];
4238
5055
 
4239
5056
  // ../server/dist/session/live-control-tools.js
4240
- import { z as z13 } from "zod";
5057
+ import { z as z14 } from "zod";
4241
5058
  var sessionIdShape5 = {
4242
- sessionId: z13.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
5059
+ sessionId: z14.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4243
5060
  };
4244
5061
  var LIVE_CONTROL_TOOLS = [
4245
5062
  {
4246
5063
  name: IrisTool.END_SESSION,
4247
5064
  description: 'End this live testing session. Sets the server state to "ended", tells the human panel (PRESENTER), and stops driving. Optional `summary` is shown in the panel. Idempotent.',
4248
- inputSchema: { summary: z13.string().optional(), ...sessionIdShape5 },
4249
- outputSchema: { ended: z13.boolean(), sessionId: z13.string() },
5065
+ inputSchema: { summary: z14.string().optional(), ...sessionIdShape5 },
5066
+ outputSchema: { ended: z14.boolean(), sessionId: z14.string() },
4250
5067
  handler: (deps, args) => {
4251
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4252
- session.setState(SessionState.ENDED, asString4(args["summary"]));
5068
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5069
+ session.setState(SessionState.ENDED, asString(args["summary"]));
4253
5070
  return Promise.resolve({ ended: true, sessionId: session.id });
4254
5071
  }
4255
5072
  },
@@ -4257,9 +5074,9 @@ var LIVE_CONTROL_TOOLS = [
4257
5074
  name: IrisTool.RESUME,
4258
5075
  description: 'Clear a human pause and resume driving the page. Sets state "active" and syncs the panel (PRESENTER). Call after you have addressed the human guidance returned by a paused iris_act.',
4259
5076
  inputSchema: { ...sessionIdShape5 },
4260
- outputSchema: { ok: z13.boolean() },
5077
+ outputSchema: { ok: z14.boolean() },
4261
5078
  handler: (deps, args) => {
4262
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5079
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4263
5080
  session.setState(SessionState.ACTIVE);
4264
5081
  return Promise.resolve({ ok: true });
4265
5082
  }
@@ -4268,9 +5085,9 @@ var LIVE_CONTROL_TOOLS = [
4268
5085
  name: IrisTool.MESSAGES,
4269
5086
  description: "Drain and return any messages the human queued from the panel since the last poll. Use to explicitly check for human guidance without acting.",
4270
5087
  inputSchema: { ...sessionIdShape5 },
4271
- outputSchema: { messages: z13.array(z13.unknown()) },
5088
+ outputSchema: { messages: z14.array(z14.unknown()) },
4272
5089
  handler: (deps, args) => {
4273
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5090
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4274
5091
  return Promise.resolve({ messages: session.drainInbox() });
4275
5092
  }
4276
5093
  }
@@ -4296,7 +5113,7 @@ function withControl(session, result) {
4296
5113
  }
4297
5114
 
4298
5115
  // ../server/dist/update/update-tools.js
4299
- import { z as z14 } from "zod";
5116
+ import { z as z15 } from "zod";
4300
5117
 
4301
5118
  // ../server/dist/update/update-checker.js
4302
5119
  import * as fs from "fs";
@@ -4490,14 +5307,14 @@ var UPDATE_TOOLS = [
4490
5307
  description: "Returns the running Iris version, latest available version, release changelog, and any breaking changes. Call this at the start of a session or when unexpected tool behavior suggests a version mismatch.",
4491
5308
  inputSchema: {},
4492
5309
  outputSchema: {
4493
- currentVersion: z14.string().describe("The Iris server version currently running."),
4494
- latestVersion: z14.string().optional().describe("Latest published version on npm."),
4495
- updateAvailable: z14.boolean().describe("True when a newer version is available to install."),
4496
- executionKind: z14.string().describe('How iris was launched: "npx" (no install needed \u2014 restart applies update), "global" (npm install -g), or "local" (project node_modules).'),
4497
- changelog: z14.string().optional().describe("Release notes for the latest version."),
4498
- breakingChanges: z14.array(z14.string()).optional().describe("Breaking changes in the latest version that may affect your scripts."),
4499
- rollbackAvailable: z14.boolean().describe("True when a previous version is stored and can be restored."),
4500
- previousVersion: z14.string().optional().describe("The version that would be restored on rollback.")
5310
+ currentVersion: z15.string().describe("The Iris server version currently running."),
5311
+ latestVersion: z15.string().optional().describe("Latest published version on npm."),
5312
+ updateAvailable: z15.boolean().describe("True when a newer version is available to install."),
5313
+ executionKind: z15.string().describe('How iris was launched: "npx" (no install needed \u2014 restart applies update), "global" (npm install -g), or "local" (project node_modules).'),
5314
+ changelog: z15.string().optional().describe("Release notes for the latest version."),
5315
+ breakingChanges: z15.array(z15.string()).optional().describe("Breaking changes in the latest version that may affect your scripts."),
5316
+ rollbackAvailable: z15.boolean().describe("True when a previous version is stored and can be restored."),
5317
+ previousVersion: z15.string().optional().describe("The version that would be restored on rollback.")
4501
5318
  },
4502
5319
  handler: async (_deps) => {
4503
5320
  const manifest = await checkForUpdate(SERVER_VERSION);
@@ -4517,11 +5334,11 @@ var UPDATE_TOOLS = [
4517
5334
  name: IrisTool.APPLY_UPDATE,
4518
5335
  description: 'Install the latest Iris server version and restart. Strategy depends on how iris was launched (check executionKind from iris_version_info): "global" and "local" installs run npm install then exit; "npx" just exits \u2014 Claude Code restarts and npx re-resolves the latest version from npm automatically. The MCP connection briefly drops during restart.',
4519
5336
  inputSchema: {
4520
- confirm: z14.boolean().describe("Set to true to confirm the update should be applied. Required to prevent accidental upgrades.")
5337
+ confirm: z15.boolean().describe("Set to true to confirm the update should be applied. Required to prevent accidental upgrades.")
4521
5338
  },
4522
5339
  outputSchema: {
4523
- ok: z14.boolean(),
4524
- message: z14.string().optional()
5340
+ ok: z15.boolean(),
5341
+ message: z15.string().optional()
4525
5342
  },
4526
5343
  handler: async (_deps, args) => {
4527
5344
  if (args["confirm"] !== true) {
@@ -4539,11 +5356,11 @@ var UPDATE_TOOLS = [
4539
5356
  name: IrisTool.ROLLBACK,
4540
5357
  description: "Restore the previous Iris server version and restart. Use when an update introduced a regression. The MCP connection will briefly drop \u2014 Claude Code restarts the process automatically with the restored binary.",
4541
5358
  inputSchema: {
4542
- confirm: z14.boolean().describe("Set to true to confirm the rollback. Required to prevent accidental downgrades.")
5359
+ confirm: z15.boolean().describe("Set to true to confirm the rollback. Required to prevent accidental downgrades.")
4543
5360
  },
4544
5361
  outputSchema: {
4545
- ok: z14.boolean(),
4546
- message: z14.string().optional()
5362
+ ok: z15.boolean(),
5363
+ message: z15.string().optional()
4547
5364
  },
4548
5365
  handler: async (_deps, args) => {
4549
5366
  if (args["confirm"] !== true) {
@@ -4565,7 +5382,7 @@ async function snapshotTree(deps, sessionId) {
4565
5382
  return { lines: normalizeLines(snap.tree ?? ""), route: snap.status?.route ?? "" };
4566
5383
  }
4567
5384
  var sessionIdShape6 = {
4568
- sessionId: z15.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open \u2014 Iris resolves it automatically.")
5385
+ sessionId: z16.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open \u2014 Iris resolves it automatically.")
4569
5386
  };
4570
5387
  async function commandOrThrow3(deps, sessionId, name, args) {
4571
5388
  const session = deps.sessions.resolve(sessionId);
@@ -4575,11 +5392,11 @@ async function commandOrThrow3(deps, sessionId, name, args) {
4575
5392
  return result.result;
4576
5393
  }
4577
5394
  function asBox2(value) {
4578
- const b = asRecord3(asRecord3(value)["box"]);
4579
- const x = asNumber2(b["x"]);
4580
- const y = asNumber2(b["y"]);
4581
- const w = asNumber2(b["width"]);
4582
- const h = asNumber2(b["height"]);
5395
+ const b = asRecord(asRecord(value)["box"]);
5396
+ const x = asNumber(b["x"]);
5397
+ const y = asNumber(b["y"]);
5398
+ const w = asNumber(b["width"]);
5399
+ const h = asNumber(b["height"]);
4583
5400
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
4584
5401
  return void 0;
4585
5402
  if (w <= 0 || h <= 0)
@@ -4595,29 +5412,51 @@ async function tryRealInput(deps, session, ref, action, args) {
4595
5412
  return synthetic();
4596
5413
  if (!isPointerAction(action))
4597
5414
  return synthetic(InputModeReason.NOT_POINTER);
4598
- const inner = asRecord3(args["args"]);
5415
+ const inner = asRecord(args["args"]);
4599
5416
  if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && inner["native"] !== true) {
4600
5417
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
4601
5418
  }
4602
5419
  if (!await provider.isAvailableFor(session.url))
4603
5420
  return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
4604
- const box = asBox2(await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref }));
5421
+ const inspected = await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref });
5422
+ const confirmed = inner[DANGEROUS_ACTION_CONFIRM_ARG] === true;
5423
+ const dangerousDescriptorText = (value2) => {
5424
+ const descriptor = asRecord(value2);
5425
+ return [
5426
+ asString(descriptor["name"]) ?? "",
5427
+ asString(descriptor["text"]) ?? "",
5428
+ asString(descriptor["value"]) ?? "",
5429
+ asString(descriptor["href"]) ?? "",
5430
+ asString(descriptor["formAction"]) ?? "",
5431
+ asString(descriptor["formText"]) ?? ""
5432
+ ].join(" ");
5433
+ };
5434
+ if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !confirmed && isDangerousActionText(dangerousDescriptorText(inspected))) {
5435
+ throw new Error(`potentially destructive native action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
5436
+ }
5437
+ const box = asBox2(inspected);
4605
5438
  if (box === void 0)
4606
5439
  return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
4607
5440
  let toBox;
4608
5441
  if (action === ActionType.DRAG) {
4609
- const toRef = asString4(inner["toRef"]);
5442
+ const toRef = asString(inner["toRef"]);
4610
5443
  if (toRef === void 0)
4611
5444
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4612
- toBox = asBox2(await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref: toRef }));
5445
+ const targetInspected = await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, {
5446
+ ref: toRef
5447
+ });
5448
+ if (!confirmed && isDangerousActionText(`${dangerousDescriptorText(inspected)} ${dangerousDescriptorText(targetInspected)}`)) {
5449
+ throw new Error(`potentially destructive native action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
5450
+ }
5451
+ toBox = asBox2(targetInspected);
4613
5452
  if (toBox === void 0)
4614
5453
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4615
5454
  }
4616
5455
  const performArgs = {};
4617
- const value = asString4(inner["value"]);
5456
+ const value = asString(inner["value"]);
4618
5457
  if (value !== void 0)
4619
5458
  performArgs.value = value;
4620
- const text = asString4(inner["text"]);
5459
+ const text = asString(inner["text"]);
4621
5460
  if (text !== void 0)
4622
5461
  performArgs.text = text;
4623
5462
  if (toBox !== void 0)
@@ -4636,23 +5475,24 @@ async function tryRealInput(deps, session, ref, action, args) {
4636
5475
  };
4637
5476
  }
4638
5477
  }
5478
+ var SNAPSHOT_CACHE = new SnapshotCache();
4639
5479
  var TOOLS = [
4640
5480
  {
4641
5481
  name: IrisTool.SESSIONS,
4642
5482
  description: "List connected browser sessions (tab url/title, sessionId, last-seen, health: hidden/focused/throttled, and `realInputAvailable` \u2014 true when native CDP/launched real input is driving this tab), plus a `recommendation` pointing to `iris drive` when a tab is hidden/throttled and may be un-scriptable from here.",
4643
5483
  inputSchema: {},
4644
5484
  outputSchema: {
4645
- sessions: z15.array(z15.object({
4646
- sessionId: z15.string(),
4647
- url: z15.string(),
4648
- title: z15.string().optional(),
4649
- lastSeenMs: z15.number(),
4650
- throttled: z15.boolean(),
4651
- focused: z15.boolean(),
4652
- hidden: z15.boolean(),
4653
- realInputAvailable: z15.boolean().optional(),
4654
- stale: z15.boolean().optional(),
4655
- recommendation: z15.string().optional()
5485
+ sessions: z16.array(z16.object({
5486
+ sessionId: z16.string(),
5487
+ url: z16.string(),
5488
+ title: z16.string().optional(),
5489
+ lastSeenMs: z16.number(),
5490
+ throttled: z16.boolean(),
5491
+ focused: z16.boolean(),
5492
+ hidden: z16.boolean(),
5493
+ realInputAvailable: z16.boolean().optional(),
5494
+ stale: z16.boolean().optional(),
5495
+ recommendation: z16.string().optional()
4656
5496
  })).describe("Connected browser sessions with health state.")
4657
5497
  },
4658
5498
  handler: async (deps) => {
@@ -4666,71 +5506,95 @@ var TOOLS = [
4666
5506
  },
4667
5507
  {
4668
5508
  name: IrisTool.SNAPSHOT,
4669
- description: "Semantic accessibility snapshot of the page or a subtree. mode: full|interactive|status. Use to see what is on screen right now.",
5509
+ description: "Semantic accessibility snapshot of the page or a subtree. mode: full|interactive|status. Use to see what is on screen right now. The result carries cost:{ bytes, tokens } (estimated) \u2014 if it is large, re-scope (pass `scope`) or use mode:interactive/status instead of reading the whole tree. Pass diff:true after your first snapshot to get back ONLY what changed since your last look (mode:delta with added/removed, or mode:unchanged) \u2014 far fewer tokens and no stale tree to mis-read; a route change resets it to a full snapshot automatically.",
4670
5510
  inputSchema: {
4671
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the snapshot to a subtree. Omit to snapshot the whole page."),
4672
- mode: z15.nativeEnum(SnapshotMode).optional().describe("full = all elements; interactive = only clickable/focusable elements; status = only route + title. Default: full."),
5511
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the snapshot to a subtree. Omit to snapshot the whole page."),
5512
+ mode: z16.nativeEnum(SnapshotMode).optional().describe("full = all elements; interactive = only clickable/focusable elements; status = only route + title. Default: full."),
5513
+ diff: z16.boolean().optional().describe("Return only what changed since your last snapshot of the same scope/mode (mode:delta|unchanged). First call (or after a route change) still returns the full tree."),
4673
5514
  ...sessionIdShape6
4674
5515
  },
4675
5516
  outputSchema: {
4676
- tree: z15.string().optional().describe("Indented ARIA tree of every element on the page (or the scoped subtree)."),
4677
- status: z15.object({ route: z15.string(), title: z15.string().optional() }).optional()
5517
+ tree: z16.string().optional().describe("Indented ARIA tree of every element on the page (or the scoped subtree)."),
5518
+ status: z16.object({ route: z16.string(), title: z16.string().optional() }).optional(),
5519
+ mode: z16.string().optional().describe("delta | unchanged when diff:true returned a change set."),
5520
+ delta: z16.object({
5521
+ added: z16.array(z16.string()),
5522
+ removed: z16.array(z16.string()),
5523
+ addedCount: z16.number(),
5524
+ removedCount: z16.number()
5525
+ }).optional().describe("Only present on a diff:true call that found changes."),
5526
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional().describe("Estimated size of this result \u2014 re-scope if large.")
4678
5527
  },
4679
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.SNAPSHOT, {
4680
- scope: args["scope"],
4681
- mode: args["mode"] ?? SnapshotMode.FULL
4682
- })
5528
+ handler: (deps, args) => {
5529
+ const sessionId = asString(args["sessionId"]);
5530
+ const mode = asString(args["mode"]) ?? SnapshotMode.FULL;
5531
+ return commandOrThrow3(deps, sessionId, IrisCommand.SNAPSHOT, {
5532
+ scope: args["scope"],
5533
+ mode
5534
+ }).then((raw) => withSizeCost(applySnapshotDelta(raw, {
5535
+ sessionId: sessionId ?? "default",
5536
+ scope: asString(args["scope"]) ?? "",
5537
+ mode,
5538
+ diff: args["diff"] === true
5539
+ }, SNAPSHOT_CACHE)));
5540
+ }
4683
5541
  },
4684
5542
  {
4685
5543
  name: IrisTool.QUERY,
4686
- description: "Find elements by Testing-Library semantics. Pass `by` (role|text|label|placeholder|testid|alt) and `value` (the query string). Returns matching refs + descriptors + visibility. On zero matches, also returns hint:{ route, presentTestids[], knownEmptyState } so you can distinguish an empty state from a missing element WITHOUT taking a snapshot.",
5544
+ description: "Find elements by Testing-Library semantics. Pass `by` (role|text|label|placeholder|testid|alt) and `value` (the query string). Returns matching refs + descriptors + visibility. Pass `limit` to cap descriptors (broad role queries can be large) or `count_only:true` for just the match count \u2014 both cut tokens. On zero matches, also returns hint:{ route, presentTestids[], knownEmptyState } so you can distinguish an empty state from a missing element WITHOUT taking a snapshot.",
4687
5545
  inputSchema: {
4688
- by: z15.string().describe("Query strategy: role | text | label | placeholder | testid | alt"),
4689
- value: z15.string().describe("Query value for the selected strategy (e.g. by=role value=button, or by=testid value=submit-btn)."),
4690
- name: z15.string().optional().describe("Accessible name filter \u2014 narrows results when `by` is role and the page has many elements of that role."),
4691
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the search to a subtree."),
5546
+ by: z16.string().describe("Query strategy: role | text | label | placeholder | testid | alt"),
5547
+ value: z16.string().describe("Query value for the selected strategy (e.g. by=role value=button, or by=testid value=submit-btn)."),
5548
+ name: z16.string().optional().describe("Accessible name filter \u2014 narrows results when `by` is role and the page has many elements of that role."),
5549
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the search to a subtree."),
5550
+ limit: z16.number().optional().describe("Cap the returned descriptors to the first N (cuts tokens on broad queries). If more matched, the result carries total + truncated:true so the trim is never silent \u2014 narrow with name/scope."),
5551
+ count_only: z16.boolean().optional().describe('Return just { count } (no element descriptors) \u2014 use when you only need "how many match?" and not their refs.'),
4692
5552
  ...sessionIdShape6
4693
5553
  },
4694
5554
  outputSchema: {
4695
- elements: z15.array(z15.object({
4696
- ref: z15.string(),
4697
- role: z15.string(),
4698
- name: z15.string(),
4699
- value: z15.string().optional(),
4700
- states: z15.array(z15.string()),
4701
- visible: z15.boolean()
4702
- })),
4703
- hint: z15.object({
4704
- route: z15.string(),
4705
- presentTestids: z15.array(z15.string()),
4706
- knownEmptyState: z15.boolean()
4707
- }).optional().describe("Present only on zero matches \u2014 tells you what IS on the page so you can diagnose the miss.")
5555
+ elements: z16.array(z16.object({
5556
+ ref: z16.string(),
5557
+ role: z16.string(),
5558
+ name: z16.string(),
5559
+ value: z16.string().optional(),
5560
+ states: z16.array(z16.string()),
5561
+ visible: z16.boolean()
5562
+ })).optional(),
5563
+ count: z16.number().optional().describe("Match count \u2014 present when count_only is set."),
5564
+ total: z16.number().optional().describe("Total matches before `limit` truncation \u2014 present only when truncated."),
5565
+ truncated: z16.boolean().optional().describe("True when `limit` dropped some matches."),
5566
+ hint: z16.object({
5567
+ route: z16.string(),
5568
+ presentTestids: z16.array(z16.string()),
5569
+ knownEmptyState: z16.boolean()
5570
+ }).optional().describe("Present only on zero matches \u2014 tells you what IS on the page so you can diagnose the miss."),
5571
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional().describe("Estimated size of this result \u2014 narrow with `name`/`scope`/`limit` if large.")
4708
5572
  },
4709
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.QUERY, {
5573
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.QUERY, {
4710
5574
  by: args["by"],
4711
5575
  value: args["value"],
4712
5576
  name: args["name"],
4713
5577
  scope: args["scope"]
4714
- })
5578
+ }).then((result) => withSizeCost(paginateQueryResult(result, asNumber(args["limit"]), args["count_only"] === true)))
4715
5579
  },
4716
5580
  {
4717
5581
  name: IrisTool.INSPECT,
4718
5582
  description: "Deep info on one element by ref: full a11y props, visibility, box, and (with @syrin/iris-react) component stack + source file.",
4719
5583
  inputSchema: {
4720
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
5584
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
4721
5585
  ...sessionIdShape6
4722
5586
  },
4723
5587
  outputSchema: {
4724
- ref: z15.string(),
4725
- role: z15.string(),
4726
- name: z15.string(),
4727
- value: z15.string().optional(),
4728
- states: z15.array(z15.string()),
4729
- visible: z15.boolean(),
4730
- box: z15.object({ x: z15.number(), y: z15.number(), width: z15.number(), height: z15.number() }).optional(),
4731
- component: z15.object({ name: z15.string().optional(), sourceFile: z15.string().optional() }).optional()
5588
+ ref: z16.string(),
5589
+ role: z16.string(),
5590
+ name: z16.string(),
5591
+ value: z16.string().optional(),
5592
+ states: z16.array(z16.string()),
5593
+ visible: z16.boolean(),
5594
+ box: z16.object({ x: z16.number(), y: z16.number(), width: z16.number(), height: z16.number() }).optional(),
5595
+ component: z16.object({ name: z16.string().optional(), sourceFile: z16.string().optional() }).optional()
4732
5596
  },
4733
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.INSPECT, {
5597
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.INSPECT, {
4734
5598
  ref: args["ref"]
4735
5599
  })
4736
5600
  },
@@ -4738,30 +5602,30 @@ var TOOLS = [
4738
5602
  name: IrisTool.ACT,
4739
5603
  description: 'Execute one action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Returns immediately with a `since` cursor \u2014 observe the reaction with iris_observe. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. occluded=true means the click point is covered by another element (a real user could not click it) \u2014 synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured \u2014 pass args.native:true to force a trusted native click (file pickers, clipboard). inputModeReason explains any real\u2192synthetic choice so it is never silent. Full model (real-input, throttled tabs, `iris drive`): docs/usage.md \xA718.',
4740
5604
  inputSchema: {
4741
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
4742
- action: z15.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4743
- args: z15.record(z15.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click."),
4744
- refuseWhenThrottled: z15.boolean().optional().describe("Throw instead of silently sending synthetic events when the tab is throttled/backgrounded. Default: false (synthetic events are still sent)."),
5605
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
5606
+ action: z16.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
5607
+ args: z16.record(z16.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click, { confirmDangerous: true } to allow a potentially destructive control."),
5608
+ refuseWhenThrottled: z16.boolean().optional().describe("Throw instead of silently sending synthetic events when the tab is throttled/backgrounded. Default: false (synthetic events are still sent)."),
4745
5609
  ...sessionIdShape6
4746
5610
  },
4747
5611
  outputSchema: {
4748
- since: z15.number().describe("Cursor \u2014 pass to iris_observe/iris_wait_for/iris_assert to scope reaction queries to this act."),
4749
- dispatched: z15.boolean(),
4750
- settled: z15.boolean().nullable(),
4751
- inputMode: z15.string(),
4752
- result: z15.unknown().optional(),
4753
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5612
+ since: z16.number().describe("Cursor \u2014 pass to iris_observe/iris_wait_for/iris_assert to scope reaction queries to this act."),
5613
+ dispatched: z16.boolean(),
5614
+ settled: z16.boolean().nullable(),
5615
+ inputMode: z16.string(),
5616
+ result: z16.unknown().optional(),
5617
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4754
5618
  },
4755
5619
  handler: async (deps, args) => {
4756
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5620
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4757
5621
  const paused = pausedShortCircuit(session);
4758
5622
  if (paused !== void 0)
4759
5623
  return paused;
4760
5624
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4761
5625
  const since = session.elapsed();
4762
5626
  session.markActCursor(since);
4763
- const ref = asString4(args["ref"]) ?? "";
4764
- const action = asString4(args["action"]) ?? "";
5627
+ const ref = asString(args["ref"]) ?? "";
5628
+ const action = asString(args["action"]) ?? "";
4765
5629
  const real = await tryRealInput(deps, session, ref, action, args);
4766
5630
  if (real.result !== void 0) {
4767
5631
  if (deps.recordings.active().length > 0) {
@@ -4787,7 +5651,7 @@ var TOOLS = [
4787
5651
  if (deps.recordings.active().length > 0) {
4788
5652
  deps.recordings.capture(compileActStep(args, result.result));
4789
5653
  }
4790
- const r = asRecord3(result.result);
5654
+ const r = asRecord(result.result);
4791
5655
  return withControl(session, {
4792
5656
  since,
4793
5657
  inputMode: InputMode.SYNTHETIC,
@@ -4806,17 +5670,17 @@ var TOOLS = [
4806
5670
  name: IrisTool.ACT_SEQUENCE,
4807
5671
  description: "Run multiple actions in order (fill -> fill -> submit) in one round-trip. Returns per-step effects[] (see iris_act).",
4808
5672
  inputSchema: {
4809
- steps: z15.array(z15.record(z15.unknown())).describe("Ordered list of { ref, action, args? } objects. Each step is equivalent to one iris_act call."),
5673
+ steps: z16.array(z16.record(z16.unknown())).describe("Ordered list of { ref, action, args? } objects. Each step is equivalent to one iris_act call; put confirmDangerous:true in a destructive step args object."),
4810
5674
  ...sessionIdShape6
4811
5675
  },
4812
5676
  outputSchema: {
4813
- since: z15.number(),
4814
- dispatched: z15.boolean(),
4815
- result: z15.unknown().optional(),
4816
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5677
+ since: z16.number(),
5678
+ dispatched: z16.boolean(),
5679
+ result: z16.unknown().optional(),
5680
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4817
5681
  },
4818
5682
  handler: async (deps, args) => {
4819
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5683
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4820
5684
  const paused = pausedShortCircuit(session);
4821
5685
  if (paused !== void 0)
4822
5686
  return paused;
@@ -4828,7 +5692,7 @@ var TOOLS = [
4828
5692
  if (deps.recordings.active().length > 0) {
4829
5693
  deps.recordings.capture(compileSequenceStep(args, result.result));
4830
5694
  }
4831
- const r = asRecord3(result.result);
5695
+ const r = asRecord(result.result);
4832
5696
  return withControl(session, {
4833
5697
  since,
4834
5698
  dispatched: r["count"] !== void 0,
@@ -4839,34 +5703,34 @@ var TOOLS = [
4839
5703
  },
4840
5704
  {
4841
5705
  name: IrisTool.ACT_AND_WAIT,
4842
- description: "Act on a ref, then wait for a predicate to hold \u2014 one hop for the act->observe->assert loop. Returns { effect } (the action result), { verdict } (predicate pass/evidence/near-miss), and { trace } (the reaction report of everything the app did after the action). timeout_ms 0 evaluates the predicate once without waiting.",
5706
+ description: "Act on a ref, then wait for a predicate to hold \u2014 one hop for the act->observe->assert loop. Omit `until` to wait for the page to settle (network + DOM idle) \u2014 use this instead of a fixed sleep. Returns { effect } (the action result), { verdict } (predicate pass/evidence/near-miss), and { trace } (the reaction report of everything the app did after the action). timeout_ms 0 evaluates the predicate once without waiting.",
4843
5707
  inputSchema: {
4844
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query."),
4845
- action: z15.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4846
- args: z15.record(z15.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press."),
4847
- until: PredicateSchema.describe("Predicate to wait for after the action completes. Same shape accepted by iris_assert."),
4848
- timeout_ms: z15.number().optional().describe("Maximum wait time in milliseconds. 0 = evaluate once without waiting. Default: 4000."),
4849
- refuseWhenThrottled: z15.boolean().optional().describe("Throw if the tab is throttled. Default: false."),
5708
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query."),
5709
+ action: z16.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
5710
+ args: z16.record(z16.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press, { confirmDangerous: true } for a potentially destructive control."),
5711
+ until: PredicateSchema.optional().describe('Predicate to wait for after the action completes (same shape as iris_assert). OMIT to wait for the page to SETTLE \u2014 network + DOM idle \u2014 the deterministic default instead of a sleep. To assert a consequence AND settle, allOf them: { kind: "allOf", predicates: [<your predicate>, { kind: "settled" }] }.'),
5712
+ timeout_ms: z16.number().optional().describe("Maximum wait time in milliseconds. 0 = evaluate once without waiting. Default: 4000."),
5713
+ refuseWhenThrottled: z16.boolean().optional().describe("Throw if the tab is throttled. Default: false."),
4850
5714
  ...sessionIdShape6
4851
5715
  },
4852
5716
  outputSchema: {
4853
- effect: z15.unknown().describe("The iris_act result (dispatched, settled, inputMode, etc.)."),
4854
- verdict: z15.object({
4855
- pass: z15.boolean(),
4856
- evidence: z15.unknown().optional(),
4857
- failureReason: z15.string().optional()
5717
+ effect: z16.unknown().describe("The iris_act result (dispatched, settled, inputMode, etc.)."),
5718
+ verdict: z16.object({
5719
+ pass: z16.boolean(),
5720
+ evidence: z16.unknown().optional(),
5721
+ failureReason: z16.string().optional()
4858
5722
  }),
4859
- trace: z15.unknown().describe("Reaction report (same shape as iris_observe summary)."),
4860
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5723
+ trace: z16.unknown().describe("Reaction report (same shape as iris_observe summary)."),
5724
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4861
5725
  },
4862
5726
  handler: async (deps, args) => {
4863
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5727
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4864
5728
  const paused = pausedShortCircuit(session);
4865
5729
  if (paused !== void 0)
4866
5730
  return paused;
4867
5731
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4868
- const until = PredicateSchema.parse(args["until"]);
4869
- const timeout2 = asNumber2(args["timeout_ms"]) ?? 4e3;
5732
+ const until = args["until"] !== void 0 ? PredicateSchema.parse(args["until"]) : { kind: "settled" };
5733
+ const timeout2 = asNumber(args["timeout_ms"]) ?? 4e3;
4870
5734
  const since = session.elapsed();
4871
5735
  session.markActCursor(since);
4872
5736
  const actResult = await session.command(IrisCommand.ACT, {
@@ -4890,40 +5754,41 @@ var TOOLS = [
4890
5754
  name: IrisTool.OBSERVE,
4891
5755
  description: "Return the timeline of everything the app did in a window (DOM/network/route/console/animation/signal), with a summary. Use after an action. Pass `max_events` to cap the timeline to the most recent N (older events are dropped and counted in cost.droppedOldest). Every result carries a `cost:{events,bytes}` hint so you can self-budget your next call.",
4892
5756
  inputSchema: {
4893
- window_ms: z15.number().optional().describe("Time window to look back. Default: 2000ms. Ignored when `since` is provided."),
4894
- since: z15.number().optional().describe("Cursor from a prior iris_act or iris_observe call. Scopes the event window to exactly that span."),
4895
- filters: z15.array(z15.string()).optional().describe("Event type allowlist: dom | net | route | console | animation | signal. Omit to return all types."),
4896
- max_events: z15.number().optional().describe("Cap the timeline to the most recent N events. Older events are counted in cost.droppedOldest."),
5757
+ window_ms: z16.number().optional().describe("Time window to look back. Default: 2000ms. Ignored when `since` is provided."),
5758
+ since: z16.number().optional().describe("Cursor from a prior iris_act or iris_observe call. Scopes the event window to exactly that span."),
5759
+ filters: z16.array(z16.string()).optional().describe("Event type allowlist: dom | net | route | console | animation | signal. Omit to return all types."),
5760
+ max_events: z16.number().optional().describe("Cap the timeline to the most recent N events. Older events are counted in cost.droppedOldest."),
4897
5761
  ...sessionIdShape6
4898
5762
  },
4899
5763
  outputSchema: {
4900
- events: z15.array(z15.unknown()),
4901
- summary: z15.object({
4902
- total: z15.number(),
4903
- network: z15.number(),
4904
- domAdded: z15.number(),
4905
- domRemoved: z15.number(),
4906
- domChanged: z15.number(),
4907
- routeChanges: z15.number(),
4908
- consoleErrors: z15.number(),
4909
- animations: z15.number(),
4910
- signals: z15.number()
5764
+ events: z16.array(z16.unknown()),
5765
+ summary: z16.object({
5766
+ total: z16.number(),
5767
+ network: z16.number(),
5768
+ domAdded: z16.number(),
5769
+ domRemoved: z16.number(),
5770
+ domChanged: z16.number(),
5771
+ routeChanges: z16.number(),
5772
+ consoleErrors: z16.number(),
5773
+ animations: z16.number(),
5774
+ signals: z16.number()
4911
5775
  }),
4912
- cost: z15.object({
4913
- events: z15.number(),
4914
- bytes: z15.number(),
4915
- droppedOldest: z15.number().optional()
5776
+ cost: z16.object({
5777
+ events: z16.number(),
5778
+ bytes: z16.number(),
5779
+ droppedOldest: z16.number().optional(),
5780
+ recommendation: z16.string().optional().describe("Present when the timeline is large \u2014 scope your next call (filters/max_events).")
4916
5781
  }),
4917
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5782
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4918
5783
  },
4919
5784
  handler: (deps, args) => {
4920
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4921
- const since = asNumber2(args["since"]);
4922
- const windowMs = asNumber2(args["window_ms"]) ?? 2e3;
5785
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5786
+ const since = asNumber(args["since"]);
5787
+ const windowMs = asNumber(args["window_ms"]) ?? 2e3;
4923
5788
  const events = since !== void 0 ? session.eventsSince(since) : session.eventsInWindow(windowMs);
4924
5789
  const filters = Array.isArray(args["filters"]) ? args["filters"] : void 0;
4925
5790
  const filtered = filters === void 0 ? events : events.filter((e) => filters.includes(e.type));
4926
- const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber2(args["max_events"]));
5791
+ const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber(args["max_events"]));
4927
5792
  const report = buildReactionReport(budgeted, windowMs);
4928
5793
  return Promise.resolve(withControl(session, {
4929
5794
  ...report,
@@ -4936,99 +5801,113 @@ var TOOLS = [
4936
5801
  name: IrisTool.WAIT_FOR,
4937
5802
  description: "Block until a predicate is satisfied (or already true in the recent buffer), else time out. Returns matching evidence or a near-miss diagnosis. By default it only counts events since your last act, so a signal buffered BEFORE the action can never fake a pass; pass `since` (an observe/act cursor) to widen or narrow that window explicitly.",
4938
5803
  inputSchema: {
4939
- predicate: PredicateSchema.describe("Predicate to wait for: { signal }, { net }, { element } or a combination."),
4940
- timeout_ms: z15.number().optional().describe("Maximum wait in milliseconds. Default: 4000."),
4941
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the wait to events after that act."),
5804
+ predicate: PredicateSchema.describe('Predicate to wait for: { signal }, { net }, { element }, { kind: "settled", quietMs } (deterministic network + DOM idle \u2014 prefer this over a fixed sleep), or a combination via allOf/anyOf.'),
5805
+ timeout_ms: z16.number().optional().describe("Maximum wait in milliseconds. Default: 4000."),
5806
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the wait to events after that act."),
4942
5807
  ...sessionIdShape6
4943
5808
  },
4944
5809
  outputSchema: {
4945
- pass: z15.boolean(),
4946
- evidence: z15.unknown().optional(),
4947
- failureReason: z15.string().optional(),
4948
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5810
+ pass: z16.boolean(),
5811
+ evidence: z16.unknown().optional(),
5812
+ failureReason: z16.string().optional(),
5813
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4949
5814
  },
4950
5815
  handler: async (deps, args) => {
4951
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5816
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4952
5817
  const predicate = PredicateSchema.parse(args["predicate"]);
4953
- const since = asNumber2(args["since"]) ?? session.lastActCursor() ?? 0;
4954
- const verdict = await waitForPredicate(session, predicate, asNumber2(args["timeout_ms"]) ?? 4e3, since);
5818
+ const since = asNumber(args["since"]) ?? session.lastActCursor() ?? 0;
5819
+ const verdict = await waitForPredicate(session, predicate, asNumber(args["timeout_ms"]) ?? 4e3, since);
4955
5820
  return withControl(session, { ...verdict, ...healthEnvelope(session) });
4956
5821
  }
4957
5822
  },
4958
5823
  {
4959
5824
  name: IrisTool.ASSERT,
4960
- description: "Evaluate a predicate (optionally waiting up to timeout_ms). Returns { pass, evidence, failureReason? }. The end of every verify loop. By default it only counts events since your last act, so a stale buffered signal can never fake a pass; pass `since` (an observe/act cursor) to set the window explicitly.",
5825
+ description: "Evaluate a predicate (optionally waiting up to timeout_ms). Returns { pass, evidence, failureReason? }. The end of every verify loop. Prefer a { signal } or { net } consequence over { element }/{ text } presence \u2014 a passing presence-only assertion returns `advice` because a wrong/healed element can fake it. By default it only counts events since your last act, so a stale buffered signal can never fake a pass; pass `since` (an observe/act cursor) to set the window explicitly.",
4961
5826
  inputSchema: {
4962
5827
  predicate: PredicateSchema.describe("Predicate to evaluate: { signal }, { net }, { element } or a combination."),
4963
- timeout_ms: z15.number().optional().describe("If > 0, wait up to this many milliseconds before failing. Default: 0 (evaluate once)."),
4964
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the assertion to events after that act."),
5828
+ timeout_ms: z16.number().optional().describe("If > 0, wait up to this many milliseconds before failing. Default: 0 (evaluate once)."),
5829
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the assertion to events after that act."),
4965
5830
  ...sessionIdShape6
4966
5831
  },
4967
5832
  outputSchema: {
4968
- pass: z15.boolean(),
4969
- evidence: z15.unknown().optional(),
4970
- failureReason: z15.string().optional(),
4971
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5833
+ pass: z16.boolean(),
5834
+ evidence: z16.unknown().optional(),
5835
+ failureReason: z16.string().optional(),
5836
+ advice: z16.string().optional().describe("Present on a PASSING presence-only assertion \u2014 nudges toward a consequence."),
5837
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4972
5838
  },
4973
5839
  handler: async (deps, args) => {
4974
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5840
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4975
5841
  const predicate = PredicateSchema.parse(args["predicate"]);
4976
- const timeout2 = asNumber2(args["timeout_ms"]) ?? 0;
4977
- const since = asNumber2(args["since"]) ?? session.lastActCursor() ?? 0;
5842
+ const timeout2 = asNumber(args["timeout_ms"]) ?? 0;
5843
+ const since = asNumber(args["since"]) ?? session.lastActCursor() ?? 0;
4978
5844
  const verdict = timeout2 > 0 ? await waitForPredicate(session, predicate, timeout2, since) : await evaluatePredicate(session, predicate, since);
4979
- return withControl(session, { ...verdict, ...healthEnvelope(session) });
5845
+ const advice = verdict.pass && isPresenceOnlyAssertion(predicate) ? { advice: PRESENCE_ONLY_ADVICE } : {};
5846
+ return withControl(session, { ...verdict, ...advice, ...healthEnvelope(session) });
4980
5847
  }
4981
5848
  },
4982
5849
  {
4983
5850
  name: IrisTool.NETWORK,
4984
5851
  description: 'Filtered list of network calls. Fast path for "did POST /x return 200?". A zero-match filter returns a `hint` { totalInWindow, present[] } of the calls that DID fire, so a miss is diagnosable.',
4985
5852
  inputSchema: {
4986
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to requests fired after that act."),
4987
- method: z15.string().optional().describe("HTTP method filter: GET | POST | PUT | DELETE | PATCH etc."),
4988
- urlContains: z15.string().optional().describe("Substring that the request URL must contain."),
4989
- status: z15.number().optional().describe("HTTP status code filter (e.g. 200, 404, 500)."),
5853
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to requests fired after that act."),
5854
+ method: z16.string().optional().describe("HTTP method filter: GET | POST | PUT | DELETE | PATCH etc."),
5855
+ urlContains: z16.string().optional().describe("Substring that the request URL must contain."),
5856
+ status: z16.number().optional().describe("HTTP status code filter (e.g. 200, 404, 500)."),
5857
+ limit: z16.number().optional().describe("Keep only the most recent N matching calls (older are dropped and counted in droppedOldest) \u2014 cuts tokens on a wide window."),
4990
5858
  ...sessionIdShape6
4991
5859
  },
4992
5860
  outputSchema: {
4993
- calls: z15.array(z15.unknown()),
4994
- hint: z15.object({ totalInWindow: z15.number(), present: z15.array(z15.string()) }).optional()
5861
+ calls: z16.array(z16.unknown()),
5862
+ total: z16.number().optional().describe("Total matches before `limit` \u2014 present only when capped."),
5863
+ droppedOldest: z16.number().optional().describe("How many older matches `limit` dropped."),
5864
+ hint: z16.object({ totalInWindow: z16.number(), present: z16.array(z16.string()) }).optional(),
5865
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional()
4995
5866
  },
4996
5867
  handler: (deps, args) => {
4997
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4998
- const since = asNumber2(args["since"]) ?? 0;
4999
- const method = asString4(args["method"]);
5000
- const urlContains = asString4(args["urlContains"]);
5001
- const status = asNumber2(args["status"]);
5868
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5869
+ const since = asNumber(args["since"]) ?? 0;
5870
+ const method = asString(args["method"]);
5871
+ const urlContains = asString(args["urlContains"]);
5872
+ const status = asNumber(args["status"]);
5873
+ const limit = asNumber(args["limit"]);
5002
5874
  const allNet = session.eventsSince(since).filter((e) => e.type === EventType.NET_REQUEST);
5003
- const calls = allNet.filter((e) => matchNet(e, method, urlContains, status));
5004
- if (calls.length === 0 && allNet.length > 0) {
5005
- return Promise.resolve({ calls, hint: netEmptyHint(allNet) });
5875
+ const matched = allNet.filter((e) => matchNet(e, method, urlContains, status));
5876
+ if (matched.length === 0 && allNet.length > 0) {
5877
+ return Promise.resolve(withSizeCost({ calls: matched, hint: netEmptyHint(allNet) }));
5006
5878
  }
5007
- return Promise.resolve({ calls });
5879
+ const { events: calls, droppedOldest } = applyEventBudget(matched, limit);
5880
+ return Promise.resolve(withSizeCost(droppedOldest > 0 ? { calls, total: matched.length, droppedOldest } : { calls }));
5008
5881
  }
5009
5882
  },
5010
5883
  {
5011
5884
  name: IrisTool.CONSOLE,
5012
5885
  description: 'Console/error log. Fast path for "were there any errors during this flow?". When a level filter matches nothing, returns a `hint` { totalInWindow, byLevel } so 0 errors is distinguishable from a silent page.',
5013
5886
  inputSchema: {
5014
- level: z15.string().optional().describe("Log level filter: error | warn | info | log. Omit to return all levels."),
5015
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to log entries after that act."),
5887
+ level: z16.string().optional().describe("Log level filter: error | warn | info | log. Omit to return all levels."),
5888
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to log entries after that act."),
5889
+ limit: z16.number().optional().describe("Keep only the most recent N matching entries (older are dropped and counted in droppedOldest) \u2014 cuts tokens when a page spams the console."),
5016
5890
  ...sessionIdShape6
5017
5891
  },
5018
5892
  outputSchema: {
5019
- logs: z15.array(z15.unknown()),
5020
- hint: z15.object({ totalInWindow: z15.number(), byLevel: z15.record(z15.number()) }).optional()
5893
+ logs: z16.array(z16.unknown()),
5894
+ total: z16.number().optional().describe("Total matches before `limit` \u2014 present only when capped."),
5895
+ droppedOldest: z16.number().optional().describe("How many older matches `limit` dropped."),
5896
+ hint: z16.object({ totalInWindow: z16.number(), byLevel: z16.record(z16.number()) }).optional(),
5897
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional()
5021
5898
  },
5022
5899
  handler: (deps, args) => {
5023
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5024
- const since = asNumber2(args["since"]) ?? 0;
5025
- const level = asString4(args["level"]);
5900
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5901
+ const since = asNumber(args["since"]) ?? 0;
5902
+ const level = asString(args["level"]);
5903
+ const limit = asNumber(args["limit"]);
5026
5904
  const allConsole = session.eventsSince(since).filter(isConsoleEvent);
5027
- const logs = allConsole.filter((e) => matchConsole(e, level));
5028
- if (logs.length === 0 && allConsole.length > 0) {
5029
- return Promise.resolve({ logs, hint: consoleEmptyHint(allConsole) });
5905
+ const matched = allConsole.filter((e) => matchConsole(e, level));
5906
+ if (matched.length === 0 && allConsole.length > 0) {
5907
+ return Promise.resolve(withSizeCost({ logs: matched, hint: consoleEmptyHint(allConsole) }));
5030
5908
  }
5031
- return Promise.resolve({ logs });
5909
+ const { events: logs, droppedOldest } = applyEventBudget(matched, limit);
5910
+ return Promise.resolve(withSizeCost(droppedOldest > 0 ? { logs, total: matched.length, droppedOldest } : { logs }));
5032
5911
  }
5033
5912
  },
5034
5913
  {
@@ -5036,24 +5915,24 @@ var TOOLS = [
5036
5915
  description: "Currently running + recently completed animations with targets/timing.",
5037
5916
  inputSchema: { ...sessionIdShape6 },
5038
5917
  outputSchema: {
5039
- animations: z15.array(z15.unknown())
5918
+ animations: z16.array(z16.unknown())
5040
5919
  },
5041
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.ANIMATIONS, {})
5920
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.ANIMATIONS, {})
5042
5921
  },
5043
5922
  {
5044
5923
  name: IrisTool.BASELINE_SAVE,
5045
5924
  description: "Snapshot the current semantic state under a name, to diff against later (regression detection).",
5046
5925
  inputSchema: {
5047
- name: z15.string().describe('Label for this baseline snapshot (e.g. "dashboard-initial"). Use the same name in iris_diff to compare.'),
5926
+ name: z16.string().describe('Label for this baseline snapshot (e.g. "dashboard-initial"). Use the same name in iris_diff to compare.'),
5048
5927
  ...sessionIdShape6
5049
5928
  },
5050
5929
  outputSchema: {
5051
- baseline: z15.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
5052
- lineCount: z15.number()
5930
+ baseline: z16.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
5931
+ lineCount: z16.number()
5053
5932
  },
5054
5933
  handler: async (deps, args) => {
5055
- const name = asString4(args["name"]) ?? "default";
5056
- const { lines, route } = await snapshotTree(deps, asString4(args["sessionId"]));
5934
+ const name = asString(args["name"]) ?? "default";
5935
+ const { lines, route } = await snapshotTree(deps, asString(args["sessionId"]));
5057
5936
  deps.baselines.save({ name, lines, route });
5058
5937
  return { baseline: name, lineCount: lines.length };
5059
5938
  }
@@ -5063,7 +5942,7 @@ var TOOLS = [
5063
5942
  description: "List saved baseline names.",
5064
5943
  inputSchema: {},
5065
5944
  outputSchema: {
5066
- baselines: z15.array(z15.string())
5945
+ baselines: z16.array(z16.string())
5067
5946
  },
5068
5947
  handler: (deps) => Promise.resolve({ baselines: deps.baselines.list() })
5069
5948
  },
@@ -5071,23 +5950,23 @@ var TOOLS = [
5071
5950
  name: IrisTool.DIFF,
5072
5951
  description: 'Diff current semantic state vs a saved baseline: REMOVED/ADDED elements + console-error count. Call iris_baseline_list to list saved baselines, iris_baseline_save to create one. Pass `baseline` (name from iris_baseline_list). Answers "did anything silently go missing/break?".',
5073
5952
  inputSchema: {
5074
- baseline: z15.string().describe("Baseline name to compare against. Call iris_baseline_list to get available names; names are created by iris_baseline_save."),
5953
+ baseline: z16.string().describe("Baseline name to compare against. Call iris_baseline_list to get available names; names are created by iris_baseline_save."),
5075
5954
  ...sessionIdShape6
5076
5955
  },
5077
5956
  outputSchema: {
5078
- baseline: z15.string(),
5079
- removed: z15.array(z15.string()),
5080
- added: z15.array(z15.string()),
5081
- consoleErrors: z15.number(),
5082
- routeChanged: z15.boolean()
5957
+ baseline: z16.string(),
5958
+ removed: z16.array(z16.string()),
5959
+ added: z16.array(z16.string()),
5960
+ consoleErrors: z16.number(),
5961
+ routeChanged: z16.boolean()
5083
5962
  },
5084
5963
  handler: async (deps, args) => {
5085
- const name = asString4(args["baseline"]) ?? "default";
5964
+ const name = asString(args["baseline"]) ?? "default";
5086
5965
  const base = deps.baselines.get(name);
5087
5966
  if (base === void 0)
5088
5967
  throw new Error(`no baseline named '${name}'`);
5089
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5090
- const { lines, route } = await snapshotTree(deps, asString4(args["sessionId"]));
5968
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5969
+ const { lines, route } = await snapshotTree(deps, asString(args["sessionId"]));
5091
5970
  const { removed, added } = diffLines(base.lines, lines);
5092
5971
  const consoleErrors = session.eventsSince(0).filter((e) => e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT).length;
5093
5972
  return { baseline: name, removed, added, consoleErrors, routeChanged: base.route !== route };
@@ -5097,16 +5976,16 @@ var TOOLS = [
5097
5976
  name: IrisTool.RECORD_START,
5098
5977
  description: "Start recording the event timeline under a name (for replay / a flow report).",
5099
5978
  inputSchema: {
5100
- recordingName: z15.string().describe("Identifier for this recording. Pass the same name to iris_record_stop and iris_replay."),
5979
+ recordingName: z16.string().describe("Identifier for this recording. Pass the same name to iris_record_stop and iris_replay."),
5101
5980
  ...sessionIdShape6
5102
5981
  },
5103
5982
  outputSchema: {
5104
- recordingName: z15.string(),
5105
- since: z15.number()
5983
+ recordingName: z16.string(),
5984
+ since: z16.number()
5106
5985
  },
5107
5986
  handler: (deps, args) => {
5108
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5109
- const name = asString4(args["recordingName"]) ?? "default";
5987
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5988
+ const name = asString(args["recordingName"]) ?? "default";
5110
5989
  const cursor = session.elapsed();
5111
5990
  deps.recordings.start(name, cursor);
5112
5991
  return Promise.resolve({ recordingName: name, since: cursor });
@@ -5116,17 +5995,17 @@ var TOOLS = [
5116
5995
  name: IrisTool.RECORD_STOP,
5117
5996
  description: "Stop the recording identified by `recordingName` and return both the reaction report for the span and a compiled, replayable { program: { version, steps:[{tool,args,stable}] } } of the agent acts captured during it.",
5118
5997
  inputSchema: {
5119
- recordingName: z15.string().describe("Identifier of an active recording started with iris_record_start."),
5998
+ recordingName: z16.string().describe("Identifier of an active recording started with iris_record_start."),
5120
5999
  ...sessionIdShape6
5121
6000
  },
5122
6001
  outputSchema: {
5123
- recordingName: z15.string(),
5124
- program: z15.unknown(),
5125
- warning: z15.string().optional()
6002
+ recordingName: z16.string(),
6003
+ program: z16.unknown(),
6004
+ warning: z16.string().optional()
5126
6005
  },
5127
6006
  handler: (deps, args) => {
5128
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5129
- const name = asString4(args["recordingName"]) ?? "default";
6007
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
6008
+ const name = asString(args["recordingName"]) ?? "default";
5130
6009
  const rec = deps.recordings.stop(name);
5131
6010
  if (rec === void 0)
5132
6011
  throw new Error(`no active recording named '${name}'`);
@@ -5152,29 +6031,30 @@ var TOOLS = [
5152
6031
  },
5153
6032
  {
5154
6033
  name: IrisTool.REPLAY,
5155
- description: "Re-execute a previously recorded program by recordingName. Re-resolves each step to its element by testid (falling back to the stored ref for unstable steps) and runs the actions in order against the live session. Stops at the first failure. Returns { ok, steps:[{tool,ok,error?,note?}] }.",
6034
+ description: "Re-execute a previously recorded program by recordingName. Re-resolves each step to its element by testid (falling back to the stored ref for unstable steps) and runs the actions in order against the live session. Stops at the first failure. Destructive controls require confirmDangerous:true on every replay; confirmation is never persisted. Returns { ok, steps:[{tool,ok,error?,note?}] }.",
5156
6035
  inputSchema: {
5157
- recordingName: z15.string().describe("Name of a compiled recording (from iris_record_stop) to re-execute."),
6036
+ recordingName: z16.string().describe("Name of a compiled recording (from iris_record_stop) to re-execute."),
6037
+ confirmDangerous: z16.boolean().optional().describe("Set true to allow destructive controls during this replay only."),
5158
6038
  ...sessionIdShape6
5159
6039
  },
5160
6040
  outputSchema: {
5161
- recordingName: z15.string(),
5162
- ok: z15.boolean(),
5163
- steps: z15.array(z15.object({
5164
- tool: z15.string(),
5165
- ok: z15.boolean(),
5166
- error: z15.string().optional(),
5167
- note: z15.string().optional()
6041
+ recordingName: z16.string(),
6042
+ ok: z16.boolean(),
6043
+ steps: z16.array(z16.object({
6044
+ tool: z16.string(),
6045
+ ok: z16.boolean(),
6046
+ error: z16.string().optional(),
6047
+ note: z16.string().optional()
5168
6048
  }))
5169
6049
  },
5170
6050
  handler: async (deps, args) => {
5171
- const name = asString4(args["recordingName"]) ?? "default";
6051
+ const name = asString(args["recordingName"]) ?? "default";
5172
6052
  const program = deps.recordings.getCompiled(name);
5173
6053
  if (program === void 0)
5174
6054
  throw new Error(`no compiled recording named '${name}'`);
5175
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6055
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5176
6056
  const since = session.elapsed();
5177
- const steps = await replayProgram(session, program);
6057
+ const steps = await replayProgram(session, program, args["confirmDangerous"] === true);
5178
6058
  return { recordingName: name, since, steps, ok: steps.every((s) => s.ok) };
5179
6059
  }
5180
6060
  },
@@ -5182,13 +6062,13 @@ var TOOLS = [
5182
6062
  name: IrisTool.NARRATE,
5183
6063
  description: "Narrate your intent on the page (presenter HUD) so the human watching sees what you are about to do and why. Use a short sentence before a meaningful action.",
5184
6064
  inputSchema: {
5185
- text: z15.string().describe("Short sentence describing your next action, shown on the presenter HUD for the developer watching."),
5186
- level: z15.string().optional().describe("Display severity: info | warn | error. Default: info."),
6065
+ text: z16.string().describe("Short sentence describing your next action, shown on the presenter HUD for the developer watching."),
6066
+ level: z16.string().optional().describe("Display severity: info | warn | error. Default: info."),
5187
6067
  ...sessionIdShape6
5188
6068
  },
5189
- outputSchema: { ok: z15.boolean() },
6069
+ outputSchema: { ok: z16.boolean() },
5190
6070
  handler: async (deps, args) => {
5191
- const result = await commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.NARRATE, {
6071
+ const result = await commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.NARRATE, {
5192
6072
  text: args["text"],
5193
6073
  level: args["level"]
5194
6074
  });
@@ -5199,16 +6079,16 @@ var TOOLS = [
5199
6079
  name: IrisTool.CLOCK,
5200
6080
  description: "Control a fake clock: { freeze:true } to freeze time, { advanceMs:N } to fast-forward timers (toasts, debounces, auto-dismiss), { reset:true } to restore. Lets you test time-gated UI deterministically.",
5201
6081
  inputSchema: {
5202
- freeze: z15.boolean().optional().describe("Freeze the fake clock. Time stops advancing until advanceMs or reset."),
5203
- advanceMs: z15.number().optional().describe("Fast-forward time by this many milliseconds \u2014 triggers debounces, toasts, auto-dismiss timers."),
5204
- reset: z15.boolean().optional().describe("Restore the real clock."),
6082
+ freeze: z16.boolean().optional().describe("Freeze the fake clock. Time stops advancing until advanceMs or reset."),
6083
+ advanceMs: z16.number().optional().describe("Fast-forward time by this many milliseconds \u2014 triggers debounces, toasts, auto-dismiss timers."),
6084
+ reset: z16.boolean().optional().describe("Restore the real clock."),
5205
6085
  ...sessionIdShape6
5206
6086
  },
5207
6087
  outputSchema: {
5208
- ok: z15.boolean().optional(),
5209
- elapsed: z15.number().optional()
6088
+ ok: z16.boolean().optional(),
6089
+ elapsed: z16.number().optional()
5210
6090
  },
5211
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.CLOCK, {
6091
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.CLOCK, {
5212
6092
  freeze: args["freeze"],
5213
6093
  advanceMs: args["advanceMs"],
5214
6094
  reset: args["reset"]
@@ -5218,27 +6098,27 @@ var TOOLS = [
5218
6098
  name: IrisTool.STATE,
5219
6099
  description: "Read live framework state without the app pre-broadcasting it. PREFERRED/RELIABLE: `store` reads a registered store (e.g. 'workspace'); omit `store` to read all stores. To avoid paying for a huge store, scope the read: `path` extracts a dot-path sub-tree (e.g. 'captionCache.v3', with numeric array indices), and `depth` collapses anything deeper than N levels to a size marker. A wrong `path` returns { found:false, availableKeys } so it is diagnosable. `ref` attempts a best-effort read of the nearest React component's hook state and is BOUNDED \u2014 on failure it returns component: { ok: false, reason: 'component-state-unavailable' }. Without path/depth: returns { stores, storeNames, component? }.",
5220
6100
  inputSchema: {
5221
- ref: z15.string().optional().describe("Element ref \u2014 attempts a best-effort read of the nearest React component's hook state."),
5222
- store: z15.string().optional().describe("Registered store name (e.g. 'workspace'). Omit to read all stores."),
5223
- path: z15.string().optional().describe("Dot-path into the store (e.g. 'captionCache.v3'). Numeric array indices are supported."),
5224
- depth: z15.number().optional().describe("Collapse anything deeper than N levels to a size marker \u2014 avoids huge outputs for large stores."),
6101
+ ref: z16.string().optional().describe("Element ref \u2014 attempts a best-effort read of the nearest React component's hook state."),
6102
+ store: z16.string().optional().describe("Registered store name (e.g. 'workspace'). Omit to read all stores."),
6103
+ path: z16.string().optional().describe("Dot-path into the store (e.g. 'captionCache.v3'). Numeric array indices are supported."),
6104
+ depth: z16.number().optional().describe("Collapse anything deeper than N levels to a size marker \u2014 avoids huge outputs for large stores."),
5225
6105
  ...sessionIdShape6
5226
6106
  },
5227
6107
  outputSchema: {
5228
- stores: z15.record(z15.unknown()).optional(),
5229
- storeNames: z15.array(z15.string()).optional(),
5230
- found: z15.boolean().optional(),
5231
- value: z15.unknown().optional(),
5232
- component: z15.object({ ok: z15.boolean(), reason: z15.string().optional(), state: z15.unknown().optional() }).optional()
6108
+ stores: z16.record(z16.unknown()).optional(),
6109
+ storeNames: z16.array(z16.string()).optional(),
6110
+ found: z16.boolean().optional(),
6111
+ value: z16.unknown().optional(),
6112
+ component: z16.object({ ok: z16.boolean(), reason: z16.string().optional(), state: z16.unknown().optional() }).optional()
5233
6113
  },
5234
6114
  handler: async (deps, args) => {
5235
- const store = asString4(args["store"]);
5236
- const result = await commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.STATE_READ, {
6115
+ const store = asString(args["store"]);
6116
+ const result = await commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.STATE_READ, {
5237
6117
  ref: args["ref"],
5238
6118
  store
5239
6119
  });
5240
- const path = asString4(args["path"]);
5241
- const depth = asNumber2(args["depth"]);
6120
+ const path = asString(args["path"]);
6121
+ const depth = asNumber(args["depth"]);
5242
6122
  if (path === void 0 && depth === void 0)
5243
6123
  return result;
5244
6124
  const root = result;
@@ -5258,16 +6138,16 @@ var TOOLS = [
5258
6138
  name: IrisTool.EXPLORE,
5259
6139
  description: "Autonomous-exploration helper: list interactive elements (with refs) + current console-error count, so the agent can drive the app and report anomalies.",
5260
6140
  inputSchema: {
5261
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the interactive element list to a subtree."),
6141
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the interactive element list to a subtree."),
5262
6142
  ...sessionIdShape6
5263
6143
  },
5264
6144
  outputSchema: {
5265
- interactive: z15.array(z15.unknown()),
5266
- consoleErrors: z15.number(),
5267
- hint: z15.string()
6145
+ interactive: z16.array(z16.unknown()),
6146
+ consoleErrors: z16.number(),
6147
+ hint: z16.string()
5268
6148
  },
5269
6149
  handler: async (deps, args) => {
5270
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6150
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5271
6151
  const result = await session.command(IrisCommand.SNAPSHOT, {
5272
6152
  mode: SnapshotMode.INTERACTIVE,
5273
6153
  scope: args["scope"]
@@ -5285,6 +6165,7 @@ var TOOLS = [
5285
6165
  },
5286
6166
  // iris_capabilities (live | fromDisk) + iris_contract_save. See contract-tools.ts.
5287
6167
  ...CONTRACT_TOOLS,
6168
+ ...DOMAIN_TOOLS,
5288
6169
  // iris_flow_save / iris_flow_list / iris_flow_load. See flow-tools.ts.
5289
6170
  ...FLOW_TOOLS,
5290
6171
  // iris_project (read history + diff-vs-last) / iris_run_record. See project-tools.ts.
@@ -5438,7 +6319,7 @@ async function runTool(tool, deps, args) {
5438
6319
  return result;
5439
6320
  if (!isPlainObject(result) || "session" in result)
5440
6321
  return result;
5441
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6322
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5442
6323
  const envelope = { ...healthEnvelope(session) };
5443
6324
  const lease = session.takeSessionLease();
5444
6325
  if (lease !== void 0)
@@ -5574,7 +6455,17 @@ var IRIS_HOME2 = join4(homedir2(), ".iris");
5574
6455
  // ../server/dist/index.js
5575
6456
  async function start(options = {}) {
5576
6457
  const port = options.port ?? IRIS_DEFAULT_PORT;
5577
- const bridge = new Bridge({ port });
6458
+ const envToken = process.env["IRIS_TOKEN"];
6459
+ const envOrigins = process.env["IRIS_ALLOWED_ORIGINS"];
6460
+ const host = options.host ?? process.env["IRIS_HOST"];
6461
+ const token = options.token ?? (envToken !== void 0 && envToken.length > 0 ? envToken : void 0);
6462
+ const allowedOrigins = options.allowedOrigins ?? envOrigins?.split(",").map((origin) => origin.trim()).filter((origin) => origin.length > 0);
6463
+ const bridge = new Bridge({
6464
+ port,
6465
+ ...host === void 0 ? {} : { host },
6466
+ ...token === void 0 ? {} : { token },
6467
+ ...allowedOrigins === void 0 ? {} : { allowedOrigins }
6468
+ });
5578
6469
  const reaper = new SessionReaper(bridge.sessions);
5579
6470
  reaper.start();
5580
6471
  const baselines = new BaselineStore();
@@ -5916,53 +6807,6 @@ function createTestContext(invoke, options = {}) {
5916
6807
  };
5917
6808
  }
5918
6809
 
5919
- // ../test/dist/success-assert.js
5920
- function successToPredicate(success, dynamic) {
5921
- const parts = [];
5922
- if (success.signal !== void 0) {
5923
- parts.push(success.signalData !== void 0 ? { kind: PredicateKind.SIGNAL, name: success.signal, dataMatches: success.signalData } : { kind: PredicateKind.SIGNAL, name: success.signal });
5924
- }
5925
- if (success.net !== void 0) {
5926
- const net = { kind: PredicateKind.NET };
5927
- if (success.net.method !== void 0)
5928
- net.method = success.net.method;
5929
- if (success.net.urlContains !== void 0)
5930
- net.urlContains = success.net.urlContains;
5931
- if (success.net.status !== void 0)
5932
- net.status = success.net.status;
5933
- parts.push(net);
5934
- }
5935
- const element = success.element;
5936
- if (element !== void 0) {
5937
- const testid = element.testid;
5938
- if (testid === void 0 || !dynamic.has(testid)) {
5939
- const query = {};
5940
- if (testid !== void 0)
5941
- query["testid"] = testid;
5942
- if (element.role !== void 0)
5943
- query["role"] = element.role;
5944
- if (element.name !== void 0)
5945
- query["name"] = element.name;
5946
- if (Object.keys(query).length > 0) {
5947
- parts.push({ kind: PredicateKind.ELEMENT, query });
5948
- }
5949
- }
5950
- }
5951
- if (parts.length === 0)
5952
- return void 0;
5953
- if (parts.length === 1)
5954
- return parts[0];
5955
- return { kind: "allOf", predicates: parts };
5956
- }
5957
- async function assertSuccess(session, success, dynamic, waitForSignal, timeoutMs) {
5958
- if (success === void 0)
5959
- return { pass: true };
5960
- const predicate = successToPredicate(success, dynamic);
5961
- if (predicate === void 0)
5962
- return { pass: true };
5963
- return waitForSignal(session, predicate, timeoutMs);
5964
- }
5965
-
5966
6810
  // ../test/dist/flow-spec.js
5967
6811
  function toDynamicSet(flow) {
5968
6812
  return new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));