@syrin/iris 0.4.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
@@ -191,16 +191,40 @@ ${body}
191
191
  </${JUnit.SUITE}>
192
192
  `;
193
193
  }
194
- async function writeJUnit(fs, path, results, opts) {
195
- await fs.writeFile(path, toJUnitXml(results, opts));
194
+ async function writeJUnit(fs2, path, results, opts) {
195
+ await fs2.writeFile(path, toJUnitXml(results, opts));
196
196
  }
197
197
 
198
198
  // ../test/dist/boot.js
199
- import { join as join3 } from "path";
199
+ import { join as join6 } from "path";
200
200
 
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",
@@ -243,6 +267,7 @@ var CRAWL_DEFAULTS = {
243
267
  /** HTTP status at/above which a response counts as a failed request. */
244
268
  FAILED_STATUS: 400
245
269
  };
270
+ var UpdateCheckIntervalMs = 24 * 60 * 60 * 1e3;
246
271
  var CONTRACT_FILE_VERSION = 1;
247
272
  var FROM_DISK_ARG = "fromDisk";
248
273
  var ContractReadError = {
@@ -301,7 +326,7 @@ var ReplayStatus = {
301
326
  DRIFT: "drift",
302
327
  // an anchor missed (testid renamed / signal not observed) — legible drift returned
303
328
  ERROR: "error"
304
- // the flow file could not be loaded (missing/invalid) no steps ran
329
+ // the flow could not load or a resolved action failed
305
330
  };
306
331
  var DriftReason = {
307
332
  TESTID_NOT_FOUND: "testid_not_found",
@@ -335,8 +360,10 @@ var HealStatus = {
335
360
  // drift exists but no proposal cleared the confidence floor
336
361
  NOTHING_TO_HEAL: "nothing_to_heal",
337
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)
338
365
  ERROR: "error"
339
- // flow missing/malformed/invalid-name no steps ran
366
+ // flow missing/malformed/invalid-name, or a resolved action failed
340
367
  };
341
368
  var HEAL_CONFIDENCE_MIN = 0.5;
342
369
  var AnnotationTarget = {
@@ -352,7 +379,8 @@ var AnnotationErrorCode = {
352
379
  var COMPILED_PREDICATE_PREFIX = "will";
353
380
  var RING_BUFFER_DEFAULTS = {
354
381
  MAX_EVENTS: 2e3,
355
- MAX_AGE_MS: 6e4
382
+ MAX_AGE_MS: 6e4,
383
+ MAX_BYTES: TRANSPORT_LIMITS.MAX_BUFFER_BYTES
356
384
  };
357
385
  var EventType = {
358
386
  DOM_ADDED: "dom.added",
@@ -537,6 +565,8 @@ var MessageKind = {
537
565
 
538
566
  // ../protocol/dist/messages.js
539
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);
540
570
  var HumanControlDataSchema = z.object({
541
571
  kind: z.nativeEnum(HumanControlKind),
542
572
  text: z.string().optional()
@@ -544,35 +574,37 @@ var HumanControlDataSchema = z.object({
544
574
  var IrisEventSchema = z.object({
545
575
  t: z.number(),
546
576
  type: z.nativeEnum(EventType),
547
- sessionId: z.string(),
577
+ sessionId: sessionIdSchema,
548
578
  /** Stable element reference this event concerns, when applicable (e.g. "e7"). */
549
- ref: z.string().optional(),
579
+ ref: refSchema.optional(),
550
580
  /** Event-type-specific payload. Kept open here; refined per observer at the edges. */
551
581
  data: z.record(z.unknown()).default({})
552
582
  });
553
583
  var HelloMessageSchema = z.object({
554
584
  kind: z.literal(MessageKind.HELLO),
555
- protocolVersion: z.number(),
556
- sessionId: z.string(),
557
- url: z.string(),
558
- title: z.string(),
559
- 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(),
560
592
  /** Whether the app has advertised a capability registry (iris.describe). */
561
593
  hasCapabilities: z.boolean().optional()
562
594
  });
563
595
  var CommandMessageSchema = z.object({
564
596
  kind: z.literal(MessageKind.COMMAND),
565
- id: z.string(),
566
- sessionId: z.string().optional(),
567
- 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),
568
600
  args: z.record(z.unknown()).default({})
569
601
  });
570
602
  var CommandResultSchema = z.object({
571
603
  kind: z.literal(MessageKind.COMMAND_RESULT),
572
- id: z.string(),
604
+ id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH),
573
605
  ok: z.boolean(),
574
606
  result: z.unknown().optional(),
575
- error: z.string().optional()
607
+ error: z.string().max(TRANSPORT_LIMITS.MAX_ERROR_LENGTH).optional()
576
608
  });
577
609
  var EventMessageSchema = z.object({
578
610
  kind: z.literal(MessageKind.EVENT),
@@ -585,6 +617,25 @@ var IrisMessageSchema = z.discriminatedUnion("kind", [
585
617
  EventMessageSchema
586
618
  ]);
587
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
+
588
639
  // ../protocol/dist/toon.js
589
640
  var ROLE_MAP = {
590
641
  button: "btn",
@@ -846,24 +897,44 @@ var AnnotationSchema = z2.discriminatedUnion("kind", [
846
897
  ]);
847
898
 
848
899
  // ../server/dist/index.js
849
- import { join as join2 } from "path";
900
+ import { join as join5 } from "path";
850
901
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
851
902
 
903
+ // ../server/dist/http-server.js
904
+ import * as http from "http";
905
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
906
+
907
+ // ../server/dist/log.js
908
+ function log(event, fields = {}) {
909
+ const line = JSON.stringify({ event, ...fields });
910
+ process.stderr.write(`${line}
911
+ `);
912
+ }
913
+
852
914
  // ../server/dist/bridge.js
915
+ import { timingSafeEqual } from "crypto";
916
+ import * as http2 from "http";
853
917
  import { WebSocketServer } from "ws";
854
918
 
855
919
  // ../server/dist/events/ring-buffer.js
856
920
  var RingBuffer = class {
857
921
  #maxEvents;
858
922
  #maxAgeMs;
923
+ #maxBytes;
859
924
  #events = [];
925
+ #eventBytes = [];
926
+ #totalBytes = 0;
860
927
  #droppedCount = 0;
861
928
  constructor(options = {}) {
862
929
  this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
863
930
  this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
931
+ this.#maxBytes = options.maxBytes ?? RING_BUFFER_DEFAULTS.MAX_BYTES;
864
932
  }
865
933
  push(event, now) {
866
934
  this.#events.push(event);
935
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8");
936
+ this.#eventBytes.push(bytes);
937
+ this.#totalBytes += bytes;
867
938
  this.#evict(now);
868
939
  }
869
940
  /** Events at or after a given timestamp cursor. */
@@ -889,10 +960,14 @@ var RingBuffer = class {
889
960
  #evict(now) {
890
961
  const before = this.#events.length;
891
962
  const cutoff = now - this.#maxAgeMs;
892
- if (this.#events.length > this.#maxEvents) {
893
- 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;
894
970
  }
895
- this.#events = this.#events.filter((e) => e.t >= cutoff);
896
971
  this.#droppedCount += before - this.#events.length;
897
972
  }
898
973
  /** Snapshot of buffer health for the agent — total events held and cumulative drops since connect. */
@@ -1129,6 +1204,14 @@ var Session = class {
1129
1204
  this.#pending.delete(id);
1130
1205
  }
1131
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
+ }
1132
1215
  // ── Live-control: state machine + human→agent inbox (server-owned) ───────────────
1133
1216
  getState() {
1134
1217
  return this.#state;
@@ -1250,11 +1333,15 @@ var Session = class {
1250
1333
  var SessionManager = class {
1251
1334
  #sessions = /* @__PURE__ */ new Map();
1252
1335
  add(session) {
1336
+ const previous = this.#sessions.get(session.id);
1253
1337
  this.#sessions.set(session.id, session);
1338
+ return previous;
1254
1339
  }
1255
- remove(sessionId) {
1256
- this.#sessions.get(sessionId)?.rejectAll("session disconnected");
1257
- 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);
1258
1345
  }
1259
1346
  get(sessionId) {
1260
1347
  return this.#sessions.get(sessionId);
@@ -1271,7 +1358,16 @@ var SessionManager = class {
1271
1358
  }
1272
1359
  /**
1273
1360
  * Resolve the target session. With an explicit id, returns it. With none and exactly
1274
- * one connected, returns that. Otherwise throws a clear, agent-readable error.
1361
+ * one connected, returns that.
1362
+ *
1363
+ * With none and multiple connected, applies smart auto-selection:
1364
+ * 1. Prefer non-throttled sessions (not hidden + recently heard from).
1365
+ * 2. Within each tier, prefer lowest lastSeenMs (most recently active SDK heartbeat).
1366
+ * 3. If two or more non-throttled sessions are within 1 s of each other, throw —
1367
+ * genuinely ambiguous, agent must specify sessionId.
1368
+ * 4. If ALL sessions are throttled (e.g. user is working in their editor on another
1369
+ * desktop), skip the gap check and pick the freshest heartbeat. This lets the agent
1370
+ * keep working in the background without requiring sessionId every time.
1275
1371
  */
1276
1372
  resolve(sessionId) {
1277
1373
  if (sessionId !== void 0) {
@@ -1285,26 +1381,48 @@ var SessionManager = class {
1285
1381
  if (this.#sessions.size === 0) {
1286
1382
  throw new Error("no browser session connected \u2014 is your app running with @syrin/iris-browser enabled?");
1287
1383
  }
1288
- if (this.#sessions.size > 1) {
1289
- const ids = [...this.#sessions.keys()].join(", ");
1290
- throw new Error(`multiple sessions connected (${ids}); pass sessionId to target one`);
1384
+ const all = [...this.#sessions.values()];
1385
+ if (all.length === 1) {
1386
+ const [only] = all;
1387
+ if (only === void 0)
1388
+ throw new Error("session lookup failed");
1389
+ only.markAgentActivity();
1390
+ return only;
1291
1391
  }
1292
- const [only] = this.#sessions.values();
1293
- if (only === void 0)
1392
+ const scored = all.map((s) => ({ s, score: s.throttled() ? 1 : 0, ms: s.lastSeenMs() }));
1393
+ const bestScore = Math.min(...scored.map((x) => x.score));
1394
+ const candidates = scored.filter((x) => x.score === bestScore);
1395
+ candidates.sort((a, b) => a.ms - b.ms);
1396
+ const [best, runnerUp] = candidates;
1397
+ if (best === void 0)
1294
1398
  throw new Error("session lookup failed");
1295
- only.markAgentActivity();
1296
- return only;
1399
+ const allThrottled = bestScore === 1;
1400
+ const RECENCY_GAP_MS = allThrottled ? 0 : 1e3;
1401
+ const clearWinner = runnerUp === void 0 || best.ms + RECENCY_GAP_MS < runnerUp.ms;
1402
+ if (!clearWinner) {
1403
+ const detail = all.map((s) => `${s.id} (${s.throttled() ? "throttled" : "active"}, lastSeenMs=${s.lastSeenMs()})`).join(", ");
1404
+ throw new Error(`multiple sessions connected \u2014 pass sessionId to target one: ${detail}`);
1405
+ }
1406
+ best.s.markAgentActivity();
1407
+ return best.s;
1297
1408
  }
1298
1409
  };
1299
1410
 
1300
- // ../server/dist/log.js
1301
- function log(event, fields = {}) {
1302
- const line = JSON.stringify({ event, ...fields });
1303
- process.stderr.write(`${line}
1304
- `);
1305
- }
1306
-
1307
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
+ }
1308
1426
  function rawToString(raw) {
1309
1427
  if (typeof raw === "string")
1310
1428
  return raw;
@@ -1320,31 +1438,132 @@ var Bridge = class {
1320
1438
  ready;
1321
1439
  #wss;
1322
1440
  #clock;
1441
+ #token;
1442
+ #allowedOrigins;
1443
+ #maxMessagesPerSecond;
1444
+ #maxSessions;
1445
+ #maxPendingConnections;
1446
+ #helloTimeoutMs;
1447
+ #pendingConnections = 0;
1323
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
+ }
1324
1456
  this.#clock = options.clock ?? (() => Date.now());
1325
- this.#wss = new WebSocketServer({
1326
- port: options.port,
1327
- host: options.host ?? "127.0.0.1",
1328
- path: IRIS_WS_PATH
1329
- });
1330
- this.ready = new Promise((resolve) => {
1331
- this.#wss.on("listening", () => {
1332
- resolve(this.#wss.address().port);
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;
1463
+ if (options.server !== void 0) {
1464
+ const srv = options.server;
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
+ }
1333
1475
  });
1334
- });
1476
+ this.ready = new Promise((resolve) => {
1477
+ if (srv.listening) {
1478
+ resolve(srv.address().port);
1479
+ } else {
1480
+ srv.once("listening", () => {
1481
+ resolve(srv.address().port);
1482
+ });
1483
+ }
1484
+ });
1485
+ } else {
1486
+ this.#wss = new WebSocketServer({
1487
+ port: options.port,
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
+ }
1497
+ });
1498
+ this.ready = new Promise((resolve) => {
1499
+ this.#wss.on("listening", () => {
1500
+ resolve(this.#wss.address().port);
1501
+ });
1502
+ });
1503
+ }
1335
1504
  this.#wss.on("connection", (socket) => {
1336
1505
  this.#onConnection(socket);
1337
1506
  });
1338
1507
  }
1339
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;
1340
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);
1341
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
+ }
1342
1542
  const parsed = this.#parse(rawToString(raw));
1343
- if (parsed === null)
1543
+ if (parsed === null) {
1544
+ socket.close(1008, "invalid message");
1344
1545
  return;
1546
+ }
1345
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();
1346
1564
  session = new Session(parsed, socket, this.#clock);
1347
- this.sessions.add(session);
1565
+ const replaced = this.sessions.add(session);
1566
+ replaced?.disconnect("session replaced by a newer connection");
1348
1567
  log("session_connected", { sessionId: session.id, url: session.url });
1349
1568
  return;
1350
1569
  }
@@ -1358,15 +1577,28 @@ var Bridge = class {
1358
1577
  }
1359
1578
  });
1360
1579
  socket.on("close", () => {
1580
+ clearTimeout(helloTimer);
1581
+ releasePending();
1361
1582
  if (session !== void 0) {
1362
- this.sessions.remove(session.id);
1363
- log("session_disconnected", { sessionId: session.id });
1583
+ if (this.sessions.remove(session)) {
1584
+ log("session_disconnected", { sessionId: session.id });
1585
+ }
1364
1586
  }
1365
1587
  });
1366
1588
  socket.on("error", (err) => {
1367
1589
  log("socket_error", { error: err.message });
1368
1590
  });
1369
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
+ }
1370
1602
  #parse(text) {
1371
1603
  let json;
1372
1604
  try {
@@ -1383,6 +1615,8 @@ var Bridge = class {
1383
1615
  }
1384
1616
  close() {
1385
1617
  return new Promise((resolve) => {
1618
+ for (const client of this.#wss.clients)
1619
+ client.terminate();
1386
1620
  this.#wss.close(() => {
1387
1621
  resolve();
1388
1622
  });
@@ -1497,6 +1731,7 @@ var IrisTool = {
1497
1731
  STATE: "iris_state",
1498
1732
  CAPABILITIES: "iris_capabilities",
1499
1733
  CONTRACT_SAVE: "iris_contract_save",
1734
+ DOMAIN: "iris_domain",
1500
1735
  FLOW_SAVE: "iris_flow_save",
1501
1736
  FLOW_LIST: "iris_flow_list",
1502
1737
  FLOW_LOAD: "iris_flow_load",
@@ -1530,180 +1765,555 @@ var IrisTool = {
1530
1765
  /** Navigate the connected browser tab to a URL. */
1531
1766
  NAVIGATE: "iris_navigate",
1532
1767
  /** Reload the connected browser tab (soft or hard). */
1533
- REFRESH: "iris_refresh"
1768
+ REFRESH: "iris_refresh",
1769
+ /** Report running version, latest available, changelog, and breaking changes. */
1770
+ VERSION_INFO: "iris_version_info",
1771
+ /** Install the latest server version and restart (Claude Code reconnects automatically). */
1772
+ APPLY_UPDATE: "iris_apply_update",
1773
+ /** Restore the previous server version and restart. */
1774
+ ROLLBACK: "iris_rollback"
1534
1775
  };
1535
1776
 
1536
- // ../server/dist/project/iris-dir.js
1537
- import { join } from "path";
1538
- function irisDirPaths(root) {
1539
- return {
1540
- root,
1541
- contract: join(root, IrisDir.CONTRACT_FILE),
1542
- flows: join(root, IrisDir.FLOWS_SUBDIR),
1543
- baselines: join(root, IrisDir.BASELINES_SUBDIR),
1544
- project: join(root, IrisDir.PROJECT_FILE),
1545
- visual: join(root, IrisDir.VISUAL_SUBDIR)
1546
- };
1547
- }
1548
- function visualPath(root, name) {
1549
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.png`);
1550
- }
1551
- function visualDiffPath(root, name) {
1552
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.diff.png`);
1553
- }
1554
- function flowPath(root, name) {
1555
- return join(root, IrisDir.FLOWS_SUBDIR, `${name}.json`);
1556
- }
1557
- function isValidFlowName(name) {
1558
- return FLOW_NAME_PATTERN.test(name) && !name.includes("..");
1559
- }
1560
- async function ensureIrisDir(fs, root) {
1561
- const p = irisDirPaths(root);
1562
- await fs.mkdir(p.root);
1563
- await fs.mkdir(p.flows);
1564
- await fs.mkdir(p.baselines);
1565
- }
1566
- var JSON_INDENT = 2;
1567
- function stableSerialize(capabilities, generatedAt) {
1568
- const envelope = {
1569
- version: CONTRACT_FILE_VERSION,
1570
- generatedAt,
1571
- capabilities: {
1572
- testids: [...capabilities.testids].sort(),
1573
- signals: [...capabilities.signals].sort(),
1574
- stores: [...capabilities.stores].sort(),
1575
- 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() });
1576
1784
  }
1577
- };
1578
- return `${JSON.stringify(envelope, null, JSON_INDENT)}
1579
- `;
1580
- }
1581
- async function writeContract(fs, root, capabilities, now) {
1582
- await ensureIrisDir(fs, root);
1583
- await fs.writeFile(irisDirPaths(root).contract, stableSerialize(capabilities, now()));
1584
- }
1585
- async function readContract(fs, root) {
1586
- const path = irisDirPaths(root).contract;
1587
- if (!await fs.exists(path))
1588
- return { ok: false, reason: ContractReadError.MISSING };
1589
- let text;
1590
- try {
1591
- text = await fs.readFile(path);
1592
- } catch (error) {
1593
- return {
1594
- ok: false,
1595
- reason: fs.isNotFound(error) ? ContractReadError.MISSING : ContractReadError.MALFORMED
1596
- };
1597
- }
1598
- let parsed;
1599
- try {
1600
- parsed = JSON.parse(text);
1601
- } catch {
1602
- return { ok: false, reason: ContractReadError.MALFORMED };
1603
1785
  }
1604
- const result = ContractFileSchema.safeParse(parsed);
1605
- if (!result.success)
1606
- return { ok: false, reason: ContractReadError.MALFORMED };
1607
- return { ok: true, capabilities: result.data.capabilities, generatedAt: result.data.generatedAt };
1786
+ return items;
1608
1787
  }
1609
-
1610
- // ../server/dist/flows/flows.js
1611
1788
  function asString(value) {
1612
1789
  return typeof value === "string" ? value : void 0;
1613
1790
  }
1791
+ function asNumber(value) {
1792
+ return typeof value === "number" ? value : void 0;
1793
+ }
1614
1794
  function asRecord(value) {
1615
1795
  return typeof value === "object" && value !== null ? value : {};
1616
1796
  }
1617
- function degradedAnchor() {
1618
- 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;
1619
1801
  }
1620
- function subStepToFlowStep(raw) {
1621
- const sub = asRecord(raw);
1622
- const by = asString(sub["by"]);
1623
- const value = asString(sub["value"]);
1624
- const action = asString(sub["action"]);
1625
- const args = asRecord(sub["args"]);
1626
- if (by === QueryBy.TESTID && value !== void 0) {
1627
- return buildStep(IrisTool.ACT, { kind: AnchorKind.TESTID, value }, action, args, false);
1628
- }
1629
- return buildStep(IrisTool.ACT, degradedAnchor(), action, args, true);
1802
+ function asRecord2(value) {
1803
+ return typeof value === "object" && value !== null ? value : {};
1630
1804
  }
1631
- function buildStep(tool, anchor, action, args, degraded) {
1632
- const step = { tool, anchor, args };
1633
- if (action !== void 0)
1634
- step.action = action;
1635
- if (degraded)
1636
- step.degraded = true;
1637
- 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;
1638
1811
  }
1639
- function recordedStepToFlowStep(step) {
1640
- if (step.tool === IrisTool.ACT_SEQUENCE) {
1641
- const rawSubs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
1642
- const subs = rawSubs.map(subStepToFlowStep);
1643
- const degraded = subs.some((s) => s.degraded === true);
1644
- const anchor = subs[0]?.anchor ?? degradedAnchor();
1645
- const out2 = { tool: IrisTool.ACT_SEQUENCE, anchor, steps: subs };
1646
- if (degraded)
1647
- out2.degraded = true;
1648
- if (step.expect !== void 0)
1649
- out2.expect = step.expect;
1650
- 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
+ };
1651
1822
  }
1652
- const by = asString(step.args["by"]);
1653
- const value = asString(step.args["value"]);
1654
- const action = asString(step.args["action"]);
1655
- const args = asRecord(step.args["args"]);
1656
- 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);
1657
- if (step.expect !== void 0)
1658
- out.expect = step.expect;
1659
- return out;
1823
+ return {
1824
+ tool: IrisTool.ACT,
1825
+ stable: false,
1826
+ args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
1827
+ };
1660
1828
  }
1661
- function withAnnotations(flow, ann) {
1662
- if (ann === void 0)
1663
- return flow;
1664
- const steps = flow.steps.map((step, i) => {
1665
- const expect = ann.stepExpect.get(i);
1666
- 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 };
1667
1843
  });
1668
- const out = { ...flow, steps };
1669
- if (ann.dynamic.length > 0) {
1670
- out.dynamic = ann.dynamic.map((value) => ({ kind: AnchorKind.TESTID, value }));
1671
- }
1672
- if (ann.success !== void 0)
1673
- out.success = ann.success;
1674
- return out;
1844
+ return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
1675
1845
  }
1676
- var JSON_INDENT2 = 2;
1677
- var FLOW_SUFFIX = ".json";
1678
- var FlowStore = class {
1679
- #fs;
1680
- #root;
1681
- #clock;
1682
- constructor(fs, root, clock) {
1683
- this.#fs = fs;
1684
- this.#root = root;
1685
- this.#clock = clock;
1686
- }
1687
- /**
1688
- * The single byte-stable flow serializer: 2-space indent + one trailing newline. save(),
1689
- * saveFlow() and heal() all route through it so an unchanged flow that round-trips through any
1690
- * of them produces byte-identical on-disk content (locked by the byte-stability tests).
1691
- */
1692
- #serialize(flow) {
1693
- return `${JSON.stringify(flow, null, JSON_INDENT2)}
1694
- `;
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 };
1695
1858
  }
1696
- /**
1697
- * Convert a CompiledProgram (testid-normalized) into an anchored, on-disk flow + write it.
1698
- * Optionally fold structured annotations (per-step expect, dynamic[], success) onto
1699
- * the flow before writing. Omitting `annotations` reproduces the same bytes.
1700
- */
1701
- async save(program, annotations) {
1702
- if (!isValidFlowName(program.name)) {
1703
- return { ok: false, code: FlowErrorCode.INVALID_NAME };
1704
- }
1705
- const steps = program.steps.map(recordedStepToFlowStep);
1706
- const base = {
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 = {
1707
2317
  version: FLOW_FILE_VERSION,
1708
2318
  name: program.name,
1709
2319
  createdAt: this.#clock.now(),
@@ -1766,19 +2376,7 @@ var FlowStore = class {
1766
2376
  if (!loaded.ok)
1767
2377
  return { ok: false, code: loaded.code };
1768
2378
  const flow = loaded.value;
1769
- const byStep = /* @__PURE__ */ new Map();
1770
- for (const change of changes)
1771
- byStep.set(change.step, change);
1772
- const applied = [];
1773
- const steps = flow.steps.map((step, index) => {
1774
- const change = byStep.get(index);
1775
- if (change === void 0 || step.anchor.kind !== AnchorKind.TESTID || step.anchor.value !== change.from) {
1776
- return step;
1777
- }
1778
- applied.push(change);
1779
- return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
1780
- });
1781
- const next = { ...flow, steps };
2379
+ const { flow: next, applied } = applyHealChanges(flow, changes);
1782
2380
  await this.#fs.writeFile(flowPath(this.#root, name), this.#serialize(next));
1783
2381
  return { ok: true, value: { name, changed: applied } };
1784
2382
  }
@@ -1826,8 +2424,8 @@ var ProjectStore = class {
1826
2424
  #fs;
1827
2425
  #root;
1828
2426
  #clock;
1829
- constructor(fs, root, clock) {
1830
- this.#fs = fs;
2427
+ constructor(fs2, root, clock) {
2428
+ this.#fs = fs2;
1831
2429
  this.#root = root;
1832
2430
  this.#clock = clock;
1833
2431
  }
@@ -2006,10 +2604,10 @@ function createNodeFileSystem() {
2006
2604
 
2007
2605
  // ../server/dist/mcp.js
2008
2606
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2009
- import { z as z15 } from "zod";
2607
+ import { z as z17 } from "zod";
2010
2608
 
2011
2609
  // ../server/dist/tools/tools.js
2012
- import { z as z14 } from "zod";
2610
+ import { z as z16 } from "zod";
2013
2611
 
2014
2612
  // ../server/dist/input/real-input.js
2015
2613
  var DriveError = class extends Error {
@@ -2070,8 +2668,10 @@ async function performGesture(page, action, box, args, sleep) {
2070
2668
  }
2071
2669
  return { performed: false, center };
2072
2670
  }
2671
+ var HIDE_IRIS_CHROME_CSS = "[data-iris-overlay]{display:none !important}";
2672
+ var SCREENSHOT_DETERMINISM = { style: HIDE_IRIS_CHROME_CSS, animations: "disabled" };
2073
2673
  async function capturePage(page, opts) {
2074
- 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 });
2075
2675
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
2076
2676
  }
2077
2677
  var nodeSleep = (ms) => new Promise((resolve) => {
@@ -2306,6 +2906,7 @@ var PredicateSchema = z3.lazy(() => z3.discriminatedUnion("kind", [
2306
2906
  name: z3.string().optional(),
2307
2907
  dataMatches: z3.record(z3.unknown()).optional()
2308
2908
  }),
2909
+ z3.object({ kind: z3.literal("settled"), quietMs: z3.number().positive().optional() }),
2309
2910
  z3.object({ kind: z3.literal("allOf"), predicates: z3.array(PredicateSchema) }),
2310
2911
  z3.object({ kind: z3.literal("anyOf"), predicates: z3.array(PredicateSchema) }),
2311
2912
  z3.object({ kind: z3.literal("not"), predicate: PredicateSchema })
@@ -2502,6 +3103,39 @@ function evalSignal(events, p) {
2502
3103
  evidence: sameName.length > 0 ? { nearMiss: sameName } : void 0
2503
3104
  };
2504
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
+ }
2505
3139
  async function evaluatePredicate(session, predicate, since = 0) {
2506
3140
  const events = session.eventsSince(since);
2507
3141
  switch (predicate.kind) {
@@ -2519,6 +3153,8 @@ async function evaluatePredicate(session, predicate, since = 0) {
2519
3153
  return evalAnimation(events, predicate);
2520
3154
  case "signal":
2521
3155
  return evalSignal(events, predicate);
3156
+ case "settled":
3157
+ return evalSettled(events, predicate, session.elapsed());
2522
3158
  case "allOf": {
2523
3159
  const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since)));
2524
3160
  const failed = results.find((r) => !r.pass);
@@ -2544,6 +3180,10 @@ async function evaluatePredicate(session, predicate, since = 0) {
2544
3180
  function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2545
3181
  return new Promise((resolve) => {
2546
3182
  let done = false;
3183
+ const failed = (error) => ({
3184
+ pass: false,
3185
+ failureReason: error instanceof Error ? error.message : String(error)
3186
+ });
2547
3187
  const finish = (result) => {
2548
3188
  if (done)
2549
3189
  return;
@@ -2557,6 +3197,8 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2557
3197
  void evaluatePredicate(session, predicate, since).then((r) => {
2558
3198
  if (r.pass)
2559
3199
  finish(r);
3200
+ }).catch((error) => {
3201
+ finish(failed(error));
2560
3202
  });
2561
3203
  };
2562
3204
  const unsub = session.onEvent(() => {
@@ -2570,141 +3212,30 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2570
3212
  evidence: r.evidence,
2571
3213
  failureReason: r.failureReason ?? "timed out waiting for predicate"
2572
3214
  });
3215
+ }).catch((error) => {
3216
+ finish(failed(error));
2573
3217
  });
2574
3218
  }, timeoutMs);
2575
3219
  check();
2576
3220
  });
2577
3221
  }
2578
3222
 
2579
- // ../server/dist/flows/replay.js
2580
- function asString2(value) {
2581
- return typeof value === "string" ? value : void 0;
2582
- }
2583
- function asRecord2(value) {
2584
- return typeof value === "object" && value !== null ? value : {};
2585
- }
2586
- function compileActStep(args, res) {
2587
- const testid = asString2(asRecord2(res)["testid"]);
2588
- const action = asString2(args["action"]) ?? "";
2589
- const actArgs = asRecord2(args["args"]);
2590
- if (testid !== void 0) {
2591
- return {
2592
- tool: IrisTool.ACT,
2593
- stable: true,
2594
- args: { by: QueryBy.TESTID, value: testid, action, args: actArgs }
2595
- };
2596
- }
2597
- return {
2598
- tool: IrisTool.ACT,
2599
- stable: false,
2600
- args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
2601
- };
2602
- }
2603
- function compileSequenceStep(args, res) {
2604
- const inputSteps = Array.isArray(args["steps"]) ? args["steps"] : [];
2605
- const resolved = Array.isArray(asRecord2(res)["steps"]) ? asRecord2(res)["steps"] : [];
2606
- let stable = inputSteps.length > 0;
2607
- const subSteps = inputSteps.map((raw, i) => {
2608
- const step = asRecord2(raw);
2609
- const action = asString2(step["action"]) ?? "";
2610
- const stepArgs = asRecord2(step["args"]);
2611
- const testid = asString2(asRecord2(resolved[i])["testid"]);
2612
- if (testid !== void 0) {
2613
- return { by: QueryBy.TESTID, value: testid, action, args: stepArgs };
2614
- }
2615
- stable = false;
2616
- return { ref: asString2(step["ref"]) ?? "", action, args: stepArgs };
2617
- });
2618
- return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
2619
- }
2620
- async function resolveRef(session, step) {
2621
- const by = asString2(step.by);
2622
- const value = asString2(step.value);
2623
- if (by === QueryBy.TESTID && value !== void 0) {
2624
- const result = await session.command(IrisCommand.QUERY, { by, value });
2625
- if (!result.ok)
2626
- throw new Error(result.error ?? "query failed");
2627
- const elements = Array.isArray(asRecord2(result.result)["elements"]) ? asRecord2(result.result)["elements"] : [];
2628
- const ref2 = asString2(asRecord2(elements[0])["ref"]);
2629
- if (ref2 === void 0)
2630
- throw new Error(`testid '${value}' did not resolve in current page`);
2631
- return elements.length > 1 ? { ref: ref2, note: `ambiguous testid '${value}', used first match` } : { ref: ref2 };
2632
- }
2633
- const ref = asString2(step.ref);
2634
- if (ref === void 0 || ref.length === 0)
2635
- throw new Error("step has no testid or ref to resolve");
2636
- return { ref, note: "replayed by stale ref (not portable across sessions)" };
2637
- }
2638
- async function replayProgram(session, program) {
2639
- const results = [];
2640
- for (const step of program.steps) {
2641
- try {
2642
- if (step.tool === IrisTool.ACT_SEQUENCE) {
2643
- const subs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
2644
- const notes = [];
2645
- const liveSteps = [];
2646
- for (const raw of subs) {
2647
- const sub = asRecord2(raw);
2648
- const { ref, note } = await resolveRef(session, sub);
2649
- if (note !== void 0)
2650
- notes.push(note);
2651
- liveSteps.push({
2652
- ref,
2653
- action: asString2(sub["action"]) ?? "",
2654
- args: asRecord2(sub["args"])
2655
- });
2656
- }
2657
- const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps });
2658
- results.push(buildResult(step.tool, r.ok, r.error, notes));
2659
- if (!r.ok)
2660
- break;
2661
- } else {
2662
- const { ref, note } = await resolveRef(session, step.args);
2663
- const r = await session.command(IrisCommand.ACT, {
2664
- ref,
2665
- action: asString2(step.args["action"]) ?? "",
2666
- args: asRecord2(step.args["args"])
2667
- });
2668
- results.push(buildResult(step.tool, r.ok, r.error, note !== void 0 ? [note] : []));
2669
- if (!r.ok)
2670
- break;
2671
- }
2672
- } catch (e) {
2673
- results.push({
2674
- tool: step.tool,
2675
- ok: false,
2676
- error: e instanceof Error ? e.message : String(e)
2677
- });
2678
- break;
2679
- }
2680
- }
2681
- return results;
2682
- }
2683
- function buildResult(tool, ok, error, notes) {
2684
- const base = { tool, ok };
2685
- if (!ok)
2686
- base.error = error ?? "command failed";
2687
- if (notes.length > 0)
2688
- base.note = notes.join("; ");
2689
- return base;
2690
- }
2691
-
2692
3223
  // ../server/dist/events/event-filters.js
2693
- function asString3(value) {
3224
+ function asString4(value) {
2694
3225
  return typeof value === "string" ? value : void 0;
2695
3226
  }
2696
- function asNumber(value) {
3227
+ function asNumber2(value) {
2697
3228
  return typeof value === "number" ? value : void 0;
2698
3229
  }
2699
3230
  function matchNet(e, method, urlContains, status) {
2700
3231
  const d = e.data;
2701
- if (method !== void 0 && asString3(d["method"])?.toUpperCase() !== method.toUpperCase()) {
3232
+ if (method !== void 0 && asString4(d["method"])?.toUpperCase() !== method.toUpperCase()) {
2702
3233
  return false;
2703
3234
  }
2704
- if (urlContains !== void 0 && !(asString3(d["url"]) ?? "").includes(urlContains)) {
3235
+ if (urlContains !== void 0 && !(asString4(d["url"]) ?? "").includes(urlContains)) {
2705
3236
  return false;
2706
3237
  }
2707
- if (status !== void 0 && asNumber(d["status"]) !== status)
3238
+ if (status !== void 0 && asNumber2(d["status"]) !== status)
2708
3239
  return false;
2709
3240
  return true;
2710
3241
  }
@@ -2721,8 +3252,8 @@ function matchConsole(e, level) {
2721
3252
  var HINT_SAMPLE_MAX = 5;
2722
3253
  function netEmptyHint(allNet) {
2723
3254
  const present = allNet.slice(-HINT_SAMPLE_MAX).reverse().map((e) => {
2724
- const status = asNumber(e.data["status"]);
2725
- 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"]) ?? "" };
2726
3257
  return status === void 0 ? base : { ...base, status };
2727
3258
  });
2728
3259
  return { totalInWindow: allNet.length, present };
@@ -2753,6 +3284,8 @@ function refuseIfThrottled(session, refuse) {
2753
3284
  }
2754
3285
 
2755
3286
  // ../server/dist/session/output-budget.js
3287
+ var LARGE_TIMELINE_EVENTS = 80;
3288
+ var LARGE_TIMELINE_BYTES = 8e3;
2756
3289
  function applyEventBudget(events, maxEvents) {
2757
3290
  if (maxEvents === void 0 || maxEvents < 0 || events.length <= maxEvents) {
2758
3291
  return { events, droppedOldest: 0 };
@@ -2763,8 +3296,92 @@ function applyEventBudget(events, maxEvents) {
2763
3296
  };
2764
3297
  }
2765
3298
  function costHint(payload, events, droppedOldest = 0) {
2766
- const bytes = JSON.stringify(payload)?.length ?? 0;
2767
- 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"] };
2768
3385
  }
2769
3386
 
2770
3387
  // ../server/dist/session/state-select.js
@@ -2815,25 +3432,55 @@ function capDepth(value, maxDepth) {
2815
3432
  return value;
2816
3433
  }
2817
3434
 
2818
- // ../server/dist/tools/tools-helpers.js
2819
- function parseInteractive(tree) {
2820
- const items = [];
2821
- for (const line of tree.split("\n")) {
2822
- const match = /\(ref=(e\d+)\)/.exec(line);
2823
- if (match !== null) {
2824
- items.push({ ref: match[1] ?? "", desc: line.replace(/\s*\(ref=e\d+\)/, "").trim() });
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 };
3447
+ }
3448
+ if (limit !== void 0 && limit >= 0 && total > limit) {
3449
+ return { ...record, elements: elements.slice(0, limit), total, truncated: true };
3450
+ }
3451
+ return result;
3452
+ }
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
+ };
2825
3476
  }
3477
+ case "not":
3478
+ return walk(predicate.predicate);
2826
3479
  }
2827
- return items;
2828
- }
2829
- function asString4(value) {
2830
- return typeof value === "string" ? value : void 0;
2831
- }
2832
- function asNumber2(value) {
2833
- return typeof value === "number" ? value : void 0;
2834
3480
  }
2835
- function asRecord3(value) {
2836
- return typeof value === "object" && value !== null ? value : {};
3481
+ function isPresenceOnlyAssertion(predicate) {
3482
+ const kinds = walk(predicate);
3483
+ return kinds.presence && !kinds.consequence;
2837
3484
  }
2838
3485
 
2839
3486
  // ../server/dist/tools/contract-tools.js
@@ -2867,7 +3514,7 @@ var CONTRACT_TOOLS = [
2867
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");
2868
3515
  return { ...r.capabilities, source: "disk", generatedAt: r.generatedAt };
2869
3516
  }
2870
- const caps = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3517
+ const caps = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2871
3518
  return { ...caps, source: "live" };
2872
3519
  }
2873
3520
  },
@@ -2882,7 +3529,7 @@ var CONTRACT_TOOLS = [
2882
3529
  signalCount: z4.number()
2883
3530
  },
2884
3531
  handler: async (deps, args) => {
2885
- const res = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3532
+ const res = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2886
3533
  const caps = CapabilitiesSchema.parse(res);
2887
3534
  await writeContract(deps.fs, deps.irisRoot, caps, deps.now);
2888
3535
  return {
@@ -2895,253 +3542,399 @@ var CONTRACT_TOOLS = [
2895
3542
  }
2896
3543
  ];
2897
3544
 
2898
- // ../server/dist/tools/browser-tools.js
3545
+ // ../server/dist/domain/domain-tools.js
2899
3546
  import { z as z5 } from "zod";
2900
- var sessionIdShape2 = {
2901
- 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"
2902
3556
  };
2903
- async function commandOrThrow2(deps, sessionId, name, args) {
2904
- const session = deps.sessions.resolve(sessionId);
2905
- const result = await session.command(name, args);
2906
- if (!result.ok)
2907
- throw new Error(result.error ?? `command '${name}' failed`);
2908
- 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);
2909
3561
  }
2910
- var BROWSER_TOOLS = [
2911
- {
2912
- name: IrisTool.NAVIGATE,
2913
- 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.",
2914
- inputSchema: {
2915
- url: z5.string().describe("The URL to navigate to."),
2916
- ...sessionIdShape2
2917
- },
2918
- outputSchema: {
2919
- ok: z5.boolean(),
2920
- url: z5.string().optional(),
2921
- reason: z5.string().optional()
2922
- },
2923
- handler: async (deps, args) => {
2924
- const url = asString4(args["url"]);
2925
- if (url === void 0 || url.length === 0)
2926
- return { ok: false, reason: "url required" };
2927
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.NAVIGATE, { url });
2928
- return { ok: true, url };
2929
- }
2930
- },
2931
- {
2932
- name: IrisTool.REFRESH,
2933
- 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.",
2934
- inputSchema: {
2935
- hard: z5.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
2936
- ...sessionIdShape2
2937
- },
2938
- outputSchema: {
2939
- ok: z5.boolean()
2940
- },
2941
- handler: async (deps, args) => {
2942
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.REFRESH, {
2943
- hard: args["hard"] === true
2944
- });
2945
- return { ok: true };
2946
- }
3562
+ function expectIsWeak(e) {
3563
+ return e !== void 0 && e.element !== void 0 && e.signal === void 0 && e.net === void 0;
3564
+ }
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));
2947
3571
  }
2948
- ];
2949
-
2950
- // ../server/dist/flows/flow-tools.js
2951
- import { z as z6 } from "zod";
3572
+ return out;
3573
+ }
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;
3598
+ }
3599
+ return {
3600
+ grade,
3601
+ hasConsequenceAssertion,
3602
+ totalSteps: all.length,
3603
+ consequenceSteps,
3604
+ weakSteps,
3605
+ successIsConsequence,
3606
+ ...warning !== void 0 ? { warning } : {}
3607
+ };
3608
+ }
2952
3609
 
2953
- // ../server/dist/flows/flow-replay.js
2954
- function editDistance(a, b) {
2955
- const s = a.toLowerCase();
2956
- const t = b.toLowerCase();
2957
- const rows = s.length + 1;
2958
- const cols = t.length + 1;
2959
- const prev = new Array(cols);
2960
- const curr = new Array(cols);
2961
- for (let j = 0; j < cols; j++)
2962
- prev[j] = j;
2963
- for (let i = 1; i < rows; i++) {
2964
- curr[0] = i;
2965
- for (let j = 1; j < cols; j++) {
2966
- const cost = s[i - 1] === t[j - 1] ? 0 : 1;
2967
- curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
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 : ""));
3613
+ }
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 });
3625
+ }
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);
3635
+ }
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 });
2968
3649
  }
2969
- for (let j = 0; j < cols; j++)
2970
- prev[j] = curr[j] ?? 0;
2971
3650
  }
2972
- return prev[cols - 1] ?? 0;
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 };
2973
3657
  }
2974
- function nearestTestid(missing, present) {
2975
- let best = null;
2976
- let bestDistance = Number.POSITIVE_INFINITY;
2977
- for (const candidate of present) {
2978
- const distance = editDistance(missing, candidate);
2979
- if (distance < bestDistance || distance === bestDistance && best !== null && candidate.length < best.length || distance === bestDistance && best !== null && candidate.length === best.length && candidate < best) {
2980
- best = candidate;
2981
- bestDistance = distance;
2982
- }
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);
3665
+ }
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;
2983
3685
  }
2984
3686
  return best;
2985
3687
  }
2986
- function readQuery(result) {
2987
- if (!result.ok)
2988
- return { refs: [] };
2989
- const payload = asRecord3(result.result);
2990
- const elements = Array.isArray(payload["elements"]) ? payload["elements"] : [];
2991
- const refs = elements.map((e) => asString4(asRecord3(e)["ref"]) ?? "").filter((r) => r.length > 0);
2992
- const rawHint = payload["hint"];
2993
- if (typeof rawHint === "object" && rawHint !== null) {
2994
- const hint = asRecord3(rawHint);
2995
- const present = Array.isArray(hint["presentTestids"]) ? hint["presentTestids"].filter((t) => typeof t === "string") : [];
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) {
2996
3698
  return {
2997
- refs,
2998
- hint: {
2999
- route: asString4(hint["route"]) ?? "",
3000
- presentTestids: present,
3001
- presentRegions: [],
3002
- knownEmptyState: hint["knownEmptyState"] === true
3003
- }
3699
+ level: RiskLevel.MEDIUM,
3700
+ reason: `last run passed but logged ${String(errors)} error(s)`
3004
3701
  };
3005
3702
  }
3006
- return { refs };
3007
- }
3008
- function testidDrift(value, hint) {
3009
- return {
3010
- reasonKind: DriftReason.TESTID_NOT_FOUND,
3011
- reason: `testid "${value}" not found`,
3012
- anchor: value,
3013
- nearest: nearestTestid(value, hint?.presentTestids ?? [])
3014
- };
3015
- }
3016
- function anchorLabel(anchor) {
3017
- if (anchor.kind === AnchorKind.TESTID)
3018
- return anchor.value;
3019
- if (anchor.kind === AnchorKind.SIGNAL)
3020
- return anchor.name;
3021
- return anchor.name ?? anchor.role;
3703
+ return { level: RiskLevel.LOW, reason: "last run passed clean" };
3022
3704
  }
3023
- async function runTestidStep(session, step, index, value, dynamic) {
3024
- const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value });
3025
- const { refs, hint } = readQuery(queryResult);
3026
- if (refs.length === 0) {
3705
+ function gradeRisk(grade) {
3706
+ if (grade === FlowAssertionGrade.ASSERTION_FREE) {
3027
3707
  return {
3028
- step: index,
3029
- tool: step.tool,
3030
- anchor: value,
3031
- ok: false,
3032
- drift: testidDrift(value, hint)
3708
+ level: RiskLevel.MEDIUM,
3709
+ reason: "asserts no consequence \u2014 a green run proves little"
3033
3710
  };
3034
3711
  }
3035
- const ref = refs[0] ?? "";
3036
- const note = refs.length > 1 ? `ambiguous testid '${value}', used first match` : void 0;
3037
- const act = await session.command(IrisCommand.ACT, {
3038
- ref,
3039
- action: step.action ?? "",
3040
- args: step.args ?? {}
3041
- });
3042
- const result = { step: index, tool: step.tool, anchor: value, ok: act.ok };
3043
- if (!act.ok) {
3044
- result.error = act.error ?? "command failed";
3045
- if (note !== void 0)
3046
- result.note = note;
3047
- return result;
3048
- }
3049
- const expectTestid = step.expect?.element?.testid;
3050
- if (expectTestid !== void 0 && !dynamic.has(expectTestid)) {
3051
- const expectQuery = await session.command(IrisCommand.QUERY, {
3052
- by: QueryBy.TESTID,
3053
- value: expectTestid
3054
- });
3055
- const expectRefs = readQuery(expectQuery);
3056
- if (expectRefs.refs.length === 0) {
3057
- return {
3058
- step: index,
3059
- tool: step.tool,
3060
- anchor: expectTestid,
3061
- ok: false,
3062
- drift: testidDrift(expectTestid, expectRefs.hint)
3063
- };
3064
- }
3712
+ if (grade === FlowAssertionGrade.PRESENCE_ONLY) {
3713
+ return { level: RiskLevel.LOW, reason: "presence-only assertion" };
3065
3714
  }
3066
- if (note !== void 0)
3067
- result.note = note;
3068
- return result;
3715
+ return { level: RiskLevel.LOW, reason: "asserts a consequence" };
3069
3716
  }
3070
- async function runSignalStep(session, step, index, name, waitForSignal, signalTimeoutMs) {
3071
- const verdict = await waitForSignal(session, { kind: "signal", name }, signalTimeoutMs);
3072
- if (verdict.pass)
3073
- return { step: index, tool: step.tool, anchor: name, ok: true };
3074
- return {
3075
- step: index,
3076
- tool: step.tool,
3077
- anchor: name,
3078
- ok: false,
3079
- drift: {
3080
- reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
3081
- reason: `signal "${name}" not observed`,
3082
- anchor: name,
3083
- nearest: null
3084
- }
3085
- };
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 };
3086
3722
  }
3087
- async function replayFlow(session, flow, waitForSignal, signalTimeoutMs) {
3088
- const results = [];
3089
- const dynamic = new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
3090
- let index = 0;
3091
- for (const step of flow.steps) {
3092
- const label = anchorLabel(step.anchor);
3093
- let result;
3094
- if (step.anchor.kind === AnchorKind.SIGNAL) {
3095
- result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs);
3096
- } else {
3097
- result = await runTestidStep(session, step, index, label, dynamic);
3098
- }
3099
- results.push(result);
3100
- if (result.drift !== void 0 || !result.ok)
3101
- break;
3102
- index += 1;
3103
- }
3104
- return results;
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);
3105
3725
  }
3106
3726
 
3107
- // ../server/dist/flows/heal.js
3108
- function confidenceFor(from, to) {
3109
- if (from === to)
3110
- return 1;
3111
- const span = Math.max(from.length, to.length);
3112
- if (span === 0)
3113
- return 1;
3114
- const raw = 1 - editDistance(from, to) / span;
3115
- if (raw >= 1)
3116
- return 1;
3117
- if (raw <= 0)
3118
- return Number.EPSILON;
3119
- 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;
3736
+ }
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
+ };
3120
3808
  }
3121
- function proposeRebindWith(drift, step, minConfidence) {
3122
- if (drift.reasonKind !== DriftReason.TESTID_NOT_FOUND)
3123
- return void 0;
3124
- const to = drift.nearest;
3125
- if (to === null)
3126
- return void 0;
3127
- const confidence = confidenceFor(drift.anchor, to);
3128
- if (confidence < minConfidence)
3129
- return void 0;
3130
- return { step, from: drift.anchor, to, confidence };
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.";
3812
+ }
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(". ") + ".";
3131
3826
  }
3132
- function collectProposals(steps, minConfidence = HEAL_CONFIDENCE_MIN) {
3133
- const proposals = [];
3134
- for (const step of steps) {
3135
- if (step.drift === void 0)
3136
- continue;
3137
- const proposal = proposeRebindWith(step.drift, step.step, minConfidence);
3138
- if (proposal !== void 0)
3139
- proposals.push(proposal);
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
+ }
3140
3877
  }
3141
- return proposals;
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;
3142
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
+ ];
3143
3935
 
3144
3936
  // ../server/dist/flows/flow-tools.js
3937
+ import { z as z7 } from "zod";
3145
3938
  function latestRecordedFlow(events) {
3146
3939
  for (let i = events.length - 1; i >= 0; i--) {
3147
3940
  const event = events[i];
@@ -3187,18 +3980,26 @@ async function recordReplayRun(deps, name, status, driftSteps, durationMs) {
3187
3980
  var FLOW_TOOLS = [
3188
3981
  {
3189
3982
  name: IrisTool.FLOW_SAVE,
3190
- 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).',
3191
3984
  inputSchema: {
3192
- 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.")
3193
3986
  },
3194
3987
  outputSchema: {
3195
- saved: z6.boolean(),
3196
- path: z6.string(),
3197
- stepCount: z6.number().optional(),
3198
- 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()
3199
4000
  },
3200
4001
  handler: (deps, args) => {
3201
- const name = asString4(args["flowName"]) ?? "";
4002
+ const name = asString(args["flowName"]) ?? "";
3202
4003
  const program = deps.recordings.getCompiled(name);
3203
4004
  if (program === void 0) {
3204
4005
  return Promise.resolve({
@@ -3212,10 +4013,12 @@ var FLOW_TOOLS = [
3212
4013
  dynamic: deps.annotations.dynamic(name),
3213
4014
  ...success !== void 0 ? { success } : {}
3214
4015
  };
3215
- return deps.flows.save(program, annotations).then((res) => {
3216
- if (res.ok)
3217
- deps.annotations.clear(name);
3218
- 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;
3219
4022
  });
3220
4023
  }
3221
4024
  },
@@ -3224,22 +4027,27 @@ var FLOW_TOOLS = [
3224
4027
  description: "List saved flow names under .iris/flows (a fresh agent learns the demonstrated journeys without a browser).",
3225
4028
  inputSchema: {},
3226
4029
  outputSchema: {
3227
- 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() }))
3228
4031
  },
3229
- 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
+ }))
3230
4038
  },
3231
4039
  {
3232
4040
  name: IrisTool.FLOW_LOAD,
3233
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 }.",
3234
4042
  inputSchema: {
3235
- 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.")
3236
4044
  },
3237
4045
  outputSchema: {
3238
- flowName: z6.string(),
3239
- steps: z6.array(z6.unknown()),
3240
- createdAt: z6.number().optional()
4046
+ flowName: z7.string(),
4047
+ steps: z7.array(z7.unknown()),
4048
+ createdAt: z7.number().optional()
3241
4049
  },
3242
- handler: (deps, args) => deps.flows.load(asString4(args["flowName"]) ?? "").then((res) => {
4050
+ handler: (deps, args) => deps.flows.load(asString(args["flowName"]) ?? "").then((res) => {
3243
4051
  if (!res.ok)
3244
4052
  return { error: flowErrorMessage(res.code), code: res.code };
3245
4053
  const { name, ...rest } = res.value;
@@ -3248,20 +4056,21 @@ var FLOW_TOOLS = [
3248
4056
  },
3249
4057
  {
3250
4058
  name: IrisTool.FLOW_REPLAY,
3251
- 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).`,
3252
4060
  inputSchema: {
3253
- flowName: z6.string().describe("Flow file name (without .json extension) from iris_flow_list."),
3254
- 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.")
3255
4064
  },
3256
4065
  outputSchema: {
3257
- status: z6.string().describe("ok | drift | error"),
3258
- steps: z6.array(z6.unknown()),
3259
- proposals: z6.array(z6.unknown()).optional(),
3260
- 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()
3261
4070
  },
3262
4071
  handler: async (deps, args) => {
3263
4072
  const startedAt = deps.now();
3264
- const name = asString4(args["flowName"]) ?? "";
4073
+ const name = asString(args["flowName"]) ?? "";
3265
4074
  const loaded = await deps.flows.load(name);
3266
4075
  if (!loaded.ok) {
3267
4076
  await recordReplayRun(deps, name, ReplayStatus.ERROR, 0, deps.now() - startedAt);
@@ -3272,12 +4081,35 @@ var FLOW_TOOLS = [
3272
4081
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3273
4082
  };
3274
4083
  }
3275
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3276
- 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
+ }
3277
4099
  const driftSteps = steps.filter((s) => s.drift !== void 0).length;
3278
4100
  const allOk = steps.every((s) => s.ok);
3279
- const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.DRIFT;
4101
+ const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.ERROR;
3280
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
+ }
3281
4113
  return { name, status, steps };
3282
4114
  }
3283
4115
  },
@@ -3285,20 +4117,20 @@ var FLOW_TOOLS = [
3285
4117
  name: IrisTool.FLOW_SAVE_RECORDED,
3286
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).",
3287
4119
  inputSchema: {
3288
- 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."),
3289
4121
  ...{
3290
- 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.")
3291
4123
  }
3292
4124
  },
3293
4125
  outputSchema: {
3294
- flowName: z6.string().optional(),
3295
- stepCount: z6.number().optional(),
3296
- degraded: z6.number().optional(),
3297
- error: z6.string().optional(),
3298
- 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()
3299
4131
  },
3300
4132
  handler: async (deps, args) => {
3301
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4133
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
3302
4134
  const recorded = latestRecordedFlow(session.eventsSince(0));
3303
4135
  if (recorded === void 0) {
3304
4136
  return {
@@ -3306,7 +4138,7 @@ var FLOW_TOOLS = [
3306
4138
  code: RecordedSaveError.NO_RECORDED_FLOW
3307
4139
  };
3308
4140
  }
3309
- const override = asString4(args["flowName"]);
4141
+ const override = asString(args["flowName"]);
3310
4142
  const flow = override !== void 0 ? { ...recorded.flow, name: override } : recorded.flow;
3311
4143
  const res = await deps.flows.saveFlow(flow);
3312
4144
  if (!res.ok)
@@ -3317,35 +4149,38 @@ var FLOW_TOOLS = [
3317
4149
  },
3318
4150
  {
3319
4151
  name: IrisTool.FLOW_HEAL,
3320
- 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 }.",
3321
4153
  inputSchema: {
3322
- flowName: z6.string().describe("Flow file name to heal (from iris_flow_list)."),
3323
- apply: z6.boolean().optional(),
3324
- 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.")
3325
4158
  },
3326
4159
  outputSchema: {
3327
- flowName: z6.string(),
3328
- status: z6.string(),
3329
- applied: z6.boolean(),
3330
- proposals: z6.array(z6.unknown()),
3331
- changed: z6.array(z6.unknown()),
3332
- message: z6.string(),
3333
- 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()
3334
4167
  },
3335
4168
  handler: (deps, args) => healFlow(deps, args).then(({ name, ...rest }) => ({ flowName: name, ...rest }))
3336
4169
  }
3337
4170
  ];
3338
4171
  var HEAL_MESSAGES = {
3339
4172
  NOTHING: "nothing to heal \u2014 every anchor resolved on replay",
3340
- 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",
3341
4174
  DRIFT_DRY: "confident rebind(s) proposed \u2014 re-run with apply:true to write them to disk",
3342
- 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"
3343
4178
  };
3344
4179
  function toChange(proposal) {
3345
4180
  return { step: proposal.step, from: proposal.from, to: proposal.to };
3346
4181
  }
3347
4182
  async function healFlow(deps, args) {
3348
- const name = asString4(args["flowName"]) ?? "";
4183
+ const name = asString(args["flowName"]) ?? "";
3349
4184
  const apply = args["apply"] === true;
3350
4185
  const loaded = await deps.flows.load(name);
3351
4186
  if (!loaded.ok) {
@@ -3359,9 +4194,22 @@ async function healFlow(deps, args) {
3359
4194
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3360
4195
  };
3361
4196
  }
3362
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3363
- 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);
3364
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
+ }
3365
4213
  if (!drifted) {
3366
4214
  return {
3367
4215
  name,
@@ -3393,6 +4241,23 @@ async function healFlow(deps, args) {
3393
4241
  message: HEAL_MESSAGES.DRIFT_DRY
3394
4242
  };
3395
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
+ }
3396
4261
  const written = await deps.flows.heal(name, proposals.map(toChange));
3397
4262
  if (!written.ok) {
3398
4263
  return {
@@ -3411,14 +4276,14 @@ async function healFlow(deps, args) {
3411
4276
  applied: written.value.changed.length > 0,
3412
4277
  proposals,
3413
4278
  changed: written.value.changed,
3414
- message: HEAL_MESSAGES.HEALED
4279
+ message: loaded.value.success !== void 0 ? HEAL_MESSAGES.HEALED : HEAL_MESSAGES.HEALED_UNVERIFIED
3415
4280
  };
3416
4281
  }
3417
4282
 
3418
4283
  // ../server/dist/project/project-tools.js
3419
- import { z as z7 } from "zod";
4284
+ import { z as z8 } from "zod";
3420
4285
  var sessionIdShape3 = {
3421
- 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.")
3422
4287
  };
3423
4288
  var REGRESSION_STATUSES = /* @__PURE__ */ new Set([
3424
4289
  RunStatus.FAIL,
@@ -3459,12 +4324,12 @@ var PROJECT_TOOLS = [
3459
4324
  name: IrisTool.PROJECT,
3460
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.',
3461
4326
  inputSchema: {
3462
- 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."),
3463
4328
  ...sessionIdShape3
3464
4329
  },
3465
4330
  outputSchema: {
3466
- runs: z7.array(z7.unknown()),
3467
- diff: z7.unknown().optional()
4331
+ runs: z8.array(z8.unknown()),
4332
+ diff: z8.unknown().optional()
3468
4333
  },
3469
4334
  handler: async (deps, args) => {
3470
4335
  const read = await deps.project.read();
@@ -3474,7 +4339,7 @@ var PROJECT_TOOLS = [
3474
4339
  reason: read.reason
3475
4340
  };
3476
4341
  }
3477
- const name = asString4(args["name"]);
4342
+ const name = asString(args["name"]);
3478
4343
  if (name === void 0) {
3479
4344
  return { runs: read.file.runs, learned: read.file.learned };
3480
4345
  }
@@ -3492,22 +4357,22 @@ var PROJECT_TOOLS = [
3492
4357
  name: IrisTool.RUN_RECORD,
3493
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 }.",
3494
4359
  inputSchema: {
3495
- name: z7.string().describe("Run name for grouping in iris_project history."),
3496
- status: z7.nativeEnum(RunStatus).describe("Outcome: pass | fail | drift | error"),
3497
- kind: z7.nativeEnum(RunKind).optional(),
3498
- 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."),
3499
4364
  ...sessionIdShape3
3500
4365
  },
3501
4366
  outputSchema: {
3502
- recorded: z7.boolean(),
3503
- runName: z7.string(),
3504
- status: z7.string()
4367
+ recorded: z8.boolean(),
4368
+ runName: z8.string(),
4369
+ status: z8.string()
3505
4370
  },
3506
4371
  handler: async (deps, args) => {
3507
- const name = asString4(args["name"]) ?? "";
4372
+ const name = asString(args["name"]) ?? "";
3508
4373
  const status = args["status"];
3509
4374
  const kindArg = args["kind"];
3510
- const summary = asString4(args["summary"]);
4375
+ const summary = asString(args["summary"]);
3511
4376
  await deps.project.recordRun({
3512
4377
  kind: typeof kindArg === "string" ? kindArg : RunKind.MANUAL,
3513
4378
  name,
@@ -3520,7 +4385,7 @@ var PROJECT_TOOLS = [
3520
4385
  ];
3521
4386
 
3522
4387
  // ../server/dist/visual/visual-tools.js
3523
- import { z as z8 } from "zod";
4388
+ import { z as z9 } from "zod";
3524
4389
 
3525
4390
  // ../server/dist/visual/visual-diff.js
3526
4391
  async function loadDeps() {
@@ -3618,8 +4483,8 @@ async function diffPng(baselineBytes, currentBytes, opts = {}) {
3618
4483
  var VisualStore = class {
3619
4484
  #fs;
3620
4485
  #root;
3621
- constructor(fs, root) {
3622
- this.#fs = fs;
4486
+ constructor(fs2, root) {
4487
+ this.#fs = fs2;
3623
4488
  this.#root = root;
3624
4489
  }
3625
4490
  /** The absolute baseline path for `name` (for echoing back to the agent). */
@@ -3671,13 +4536,13 @@ var VisualStore = class {
3671
4536
 
3672
4537
  // ../server/dist/visual/visual-tools.js
3673
4538
  var sessionIdShape4 = {
3674
- 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.")
3675
4540
  };
3676
- var rectShape = z8.object({
3677
- x: z8.number(),
3678
- y: z8.number(),
3679
- width: z8.number(),
3680
- height: z8.number()
4541
+ var rectShape = z9.object({
4542
+ x: z9.number(),
4543
+ y: z9.number(),
4544
+ width: z9.number(),
4545
+ height: z9.number()
3681
4546
  });
3682
4547
  function screenshotProvider(deps) {
3683
4548
  const p = deps.realInput;
@@ -3689,11 +4554,11 @@ var noProvider = {
3689
4554
  recommendation: VISUAL_NO_PROVIDER_RECOMMENDATION
3690
4555
  };
3691
4556
  function asBox(value) {
3692
- const b = asRecord3(asRecord3(value)["box"]);
3693
- const x = asNumber2(b["x"]);
3694
- const y = asNumber2(b["y"]);
3695
- const w = asNumber2(b["width"]);
3696
- 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"]);
3697
4562
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
3698
4563
  return void 0;
3699
4564
  if (w <= 0 || h <= 0)
@@ -3703,12 +4568,12 @@ function asBox(value) {
3703
4568
  async function buildOpts(deps, sessionId, args) {
3704
4569
  const clipArg = args["clip"];
3705
4570
  if (clipArg !== void 0) {
3706
- const c = asRecord3(clipArg);
4571
+ const c = asRecord(clipArg);
3707
4572
  const box = asBox({ box: c });
3708
4573
  if (box !== void 0)
3709
4574
  return { clip: box };
3710
4575
  }
3711
- const ref = asString4(args["ref"]);
4576
+ const ref = asString(args["ref"]);
3712
4577
  if (ref !== void 0) {
3713
4578
  const session = deps.sessions.resolve(sessionId);
3714
4579
  const res = await session.command(IrisCommand.INSPECT, { ref });
@@ -3734,31 +4599,31 @@ var VISUAL_TOOLS = [
3734
4599
  name: IrisTool.SCREENSHOT,
3735
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.",
3736
4601
  inputSchema: {
3737
- name: z8.string().describe("Baseline name \u2014 saved as .iris/visual/<name>.png. Use the same name in iris_visual_diff to compare."),
3738
- fullPage: z8.boolean().optional().describe("Capture the full scroll height. Default: viewport only."),
3739
- 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."),
3740
4605
  clip: rectShape.optional().describe("Explicit { x, y, width, height } clip rectangle in page coordinates."),
3741
4606
  ...sessionIdShape4
3742
4607
  },
3743
4608
  outputSchema: {
3744
- ok: z8.boolean(),
3745
- saved: z8.boolean().optional(),
3746
- name: z8.string().optional(),
3747
- path: z8.string().optional(),
3748
- bytes: z8.number().optional(),
3749
- reason: z8.string().optional(),
3750
- 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()
3751
4616
  },
3752
4617
  handler: async (deps, args) => {
3753
4618
  const provider = screenshotProvider(deps);
3754
4619
  if (provider === void 0)
3755
4620
  return noProvider;
3756
- const sessionId = asString4(args["sessionId"]);
4621
+ const sessionId = asString(args["sessionId"]);
3757
4622
  const session = deps.sessions.resolve(sessionId);
3758
4623
  const png = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3759
4624
  if (png === void 0)
3760
4625
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3761
- const name = asString4(args["name"]) ?? "default";
4626
+ const name = asString(args["name"]) ?? "default";
3762
4627
  const store = new VisualStore(deps.fs, deps.irisRoot);
3763
4628
  const path = await store.saveBaseline(name, png);
3764
4629
  return { ok: true, saved: true, name, path, bytes: png.length };
@@ -3768,39 +4633,39 @@ var VISUAL_TOOLS = [
3768
4633
  name: IrisTool.VISUAL_DIFF,
3769
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).",
3770
4635
  inputSchema: {
3771
- baseline: z8.string().describe("Baseline screenshot name (from iris_screenshot). Used to compare with the current screenshot."),
3772
- fullPage: z8.boolean().optional(),
3773
- 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(),
3774
4639
  clip: rectShape.optional(),
3775
- masks: z8.array(rectShape).optional(),
3776
- maxRatio: z8.number().optional(),
3777
- 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."),
3778
4643
  ...sessionIdShape4
3779
4644
  },
3780
4645
  outputSchema: {
3781
- ok: z8.boolean(),
3782
- match: z8.boolean().optional(),
3783
- diffPct: z8.number().optional(),
3784
- diffPath: z8.string().optional(),
3785
- 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()
3786
4651
  },
3787
4652
  handler: async (deps, args) => {
3788
4653
  const provider = screenshotProvider(deps);
3789
4654
  if (provider === void 0)
3790
4655
  return noProvider;
3791
- const baseline = asString4(args["baseline"]) ?? "";
4656
+ const baseline = asString(args["baseline"]) ?? "";
3792
4657
  const store = new VisualStore(deps.fs, deps.irisRoot);
3793
4658
  const baselineBytes = await store.readBaseline(baseline);
3794
4659
  if (baselineBytes === void 0)
3795
4660
  return { ok: false, reason: VisualReason.BASELINE_MISSING };
3796
- const sessionId = asString4(args["sessionId"]);
4661
+ const sessionId = asString(args["sessionId"]);
3797
4662
  const session = deps.sessions.resolve(sessionId);
3798
4663
  const current = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3799
4664
  if (current === void 0)
3800
4665
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3801
4666
  const masks = rectsFrom(args["masks"]);
3802
- const threshold = asNumber2(args["threshold"]);
3803
- const maxRatio = asNumber2(args["maxRatio"]);
4667
+ const threshold = asNumber(args["threshold"]);
4668
+ const maxRatio = asNumber(args["maxRatio"]);
3804
4669
  const result = await diffPng(baselineBytes, current, {
3805
4670
  ...threshold !== void 0 ? { threshold } : {},
3806
4671
  ...maxRatio !== void 0 ? { maxRatio } : {},
@@ -3817,7 +4682,7 @@ var VISUAL_TOOLS = [
3817
4682
  ];
3818
4683
 
3819
4684
  // ../server/dist/crawl/crawl-tools.js
3820
- import { z as z9 } from "zod";
4685
+ import { z as z10 } from "zod";
3821
4686
 
3822
4687
  // ../server/dist/crawl/crawl.js
3823
4688
  function isActivity(e) {
@@ -3830,7 +4695,7 @@ function failedRequests(events, floor) {
3830
4695
  return events.filter((e) => {
3831
4696
  if (e.type !== EventType.NET_REQUEST)
3832
4697
  return false;
3833
- const status = asNumber2(e.data["status"]);
4698
+ const status = asNumber(e.data["status"]);
3834
4699
  return status !== void 0 && status >= floor;
3835
4700
  });
3836
4701
  }
@@ -3860,7 +4725,7 @@ async function crawl(session, opts, sleep) {
3860
4725
  const act = await session.command(IrisCommand.ACT, {
3861
4726
  ref: item.ref,
3862
4727
  action: ActionType.CLICK,
3863
- args: {}
4728
+ args: opts.confirmDangerous === true ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {}
3864
4729
  });
3865
4730
  await sleep(settleMs);
3866
4731
  const events = session.eventsSince(since);
@@ -3871,14 +4736,14 @@ async function crawl(session, opts, sleep) {
3871
4736
  kind: CrawlAnomalyKind.CONSOLE_ERROR,
3872
4737
  ref: item.ref,
3873
4738
  desc: item.desc,
3874
- detail: asString4(e.data["message"]) ?? e.type
4739
+ detail: asString(e.data["message"]) ?? e.type
3875
4740
  });
3876
4741
  }
3877
4742
  for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
3878
4743
  counts.failedRequests += 1;
3879
- const method = asString4(e.data["method"]) ?? "";
3880
- const url = asString4(e.data["url"]) ?? "";
3881
- 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"]);
3882
4747
  anomalies.push({
3883
4748
  kind: CrawlAnomalyKind.FAILED_REQUEST,
3884
4749
  ref: item.ref,
@@ -3886,7 +4751,7 @@ async function crawl(session, opts, sleep) {
3886
4751
  detail: `${method} ${url} \u2192 ${status ?? ""}`.trim()
3887
4752
  });
3888
4753
  }
3889
- const dispatched = asRecord3(act.result)["dispatched"] !== false && act.ok;
4754
+ const dispatched = asRecord(act.result)["dispatched"] !== false && act.ok;
3890
4755
  if (dispatched && errs.length === 0 && !events.some(isActivity)) {
3891
4756
  counts.deadControls += 1;
3892
4757
  anomalies.push({
@@ -3914,32 +4779,34 @@ var CRAWL_TOOLS = [
3914
4779
  name: IrisTool.CRAWL,
3915
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 }.",
3916
4781
  inputSchema: {
3917
- maxSteps: z9.number().optional().describe("Maximum number of controls to click. Default: 25."),
3918
- settleMs: z9.number().optional().describe("Milliseconds to wait after each click for the app to react. Default: 500."),
3919
- scope: z9.string().optional().describe("CSS selector or element ref to restrict crawling to a subtree."),
3920
- 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.")
3921
4787
  },
3922
4788
  outputSchema: {
3923
- interactiveFound: z9.number(),
3924
- stepsRun: z9.number(),
3925
- anomalies: z9.array(z9.object({
3926
- kind: z9.string(),
3927
- ref: z9.string(),
3928
- desc: z9.string(),
3929
- 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()
3930
4796
  })),
3931
- counts: z9.record(z9.number()),
3932
- truncated: z9.boolean()
4797
+ counts: z10.record(z10.number()),
4798
+ truncated: z10.boolean()
3933
4799
  },
3934
4800
  handler: (deps, args) => {
3935
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3936
- const maxSteps = asNumber2(args["maxSteps"]);
3937
- const settleMs = asNumber2(args["settleMs"]);
3938
- 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"]);
3939
4805
  const opts = {
3940
4806
  ...maxSteps !== void 0 ? { maxSteps } : {},
3941
4807
  ...settleMs !== void 0 ? { settleMs } : {},
3942
- ...scope !== void 0 ? { scope } : {}
4808
+ ...scope !== void 0 ? { scope } : {},
4809
+ ...args["confirmDangerous"] === true ? { confirmDangerous: true } : {}
3943
4810
  };
3944
4811
  return crawl(session, opts, nodeSleep2);
3945
4812
  }
@@ -3947,7 +4814,7 @@ var CRAWL_TOOLS = [
3947
4814
  ];
3948
4815
 
3949
4816
  // ../server/dist/input/scroll-tools.js
3950
- import { z as z10 } from "zod";
4817
+ import { z as z11 } from "zod";
3951
4818
 
3952
4819
  // ../server/dist/input/scroll-find.js
3953
4820
  async function queryFirst(session, q) {
@@ -3956,9 +4823,9 @@ async function queryFirst(session, q) {
3956
4823
  value: q.value,
3957
4824
  ...q.name !== void 0 ? { name: q.name } : {}
3958
4825
  });
3959
- const elements = asRecord3(res.result)["elements"];
4826
+ const elements = asRecord(res.result)["elements"];
3960
4827
  if (Array.isArray(elements) && elements.length > 0)
3961
- return asRecord3(elements[0]);
4828
+ return asRecord(elements[0]);
3962
4829
  return void 0;
3963
4830
  }
3964
4831
  async function scrollToFind(session, q, opts = {}) {
@@ -3977,7 +4844,7 @@ async function scrollToFind(session, q, opts = {}) {
3977
4844
  const hit = await queryFirst(session, q);
3978
4845
  if (hit !== void 0)
3979
4846
  return { found: true, element: hit, scrolls, exhausted: false };
3980
- const data = asRecord3(sr.result);
4847
+ const data = asRecord(sr.result);
3981
4848
  if (data["atEnd"] === true || data["scrolled"] === false) {
3982
4849
  return { found: false, scrolls, exhausted: true };
3983
4850
  }
@@ -3985,7 +4852,7 @@ async function scrollToFind(session, q, opts = {}) {
3985
4852
  for (let i = 0; i < max; i += 1) {
3986
4853
  const sr = await session.command(IrisCommand.SCROLL, q.container !== void 0 ? { ref: q.container } : {});
3987
4854
  scrolls += 1;
3988
- const data = asRecord3(sr.result);
4855
+ const data = asRecord(sr.result);
3989
4856
  const hit = await queryFirst(session, q);
3990
4857
  if (hit !== void 0)
3991
4858
  return { found: true, element: hit, scrolls, exhausted: false };
@@ -4002,58 +4869,58 @@ var SCROLL_TOOLS = [
4002
4869
  name: IrisTool.SCROLL_TO,
4003
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 }.",
4004
4871
  inputSchema: {
4005
- by: z10.string().describe("Query strategy for finding the target: role | text | testid | label | placeholder | alt"),
4006
- value: z10.string().describe("Query value for the selected strategy (the element to scroll into view)."),
4007
- name: z10.string().optional().describe("Optional accessible name filter when using by=role."),
4008
- container: z10.string().optional().describe("Element ref for the scrollable container. Omit to scroll the document."),
4009
- maxScrolls: z10.number().optional().describe("Maximum number of scroll steps before giving up. Default: 20."),
4010
- 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."),
4011
- totalCount: z10.number().optional().describe("Total item count in the virtualized list. Required for bisection with targetIndex."),
4012
- 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.")
4013
4880
  },
4014
4881
  outputSchema: {
4015
- found: z10.boolean(),
4016
- element: z10.object({ ref: z10.string(), role: z10.string(), name: z10.string() }).optional(),
4017
- scrolls: z10.number(),
4018
- 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()
4019
4886
  },
4020
4887
  handler: (deps, args) => {
4021
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4022
- const name = asString4(args["name"]);
4023
- const container = asString4(args["container"]);
4024
- const targetIndex = asNumber2(args["targetIndex"]);
4025
- 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"]);
4026
4893
  const q = {
4027
- by: asString4(args["by"]) ?? "",
4028
- value: asString4(args["value"]) ?? "",
4894
+ by: asString(args["by"]) ?? "",
4895
+ value: asString(args["value"]) ?? "",
4029
4896
  ...name !== void 0 ? { name } : {},
4030
4897
  ...container !== void 0 ? { container } : {},
4031
4898
  ...targetIndex !== void 0 ? { targetIndex } : {},
4032
4899
  ...totalCount !== void 0 ? { totalCount } : {}
4033
4900
  };
4034
- const maxScrolls = asNumber2(args["maxScrolls"]);
4901
+ const maxScrolls = asNumber(args["maxScrolls"]);
4035
4902
  return scrollToFind(session, q, maxScrolls !== void 0 ? { maxScrolls } : {});
4036
4903
  }
4037
4904
  }
4038
4905
  ];
4039
4906
 
4040
4907
  // ../server/dist/session/session-tools.js
4041
- import { z as z11 } from "zod";
4908
+ import { z as z12 } from "zod";
4042
4909
  var SESSION_TOOLS = [
4043
4910
  {
4044
4911
  name: IrisTool.SESSION,
4045
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 }.",
4046
4913
  inputSchema: {
4047
- idleEndMs: z11.number().optional().describe("Idle window in milliseconds after which the presenter session auto-ends. Default: 300000 (5 min). Raise for slow apps."),
4048
- 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.")
4049
4916
  },
4050
4917
  outputSchema: {
4051
- applied: z11.boolean(),
4052
- idleEndMs: z11.number().optional()
4918
+ applied: z12.boolean(),
4919
+ idleEndMs: z12.number().optional()
4053
4920
  },
4054
4921
  handler: async (deps, args) => {
4055
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4056
- const idleEndMs = asNumber2(args["idleEndMs"]);
4922
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4923
+ const idleEndMs = asNumber(args["idleEndMs"]);
4057
4924
  if (idleEndMs !== void 0)
4058
4925
  session.setIdleEndMs(idleEndMs);
4059
4926
  const res = await session.command(IrisCommand.SESSION_CONFIG, idleEndMs !== void 0 ? { idleEndMs } : {});
@@ -4065,7 +4932,7 @@ var SESSION_TOOLS = [
4065
4932
  ];
4066
4933
 
4067
4934
  // ../server/dist/flows/annotate-tools.js
4068
- import { z as z12 } from "zod";
4935
+ import { z as z13 } from "zod";
4069
4936
 
4070
4937
  // ../server/dist/flows/annotate.js
4071
4938
  function compileAnnotation(a, stepCount) {
@@ -4142,23 +5009,23 @@ var ANNOTATE_TOOLS = [
4142
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.",
4143
5010
  inputSchema: {
4144
5011
  // `flow` selects the recording; `name`/`signal`/`testid`/`dataMatches` are the annotation fields.
4145
- flow: z12.string().optional().describe("Named recording to annotate. Defaults to 'default'."),
4146
- kind: z12.string().describe("Annotation kind: assert-signal | assert-visible | mark-dynamic | success-state."),
4147
- name: z12.string().optional().describe("Signal name for assert-signal annotations."),
4148
- testid: z12.string().optional().describe("data-testid value for assert-visible / mark-dynamic / success-state annotations."),
4149
- signal: z12.string().optional().describe("Signal name for success-state annotations."),
4150
- dataMatches: z12.record(z12.unknown()).optional().describe("Key/value pairs the signal payload must match (assert-signal only)."),
4151
- sessionId: z12.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open."),
4152
- 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.")
4153
5020
  },
4154
5021
  outputSchema: {
4155
- ok: z12.boolean(),
4156
- target: z12.string().optional(),
4157
- compiled: z12.string().optional(),
4158
- code: z12.string().optional()
5022
+ ok: z13.boolean(),
5023
+ target: z13.string().optional(),
5024
+ compiled: z13.string().optional(),
5025
+ code: z13.string().optional()
4159
5026
  },
4160
5027
  handler: (deps, args) => {
4161
- const name = asString4(args["flow"]) ?? DEFAULT_RECORDING;
5028
+ const name = asString(args["flow"]) ?? DEFAULT_RECORDING;
4162
5029
  const parsed = AnnotationSchema.safeParse(args);
4163
5030
  if (!parsed.success) {
4164
5031
  return Promise.resolve({ ok: false, code: AnnotationErrorCode.UNKNOWN_KIND });
@@ -4187,19 +5054,19 @@ var ANNOTATE_TOOLS = [
4187
5054
  ];
4188
5055
 
4189
5056
  // ../server/dist/session/live-control-tools.js
4190
- import { z as z13 } from "zod";
5057
+ import { z as z14 } from "zod";
4191
5058
  var sessionIdShape5 = {
4192
- 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.")
4193
5060
  };
4194
5061
  var LIVE_CONTROL_TOOLS = [
4195
5062
  {
4196
5063
  name: IrisTool.END_SESSION,
4197
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.',
4198
- inputSchema: { summary: z13.string().optional(), ...sessionIdShape5 },
4199
- outputSchema: { ended: z13.boolean(), sessionId: z13.string() },
5065
+ inputSchema: { summary: z14.string().optional(), ...sessionIdShape5 },
5066
+ outputSchema: { ended: z14.boolean(), sessionId: z14.string() },
4200
5067
  handler: (deps, args) => {
4201
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4202
- session.setState(SessionState.ENDED, asString4(args["summary"]));
5068
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5069
+ session.setState(SessionState.ENDED, asString(args["summary"]));
4203
5070
  return Promise.resolve({ ended: true, sessionId: session.id });
4204
5071
  }
4205
5072
  },
@@ -4207,9 +5074,9 @@ var LIVE_CONTROL_TOOLS = [
4207
5074
  name: IrisTool.RESUME,
4208
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.',
4209
5076
  inputSchema: { ...sessionIdShape5 },
4210
- outputSchema: { ok: z13.boolean() },
5077
+ outputSchema: { ok: z14.boolean() },
4211
5078
  handler: (deps, args) => {
4212
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5079
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4213
5080
  session.setState(SessionState.ACTIVE);
4214
5081
  return Promise.resolve({ ok: true });
4215
5082
  }
@@ -4218,9 +5085,9 @@ var LIVE_CONTROL_TOOLS = [
4218
5085
  name: IrisTool.MESSAGES,
4219
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.",
4220
5087
  inputSchema: { ...sessionIdShape5 },
4221
- outputSchema: { messages: z13.array(z13.unknown()) },
5088
+ outputSchema: { messages: z14.array(z14.unknown()) },
4222
5089
  handler: (deps, args) => {
4223
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5090
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4224
5091
  return Promise.resolve({ messages: session.drainInbox() });
4225
5092
  }
4226
5093
  }
@@ -4245,6 +5112,266 @@ function withControl(session, result) {
4245
5112
  return control === void 0 ? result : { ...result, control };
4246
5113
  }
4247
5114
 
5115
+ // ../server/dist/update/update-tools.js
5116
+ import { z as z15 } from "zod";
5117
+
5118
+ // ../server/dist/update/update-checker.js
5119
+ import * as fs from "fs";
5120
+ import * as https from "https";
5121
+ import { join as join2 } from "path";
5122
+ import { homedir } from "os";
5123
+ var IRIS_HOME = join2(homedir(), ".iris");
5124
+ var MANIFEST_PATH = join2(IRIS_HOME, "update-manifest.json");
5125
+ var NPM_REGISTRY = "https://registry.npmjs.org/@syrin/iris/latest";
5126
+ function loadManifest() {
5127
+ if (!fs.existsSync(MANIFEST_PATH))
5128
+ return null;
5129
+ try {
5130
+ const raw = fs.readFileSync(MANIFEST_PATH, "utf8");
5131
+ return JSON.parse(raw);
5132
+ } catch {
5133
+ return null;
5134
+ }
5135
+ }
5136
+ function saveManifest(manifest) {
5137
+ fs.mkdirSync(IRIS_HOME, { recursive: true });
5138
+ fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf8");
5139
+ }
5140
+ function isCacheFresh(manifest) {
5141
+ const checked = new Date(manifest.lastChecked).getTime();
5142
+ return Date.now() - checked < UpdateCheckIntervalMs;
5143
+ }
5144
+ function fetchNpmInfo() {
5145
+ return new Promise((resolve, reject) => {
5146
+ const req = https.get(NPM_REGISTRY, (res) => {
5147
+ let body = "";
5148
+ res.setEncoding("utf8");
5149
+ res.on("data", (chunk) => {
5150
+ body += chunk;
5151
+ });
5152
+ res.on("end", () => {
5153
+ try {
5154
+ resolve(JSON.parse(body));
5155
+ } catch (err) {
5156
+ reject(err instanceof Error ? err : new Error(String(err)));
5157
+ }
5158
+ });
5159
+ res.on("error", reject);
5160
+ });
5161
+ req.setTimeout(5e3, () => {
5162
+ req.destroy();
5163
+ reject(new Error("npm registry request timed out"));
5164
+ });
5165
+ req.on("error", reject);
5166
+ });
5167
+ }
5168
+ async function checkForUpdate(currentVersion) {
5169
+ const cached = loadManifest();
5170
+ if (cached !== null && cached.currentVersion === currentVersion && isCacheFresh(cached)) {
5171
+ return cached;
5172
+ }
5173
+ try {
5174
+ const info = await fetchNpmInfo();
5175
+ const updateAvailable = info.version !== currentVersion;
5176
+ const manifest = {
5177
+ currentVersion,
5178
+ latestVersion: info.version,
5179
+ updateAvailable,
5180
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString(),
5181
+ ...info.iris?.changelog !== void 0 ? { changelog: info.iris.changelog } : {},
5182
+ ...info.iris?.breakingChanges !== void 0 ? { breakingChanges: info.iris.breakingChanges } : {},
5183
+ ...cached?.previousVersion !== void 0 ? { previousVersion: cached.previousVersion } : {}
5184
+ };
5185
+ saveManifest(manifest);
5186
+ return manifest;
5187
+ } catch (err) {
5188
+ log("iris_update_check_failed", {
5189
+ error: err instanceof Error ? err.message : String(err)
5190
+ });
5191
+ if (cached !== null)
5192
+ return { ...cached, currentVersion };
5193
+ return {
5194
+ currentVersion,
5195
+ updateAvailable: false,
5196
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString()
5197
+ };
5198
+ }
5199
+ }
5200
+
5201
+ // ../server/dist/update/updater.js
5202
+ import { execFile } from "child_process";
5203
+ import { existsSync as existsSync2 } from "fs";
5204
+ import { platform } from "os";
5205
+ import { dirname, join as join3 } from "path";
5206
+ var NPM_BIN = platform() === "win32" ? "npm.cmd" : "npm";
5207
+ var NPM_TIMEOUT_MS = 12e4;
5208
+ var ExecutionKind = {
5209
+ /** Launched via `npx @syrin/iris` — npm re-resolves the package on restart. */
5210
+ NPX: "npx",
5211
+ /** Installed globally via `npm install -g`. */
5212
+ GLOBAL: "global",
5213
+ /** Installed as a local project dependency. */
5214
+ LOCAL: "local"
5215
+ };
5216
+ function detectExecutionKind() {
5217
+ const script = process.argv[1] ?? "";
5218
+ if (script.includes("/_npx/") || script.includes("\\_npx\\"))
5219
+ return ExecutionKind.NPX;
5220
+ if (script.includes("/node_modules/") || script.includes("\\node_modules\\")) {
5221
+ return ExecutionKind.LOCAL;
5222
+ }
5223
+ return ExecutionKind.GLOBAL;
5224
+ }
5225
+ function findLocalProjectRoot() {
5226
+ let dir = process.cwd();
5227
+ for (; ; ) {
5228
+ if (existsSync2(join3(dir, "package.json")))
5229
+ return dir;
5230
+ const parent = dirname(dir);
5231
+ if (parent === dir)
5232
+ return null;
5233
+ dir = parent;
5234
+ }
5235
+ }
5236
+ function runNpm(args, opts = {}) {
5237
+ return new Promise((resolve, reject) => {
5238
+ execFile(NPM_BIN, args, { timeout: NPM_TIMEOUT_MS, ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {} }, (err, _stdout, stderr) => {
5239
+ if (err !== null) {
5240
+ reject(new Error(`npm ${args.join(" ")} failed: ${stderr !== "" ? stderr : err.message}`));
5241
+ } else {
5242
+ resolve();
5243
+ }
5244
+ });
5245
+ });
5246
+ }
5247
+ async function installVersion(version, kind) {
5248
+ const pkg = `@syrin/iris@${version}`;
5249
+ if (kind === ExecutionKind.NPX) {
5250
+ log("iris_update_npx_strategy", {
5251
+ note: "Running via npx \u2014 exiting so Claude Code restarts and npx fetches the new version"
5252
+ });
5253
+ return;
5254
+ }
5255
+ if (kind === ExecutionKind.LOCAL) {
5256
+ const root = findLocalProjectRoot();
5257
+ if (root !== null) {
5258
+ await runNpm(["install", pkg], { cwd: root });
5259
+ return;
5260
+ }
5261
+ log("iris_update_local_no_root", { fallback: "global" });
5262
+ }
5263
+ await runNpm(["install", "-g", pkg]);
5264
+ }
5265
+ async function installVersionRollback(version, kind) {
5266
+ if (kind === ExecutionKind.NPX) {
5267
+ log("iris_rollback_npx_strategy", {
5268
+ note: "Running via npx \u2014 update your .mcp.json args to pin the version you want to restore"
5269
+ });
5270
+ return;
5271
+ }
5272
+ await installVersion(version, kind);
5273
+ }
5274
+ async function applyUpdate(targetVersion) {
5275
+ const manifest = loadManifest();
5276
+ if (manifest !== null) {
5277
+ saveManifest({ ...manifest, previousVersion: manifest.currentVersion });
5278
+ }
5279
+ const kind = detectExecutionKind();
5280
+ log("iris_update_applying", { version: targetVersion, executionKind: kind });
5281
+ await installVersion(targetVersion, kind);
5282
+ log("iris_update_applied", { version: targetVersion, executionKind: kind });
5283
+ process.exit(0);
5284
+ }
5285
+ async function rollback() {
5286
+ const manifest = loadManifest();
5287
+ if (manifest === null || manifest.previousVersion === void 0) {
5288
+ throw new Error("No previous version available for rollback");
5289
+ }
5290
+ const prev = manifest.previousVersion;
5291
+ const kind = detectExecutionKind();
5292
+ log("iris_rollback_applying", { version: prev, executionKind: kind });
5293
+ await installVersionRollback(prev, kind);
5294
+ log("iris_rollback_applied", { version: prev, executionKind: kind });
5295
+ process.exit(0);
5296
+ }
5297
+
5298
+ // ../server/dist/server-version.js
5299
+ import { createRequire } from "module";
5300
+ var _pkg = createRequire(import.meta.url)("../package.json");
5301
+ var SERVER_VERSION = _pkg.version;
5302
+
5303
+ // ../server/dist/update/update-tools.js
5304
+ var UPDATE_TOOLS = [
5305
+ {
5306
+ name: IrisTool.VERSION_INFO,
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.",
5308
+ inputSchema: {},
5309
+ outputSchema: {
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.")
5318
+ },
5319
+ handler: async (_deps) => {
5320
+ const manifest = await checkForUpdate(SERVER_VERSION);
5321
+ return {
5322
+ currentVersion: manifest.currentVersion,
5323
+ ...manifest.latestVersion !== void 0 ? { latestVersion: manifest.latestVersion } : {},
5324
+ updateAvailable: manifest.updateAvailable,
5325
+ executionKind: detectExecutionKind(),
5326
+ ...manifest.changelog !== void 0 ? { changelog: manifest.changelog } : {},
5327
+ ...manifest.breakingChanges !== void 0 ? { breakingChanges: manifest.breakingChanges } : {},
5328
+ rollbackAvailable: manifest.previousVersion !== void 0,
5329
+ ...manifest.previousVersion !== void 0 ? { previousVersion: manifest.previousVersion } : {}
5330
+ };
5331
+ }
5332
+ },
5333
+ {
5334
+ name: IrisTool.APPLY_UPDATE,
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.',
5336
+ inputSchema: {
5337
+ confirm: z15.boolean().describe("Set to true to confirm the update should be applied. Required to prevent accidental upgrades.")
5338
+ },
5339
+ outputSchema: {
5340
+ ok: z15.boolean(),
5341
+ message: z15.string().optional()
5342
+ },
5343
+ handler: async (_deps, args) => {
5344
+ if (args["confirm"] !== true) {
5345
+ return { ok: false, message: "Set confirm:true to apply the update" };
5346
+ }
5347
+ const manifest = await checkForUpdate(SERVER_VERSION);
5348
+ if (!manifest.updateAvailable || manifest.latestVersion === void 0) {
5349
+ return { ok: false, message: "No update available \u2014 already on the latest version" };
5350
+ }
5351
+ await applyUpdate(manifest.latestVersion);
5352
+ return { ok: true };
5353
+ }
5354
+ },
5355
+ {
5356
+ name: IrisTool.ROLLBACK,
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.",
5358
+ inputSchema: {
5359
+ confirm: z15.boolean().describe("Set to true to confirm the rollback. Required to prevent accidental downgrades.")
5360
+ },
5361
+ outputSchema: {
5362
+ ok: z15.boolean(),
5363
+ message: z15.string().optional()
5364
+ },
5365
+ handler: async (_deps, args) => {
5366
+ if (args["confirm"] !== true) {
5367
+ return { ok: false, message: "Set confirm:true to apply the rollback" };
5368
+ }
5369
+ await rollback();
5370
+ return { ok: true };
5371
+ }
5372
+ }
5373
+ ];
5374
+
4248
5375
  // ../server/dist/tools/tools.js
4249
5376
  async function snapshotTree(deps, sessionId) {
4250
5377
  const session = deps.sessions.resolve(sessionId);
@@ -4255,7 +5382,7 @@ async function snapshotTree(deps, sessionId) {
4255
5382
  return { lines: normalizeLines(snap.tree ?? ""), route: snap.status?.route ?? "" };
4256
5383
  }
4257
5384
  var sessionIdShape6 = {
4258
- sessionId: z14.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.")
4259
5386
  };
4260
5387
  async function commandOrThrow3(deps, sessionId, name, args) {
4261
5388
  const session = deps.sessions.resolve(sessionId);
@@ -4265,11 +5392,11 @@ async function commandOrThrow3(deps, sessionId, name, args) {
4265
5392
  return result.result;
4266
5393
  }
4267
5394
  function asBox2(value) {
4268
- const b = asRecord3(asRecord3(value)["box"]);
4269
- const x = asNumber2(b["x"]);
4270
- const y = asNumber2(b["y"]);
4271
- const w = asNumber2(b["width"]);
4272
- 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"]);
4273
5400
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
4274
5401
  return void 0;
4275
5402
  if (w <= 0 || h <= 0)
@@ -4285,29 +5412,51 @@ async function tryRealInput(deps, session, ref, action, args) {
4285
5412
  return synthetic();
4286
5413
  if (!isPointerAction(action))
4287
5414
  return synthetic(InputModeReason.NOT_POINTER);
4288
- const inner = asRecord3(args["args"]);
5415
+ const inner = asRecord(args["args"]);
4289
5416
  if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && inner["native"] !== true) {
4290
5417
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
4291
5418
  }
4292
5419
  if (!await provider.isAvailableFor(session.url))
4293
5420
  return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
4294
- 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);
4295
5438
  if (box === void 0)
4296
5439
  return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
4297
5440
  let toBox;
4298
5441
  if (action === ActionType.DRAG) {
4299
- const toRef = asString4(inner["toRef"]);
5442
+ const toRef = asString(inner["toRef"]);
4300
5443
  if (toRef === void 0)
4301
5444
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4302
- 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);
4303
5452
  if (toBox === void 0)
4304
5453
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4305
5454
  }
4306
5455
  const performArgs = {};
4307
- const value = asString4(inner["value"]);
5456
+ const value = asString(inner["value"]);
4308
5457
  if (value !== void 0)
4309
5458
  performArgs.value = value;
4310
- const text = asString4(inner["text"]);
5459
+ const text = asString(inner["text"]);
4311
5460
  if (text !== void 0)
4312
5461
  performArgs.text = text;
4313
5462
  if (toBox !== void 0)
@@ -4326,23 +5475,24 @@ async function tryRealInput(deps, session, ref, action, args) {
4326
5475
  };
4327
5476
  }
4328
5477
  }
5478
+ var SNAPSHOT_CACHE = new SnapshotCache();
4329
5479
  var TOOLS = [
4330
5480
  {
4331
5481
  name: IrisTool.SESSIONS,
4332
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.",
4333
5483
  inputSchema: {},
4334
5484
  outputSchema: {
4335
- sessions: z14.array(z14.object({
4336
- sessionId: z14.string(),
4337
- url: z14.string(),
4338
- title: z14.string().optional(),
4339
- lastSeenMs: z14.number(),
4340
- throttled: z14.boolean(),
4341
- focused: z14.boolean(),
4342
- hidden: z14.boolean(),
4343
- realInputAvailable: z14.boolean().optional(),
4344
- stale: z14.boolean().optional(),
4345
- recommendation: z14.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()
4346
5496
  })).describe("Connected browser sessions with health state.")
4347
5497
  },
4348
5498
  handler: async (deps) => {
@@ -4356,71 +5506,95 @@ var TOOLS = [
4356
5506
  },
4357
5507
  {
4358
5508
  name: IrisTool.SNAPSHOT,
4359
- 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.",
4360
5510
  inputSchema: {
4361
- scope: z14.string().optional().describe("CSS selector or element ref to restrict the snapshot to a subtree. Omit to snapshot the whole page."),
4362
- mode: z14.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."),
4363
5514
  ...sessionIdShape6
4364
5515
  },
4365
5516
  outputSchema: {
4366
- tree: z14.string().optional().describe("Indented ARIA tree of every element on the page (or the scoped subtree)."),
4367
- status: z14.object({ route: z14.string(), title: z14.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.")
4368
5527
  },
4369
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.SNAPSHOT, {
4370
- scope: args["scope"],
4371
- mode: args["mode"] ?? SnapshotMode.FULL
4372
- })
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
+ }
4373
5541
  },
4374
5542
  {
4375
5543
  name: IrisTool.QUERY,
4376
- 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.",
4377
5545
  inputSchema: {
4378
- by: z14.string().describe("Query strategy: role | text | label | placeholder | testid | alt"),
4379
- value: z14.string().describe("Query value for the selected strategy (e.g. by=role value=button, or by=testid value=submit-btn)."),
4380
- name: z14.string().optional().describe("Accessible name filter \u2014 narrows results when `by` is role and the page has many elements of that role."),
4381
- scope: z14.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.'),
4382
5552
  ...sessionIdShape6
4383
5553
  },
4384
5554
  outputSchema: {
4385
- elements: z14.array(z14.object({
4386
- ref: z14.string(),
4387
- role: z14.string(),
4388
- name: z14.string(),
4389
- value: z14.string().optional(),
4390
- states: z14.array(z14.string()),
4391
- visible: z14.boolean()
4392
- })),
4393
- hint: z14.object({
4394
- route: z14.string(),
4395
- presentTestids: z14.array(z14.string()),
4396
- knownEmptyState: z14.boolean()
4397
- }).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.")
4398
5572
  },
4399
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.QUERY, {
5573
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.QUERY, {
4400
5574
  by: args["by"],
4401
5575
  value: args["value"],
4402
5576
  name: args["name"],
4403
5577
  scope: args["scope"]
4404
- })
5578
+ }).then((result) => withSizeCost(paginateQueryResult(result, asNumber(args["limit"]), args["count_only"] === true)))
4405
5579
  },
4406
5580
  {
4407
5581
  name: IrisTool.INSPECT,
4408
5582
  description: "Deep info on one element by ref: full a11y props, visibility, box, and (with @syrin/iris-react) component stack + source file.",
4409
5583
  inputSchema: {
4410
- ref: z14.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')."),
4411
5585
  ...sessionIdShape6
4412
5586
  },
4413
5587
  outputSchema: {
4414
- ref: z14.string(),
4415
- role: z14.string(),
4416
- name: z14.string(),
4417
- value: z14.string().optional(),
4418
- states: z14.array(z14.string()),
4419
- visible: z14.boolean(),
4420
- box: z14.object({ x: z14.number(), y: z14.number(), width: z14.number(), height: z14.number() }).optional(),
4421
- component: z14.object({ name: z14.string(), sourceFile: z14.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()
4422
5596
  },
4423
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.INSPECT, {
5597
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.INSPECT, {
4424
5598
  ref: args["ref"]
4425
5599
  })
4426
5600
  },
@@ -4428,30 +5602,30 @@ var TOOLS = [
4428
5602
  name: IrisTool.ACT,
4429
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.',
4430
5604
  inputSchema: {
4431
- ref: z14.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
4432
- action: z14.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4433
- args: z14.record(z14.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click."),
4434
- refuseWhenThrottled: z14.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)."),
4435
5609
  ...sessionIdShape6
4436
5610
  },
4437
5611
  outputSchema: {
4438
- since: z14.number().describe("Cursor \u2014 pass to iris_observe/iris_wait_for/iris_assert to scope reaction queries to this act."),
4439
- dispatched: z14.boolean(),
4440
- settled: z14.boolean().nullable(),
4441
- inputMode: z14.string(),
4442
- result: z14.unknown().optional(),
4443
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4444
5618
  },
4445
5619
  handler: async (deps, args) => {
4446
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5620
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4447
5621
  const paused = pausedShortCircuit(session);
4448
5622
  if (paused !== void 0)
4449
5623
  return paused;
4450
5624
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4451
5625
  const since = session.elapsed();
4452
5626
  session.markActCursor(since);
4453
- const ref = asString4(args["ref"]) ?? "";
4454
- const action = asString4(args["action"]) ?? "";
5627
+ const ref = asString(args["ref"]) ?? "";
5628
+ const action = asString(args["action"]) ?? "";
4455
5629
  const real = await tryRealInput(deps, session, ref, action, args);
4456
5630
  if (real.result !== void 0) {
4457
5631
  if (deps.recordings.active().length > 0) {
@@ -4477,7 +5651,7 @@ var TOOLS = [
4477
5651
  if (deps.recordings.active().length > 0) {
4478
5652
  deps.recordings.capture(compileActStep(args, result.result));
4479
5653
  }
4480
- const r = asRecord3(result.result);
5654
+ const r = asRecord(result.result);
4481
5655
  return withControl(session, {
4482
5656
  since,
4483
5657
  inputMode: InputMode.SYNTHETIC,
@@ -4496,17 +5670,17 @@ var TOOLS = [
4496
5670
  name: IrisTool.ACT_SEQUENCE,
4497
5671
  description: "Run multiple actions in order (fill -> fill -> submit) in one round-trip. Returns per-step effects[] (see iris_act).",
4498
5672
  inputSchema: {
4499
- steps: z14.array(z14.record(z14.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."),
4500
5674
  ...sessionIdShape6
4501
5675
  },
4502
5676
  outputSchema: {
4503
- since: z14.number(),
4504
- dispatched: z14.boolean(),
4505
- result: z14.unknown().optional(),
4506
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4507
5681
  },
4508
5682
  handler: async (deps, args) => {
4509
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5683
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4510
5684
  const paused = pausedShortCircuit(session);
4511
5685
  if (paused !== void 0)
4512
5686
  return paused;
@@ -4518,7 +5692,7 @@ var TOOLS = [
4518
5692
  if (deps.recordings.active().length > 0) {
4519
5693
  deps.recordings.capture(compileSequenceStep(args, result.result));
4520
5694
  }
4521
- const r = asRecord3(result.result);
5695
+ const r = asRecord(result.result);
4522
5696
  return withControl(session, {
4523
5697
  since,
4524
5698
  dispatched: r["count"] !== void 0,
@@ -4529,34 +5703,34 @@ var TOOLS = [
4529
5703
  },
4530
5704
  {
4531
5705
  name: IrisTool.ACT_AND_WAIT,
4532
- 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.",
4533
5707
  inputSchema: {
4534
- ref: z14.string().describe("Element ref from iris_snapshot or iris_query."),
4535
- action: z14.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4536
- args: z14.record(z14.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press."),
4537
- until: PredicateSchema.describe("Predicate to wait for after the action completes. Same shape accepted by iris_assert."),
4538
- timeout_ms: z14.number().optional().describe("Maximum wait time in milliseconds. 0 = evaluate once without waiting. Default: 4000."),
4539
- refuseWhenThrottled: z14.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."),
4540
5714
  ...sessionIdShape6
4541
5715
  },
4542
5716
  outputSchema: {
4543
- effect: z14.unknown().describe("The iris_act result (dispatched, settled, inputMode, etc.)."),
4544
- verdict: z14.object({
4545
- pass: z14.boolean(),
4546
- evidence: z14.unknown().optional(),
4547
- failureReason: z14.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()
4548
5722
  }),
4549
- trace: z14.unknown().describe("Reaction report (same shape as iris_observe summary)."),
4550
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4551
5725
  },
4552
5726
  handler: async (deps, args) => {
4553
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5727
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4554
5728
  const paused = pausedShortCircuit(session);
4555
5729
  if (paused !== void 0)
4556
5730
  return paused;
4557
5731
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4558
- const until = PredicateSchema.parse(args["until"]);
4559
- 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;
4560
5734
  const since = session.elapsed();
4561
5735
  session.markActCursor(since);
4562
5736
  const actResult = await session.command(IrisCommand.ACT, {
@@ -4580,40 +5754,41 @@ var TOOLS = [
4580
5754
  name: IrisTool.OBSERVE,
4581
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.",
4582
5756
  inputSchema: {
4583
- window_ms: z14.number().optional().describe("Time window to look back. Default: 2000ms. Ignored when `since` is provided."),
4584
- since: z14.number().optional().describe("Cursor from a prior iris_act or iris_observe call. Scopes the event window to exactly that span."),
4585
- filters: z14.array(z14.string()).optional().describe("Event type allowlist: dom | net | route | console | animation | signal. Omit to return all types."),
4586
- max_events: z14.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."),
4587
5761
  ...sessionIdShape6
4588
5762
  },
4589
5763
  outputSchema: {
4590
- events: z14.array(z14.unknown()),
4591
- summary: z14.object({
4592
- total: z14.number(),
4593
- network: z14.number(),
4594
- domAdded: z14.number(),
4595
- domRemoved: z14.number(),
4596
- domChanged: z14.number(),
4597
- routeChanges: z14.number(),
4598
- consoleErrors: z14.number(),
4599
- animations: z14.number(),
4600
- signals: z14.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()
4601
5775
  }),
4602
- cost: z14.object({
4603
- events: z14.number(),
4604
- bytes: z14.number(),
4605
- droppedOldest: z14.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).")
4606
5781
  }),
4607
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.boolean() }).optional()
5782
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4608
5783
  },
4609
5784
  handler: (deps, args) => {
4610
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4611
- const since = asNumber2(args["since"]);
4612
- 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;
4613
5788
  const events = since !== void 0 ? session.eventsSince(since) : session.eventsInWindow(windowMs);
4614
5789
  const filters = Array.isArray(args["filters"]) ? args["filters"] : void 0;
4615
5790
  const filtered = filters === void 0 ? events : events.filter((e) => filters.includes(e.type));
4616
- const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber2(args["max_events"]));
5791
+ const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber(args["max_events"]));
4617
5792
  const report = buildReactionReport(budgeted, windowMs);
4618
5793
  return Promise.resolve(withControl(session, {
4619
5794
  ...report,
@@ -4626,99 +5801,113 @@ var TOOLS = [
4626
5801
  name: IrisTool.WAIT_FOR,
4627
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.",
4628
5803
  inputSchema: {
4629
- predicate: PredicateSchema.describe("Predicate to wait for: { signal }, { net }, { element } or a combination."),
4630
- timeout_ms: z14.number().optional().describe("Maximum wait in milliseconds. Default: 4000."),
4631
- since: z14.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."),
4632
5807
  ...sessionIdShape6
4633
5808
  },
4634
5809
  outputSchema: {
4635
- pass: z14.boolean(),
4636
- evidence: z14.unknown().optional(),
4637
- failureReason: z14.string().optional(),
4638
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4639
5814
  },
4640
5815
  handler: async (deps, args) => {
4641
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5816
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4642
5817
  const predicate = PredicateSchema.parse(args["predicate"]);
4643
- const since = asNumber2(args["since"]) ?? session.lastActCursor() ?? 0;
4644
- 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);
4645
5820
  return withControl(session, { ...verdict, ...healthEnvelope(session) });
4646
5821
  }
4647
5822
  },
4648
5823
  {
4649
5824
  name: IrisTool.ASSERT,
4650
- 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.",
4651
5826
  inputSchema: {
4652
5827
  predicate: PredicateSchema.describe("Predicate to evaluate: { signal }, { net }, { element } or a combination."),
4653
- timeout_ms: z14.number().optional().describe("If > 0, wait up to this many milliseconds before failing. Default: 0 (evaluate once)."),
4654
- since: z14.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."),
4655
5830
  ...sessionIdShape6
4656
5831
  },
4657
5832
  outputSchema: {
4658
- pass: z14.boolean(),
4659
- evidence: z14.unknown().optional(),
4660
- failureReason: z14.string().optional(),
4661
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4662
5838
  },
4663
5839
  handler: async (deps, args) => {
4664
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5840
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4665
5841
  const predicate = PredicateSchema.parse(args["predicate"]);
4666
- const timeout2 = asNumber2(args["timeout_ms"]) ?? 0;
4667
- 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;
4668
5844
  const verdict = timeout2 > 0 ? await waitForPredicate(session, predicate, timeout2, since) : await evaluatePredicate(session, predicate, since);
4669
- 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) });
4670
5847
  }
4671
5848
  },
4672
5849
  {
4673
5850
  name: IrisTool.NETWORK,
4674
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.',
4675
5852
  inputSchema: {
4676
- since: z14.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to requests fired after that act."),
4677
- method: z14.string().optional().describe("HTTP method filter: GET | POST | PUT | DELETE | PATCH etc."),
4678
- urlContains: z14.string().optional().describe("Substring that the request URL must contain."),
4679
- status: z14.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."),
4680
5858
  ...sessionIdShape6
4681
5859
  },
4682
5860
  outputSchema: {
4683
- calls: z14.array(z14.unknown()),
4684
- hint: z14.object({ totalInWindow: z14.number(), present: z14.array(z14.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()
4685
5866
  },
4686
5867
  handler: (deps, args) => {
4687
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4688
- const since = asNumber2(args["since"]) ?? 0;
4689
- const method = asString4(args["method"]);
4690
- const urlContains = asString4(args["urlContains"]);
4691
- 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"]);
4692
5874
  const allNet = session.eventsSince(since).filter((e) => e.type === EventType.NET_REQUEST);
4693
- const calls = allNet.filter((e) => matchNet(e, method, urlContains, status));
4694
- if (calls.length === 0 && allNet.length > 0) {
4695
- 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) }));
4696
5878
  }
4697
- 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 }));
4698
5881
  }
4699
5882
  },
4700
5883
  {
4701
5884
  name: IrisTool.CONSOLE,
4702
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.',
4703
5886
  inputSchema: {
4704
- level: z14.string().optional().describe("Log level filter: error | warn | info | log. Omit to return all levels."),
4705
- since: z14.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."),
4706
5890
  ...sessionIdShape6
4707
5891
  },
4708
5892
  outputSchema: {
4709
- logs: z14.array(z14.unknown()),
4710
- hint: z14.object({ totalInWindow: z14.number(), byLevel: z14.record(z14.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()
4711
5898
  },
4712
5899
  handler: (deps, args) => {
4713
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4714
- const since = asNumber2(args["since"]) ?? 0;
4715
- 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"]);
4716
5904
  const allConsole = session.eventsSince(since).filter(isConsoleEvent);
4717
- const logs = allConsole.filter((e) => matchConsole(e, level));
4718
- if (logs.length === 0 && allConsole.length > 0) {
4719
- 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) }));
4720
5908
  }
4721
- 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 }));
4722
5911
  }
4723
5912
  },
4724
5913
  {
@@ -4726,24 +5915,24 @@ var TOOLS = [
4726
5915
  description: "Currently running + recently completed animations with targets/timing.",
4727
5916
  inputSchema: { ...sessionIdShape6 },
4728
5917
  outputSchema: {
4729
- animations: z14.array(z14.unknown())
5918
+ animations: z16.array(z16.unknown())
4730
5919
  },
4731
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.ANIMATIONS, {})
5920
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.ANIMATIONS, {})
4732
5921
  },
4733
5922
  {
4734
5923
  name: IrisTool.BASELINE_SAVE,
4735
5924
  description: "Snapshot the current semantic state under a name, to diff against later (regression detection).",
4736
5925
  inputSchema: {
4737
- name: z14.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.'),
4738
5927
  ...sessionIdShape6
4739
5928
  },
4740
5929
  outputSchema: {
4741
- baseline: z14.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
4742
- lineCount: z14.number()
5930
+ baseline: z16.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
5931
+ lineCount: z16.number()
4743
5932
  },
4744
5933
  handler: async (deps, args) => {
4745
- const name = asString4(args["name"]) ?? "default";
4746
- 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"]));
4747
5936
  deps.baselines.save({ name, lines, route });
4748
5937
  return { baseline: name, lineCount: lines.length };
4749
5938
  }
@@ -4753,7 +5942,7 @@ var TOOLS = [
4753
5942
  description: "List saved baseline names.",
4754
5943
  inputSchema: {},
4755
5944
  outputSchema: {
4756
- baselines: z14.array(z14.string())
5945
+ baselines: z16.array(z16.string())
4757
5946
  },
4758
5947
  handler: (deps) => Promise.resolve({ baselines: deps.baselines.list() })
4759
5948
  },
@@ -4761,23 +5950,23 @@ var TOOLS = [
4761
5950
  name: IrisTool.DIFF,
4762
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?".',
4763
5952
  inputSchema: {
4764
- baseline: z14.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."),
4765
5954
  ...sessionIdShape6
4766
5955
  },
4767
5956
  outputSchema: {
4768
- baseline: z14.string(),
4769
- removed: z14.array(z14.string()),
4770
- added: z14.array(z14.string()),
4771
- consoleErrors: z14.number(),
4772
- routeChanged: z14.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()
4773
5962
  },
4774
5963
  handler: async (deps, args) => {
4775
- const name = asString4(args["baseline"]) ?? "default";
5964
+ const name = asString(args["baseline"]) ?? "default";
4776
5965
  const base = deps.baselines.get(name);
4777
5966
  if (base === void 0)
4778
5967
  throw new Error(`no baseline named '${name}'`);
4779
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4780
- 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"]));
4781
5970
  const { removed, added } = diffLines(base.lines, lines);
4782
5971
  const consoleErrors = session.eventsSince(0).filter((e) => e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT).length;
4783
5972
  return { baseline: name, removed, added, consoleErrors, routeChanged: base.route !== route };
@@ -4787,16 +5976,16 @@ var TOOLS = [
4787
5976
  name: IrisTool.RECORD_START,
4788
5977
  description: "Start recording the event timeline under a name (for replay / a flow report).",
4789
5978
  inputSchema: {
4790
- recordingName: z14.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."),
4791
5980
  ...sessionIdShape6
4792
5981
  },
4793
5982
  outputSchema: {
4794
- recordingName: z14.string(),
4795
- since: z14.number()
5983
+ recordingName: z16.string(),
5984
+ since: z16.number()
4796
5985
  },
4797
5986
  handler: (deps, args) => {
4798
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4799
- const name = asString4(args["recordingName"]) ?? "default";
5987
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5988
+ const name = asString(args["recordingName"]) ?? "default";
4800
5989
  const cursor = session.elapsed();
4801
5990
  deps.recordings.start(name, cursor);
4802
5991
  return Promise.resolve({ recordingName: name, since: cursor });
@@ -4806,17 +5995,17 @@ var TOOLS = [
4806
5995
  name: IrisTool.RECORD_STOP,
4807
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.",
4808
5997
  inputSchema: {
4809
- recordingName: z14.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."),
4810
5999
  ...sessionIdShape6
4811
6000
  },
4812
6001
  outputSchema: {
4813
- recordingName: z14.string(),
4814
- program: z14.unknown(),
4815
- warning: z14.string().optional()
6002
+ recordingName: z16.string(),
6003
+ program: z16.unknown(),
6004
+ warning: z16.string().optional()
4816
6005
  },
4817
6006
  handler: (deps, args) => {
4818
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4819
- const name = asString4(args["recordingName"]) ?? "default";
6007
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
6008
+ const name = asString(args["recordingName"]) ?? "default";
4820
6009
  const rec = deps.recordings.stop(name);
4821
6010
  if (rec === void 0)
4822
6011
  throw new Error(`no active recording named '${name}'`);
@@ -4842,29 +6031,30 @@ var TOOLS = [
4842
6031
  },
4843
6032
  {
4844
6033
  name: IrisTool.REPLAY,
4845
- 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?}] }.",
4846
6035
  inputSchema: {
4847
- recordingName: z14.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."),
4848
6038
  ...sessionIdShape6
4849
6039
  },
4850
6040
  outputSchema: {
4851
- recordingName: z14.string(),
4852
- ok: z14.boolean(),
4853
- steps: z14.array(z14.object({
4854
- tool: z14.string(),
4855
- ok: z14.boolean(),
4856
- error: z14.string().optional(),
4857
- note: z14.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()
4858
6048
  }))
4859
6049
  },
4860
6050
  handler: async (deps, args) => {
4861
- const name = asString4(args["recordingName"]) ?? "default";
6051
+ const name = asString(args["recordingName"]) ?? "default";
4862
6052
  const program = deps.recordings.getCompiled(name);
4863
6053
  if (program === void 0)
4864
6054
  throw new Error(`no compiled recording named '${name}'`);
4865
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6055
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4866
6056
  const since = session.elapsed();
4867
- const steps = await replayProgram(session, program);
6057
+ const steps = await replayProgram(session, program, args["confirmDangerous"] === true);
4868
6058
  return { recordingName: name, since, steps, ok: steps.every((s) => s.ok) };
4869
6059
  }
4870
6060
  },
@@ -4872,32 +6062,33 @@ var TOOLS = [
4872
6062
  name: IrisTool.NARRATE,
4873
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.",
4874
6064
  inputSchema: {
4875
- text: z14.string().describe("Short sentence describing your next action, shown on the presenter HUD for the developer watching."),
4876
- level: z14.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."),
4877
6067
  ...sessionIdShape6
4878
6068
  },
4879
- outputSchema: {
4880
- ok: z14.boolean()
4881
- },
4882
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.NARRATE, {
4883
- text: args["text"],
4884
- level: args["level"]
4885
- })
6069
+ outputSchema: { ok: z16.boolean() },
6070
+ handler: async (deps, args) => {
6071
+ const result = await commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.NARRATE, {
6072
+ text: args["text"],
6073
+ level: args["level"]
6074
+ });
6075
+ return { ok: true, ...result };
6076
+ }
4886
6077
  },
4887
6078
  {
4888
6079
  name: IrisTool.CLOCK,
4889
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.",
4890
6081
  inputSchema: {
4891
- freeze: z14.boolean().optional().describe("Freeze the fake clock. Time stops advancing until advanceMs or reset."),
4892
- advanceMs: z14.number().optional().describe("Fast-forward time by this many milliseconds \u2014 triggers debounces, toasts, auto-dismiss timers."),
4893
- reset: z14.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."),
4894
6085
  ...sessionIdShape6
4895
6086
  },
4896
6087
  outputSchema: {
4897
- ok: z14.boolean(),
4898
- elapsed: z14.number().optional()
6088
+ ok: z16.boolean().optional(),
6089
+ elapsed: z16.number().optional()
4899
6090
  },
4900
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.CLOCK, {
6091
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.CLOCK, {
4901
6092
  freeze: args["freeze"],
4902
6093
  advanceMs: args["advanceMs"],
4903
6094
  reset: args["reset"]
@@ -4907,27 +6098,27 @@ var TOOLS = [
4907
6098
  name: IrisTool.STATE,
4908
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? }.",
4909
6100
  inputSchema: {
4910
- ref: z14.string().optional().describe("Element ref \u2014 attempts a best-effort read of the nearest React component's hook state."),
4911
- store: z14.string().optional().describe("Registered store name (e.g. 'workspace'). Omit to read all stores."),
4912
- path: z14.string().optional().describe("Dot-path into the store (e.g. 'captionCache.v3'). Numeric array indices are supported."),
4913
- depth: z14.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."),
4914
6105
  ...sessionIdShape6
4915
6106
  },
4916
6107
  outputSchema: {
4917
- stores: z14.record(z14.unknown()).optional(),
4918
- storeNames: z14.array(z14.string()).optional(),
4919
- found: z14.boolean().optional(),
4920
- value: z14.unknown().optional(),
4921
- component: z14.object({ ok: z14.boolean(), reason: z14.string().optional(), state: z14.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()
4922
6113
  },
4923
6114
  handler: async (deps, args) => {
4924
- const store = asString4(args["store"]);
4925
- 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, {
4926
6117
  ref: args["ref"],
4927
6118
  store
4928
6119
  });
4929
- const path = asString4(args["path"]);
4930
- const depth = asNumber2(args["depth"]);
6120
+ const path = asString(args["path"]);
6121
+ const depth = asNumber(args["depth"]);
4931
6122
  if (path === void 0 && depth === void 0)
4932
6123
  return result;
4933
6124
  const root = result;
@@ -4947,16 +6138,16 @@ var TOOLS = [
4947
6138
  name: IrisTool.EXPLORE,
4948
6139
  description: "Autonomous-exploration helper: list interactive elements (with refs) + current console-error count, so the agent can drive the app and report anomalies.",
4949
6140
  inputSchema: {
4950
- scope: z14.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."),
4951
6142
  ...sessionIdShape6
4952
6143
  },
4953
6144
  outputSchema: {
4954
- interactive: z14.array(z14.unknown()),
4955
- consoleErrors: z14.number(),
4956
- hint: z14.string()
6145
+ interactive: z16.array(z16.unknown()),
6146
+ consoleErrors: z16.number(),
6147
+ hint: z16.string()
4957
6148
  },
4958
6149
  handler: async (deps, args) => {
4959
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6150
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4960
6151
  const result = await session.command(IrisCommand.SNAPSHOT, {
4961
6152
  mode: SnapshotMode.INTERACTIVE,
4962
6153
  scope: args["scope"]
@@ -4974,6 +6165,7 @@ var TOOLS = [
4974
6165
  },
4975
6166
  // iris_capabilities (live | fromDisk) + iris_contract_save. See contract-tools.ts.
4976
6167
  ...CONTRACT_TOOLS,
6168
+ ...DOMAIN_TOOLS,
4977
6169
  // iris_flow_save / iris_flow_list / iris_flow_load. See flow-tools.ts.
4978
6170
  ...FLOW_TOOLS,
4979
6171
  // iris_project (read history + diff-vs-last) / iris_run_record. See project-tools.ts.
@@ -4991,7 +6183,9 @@ var TOOLS = [
4991
6183
  // Live-control: iris_end_session / iris_resume / iris_messages. See live-control-tools.ts.
4992
6184
  ...LIVE_CONTROL_TOOLS,
4993
6185
  // iris_navigate / iris_refresh — browser navigation tools. See browser-tools.ts.
4994
- ...BROWSER_TOOLS
6186
+ ...BROWSER_TOOLS,
6187
+ // iris_version_info / iris_apply_update / iris_rollback — update lifecycle tools.
6188
+ ...UPDATE_TOOLS
4995
6189
  ];
4996
6190
 
4997
6191
  // ../server/dist/tools/profiles.js
@@ -5125,7 +6319,7 @@ async function runTool(tool, deps, args) {
5125
6319
  return result;
5126
6320
  if (!isPlainObject(result) || "session" in result)
5127
6321
  return result;
5128
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6322
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5129
6323
  const envelope = { ...healthEnvelope(session) };
5130
6324
  const lease = session.takeSessionLease();
5131
6325
  if (lease !== void 0)
@@ -5137,7 +6331,7 @@ async function runTool(tool, deps, args) {
5137
6331
  }
5138
6332
 
5139
6333
  // ../server/dist/mcp.js
5140
- var SERVER_INFO = { name: "iris", version: "0.3.10" };
6334
+ var SERVER_INFO = { name: "iris", version: SERVER_VERSION };
5141
6335
  var ENCODING_ENV = "IRIS_ENCODING";
5142
6336
  var TOON_VALUE = "toon";
5143
6337
  function encodeResult(result, useToon) {
@@ -5251,10 +6445,27 @@ function createToolInvoker(deps) {
5251
6445
  };
5252
6446
  }
5253
6447
 
6448
+ // ../server/dist/daemon.js
6449
+ import { join as join4 } from "path";
6450
+ import { homedir as homedir2 } from "os";
6451
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3, unlinkSync, openSync } from "fs";
6452
+ import { spawn } from "child_process";
6453
+ var IRIS_HOME2 = join4(homedir2(), ".iris");
6454
+
5254
6455
  // ../server/dist/index.js
5255
6456
  async function start(options = {}) {
5256
6457
  const port = options.port ?? IRIS_DEFAULT_PORT;
5257
- 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
+ });
5258
6469
  const reaper = new SessionReaper(bridge.sessions);
5259
6470
  reaper.start();
5260
6471
  const baselines = new BaselineStore();
@@ -5283,11 +6494,11 @@ async function start(options = {}) {
5283
6494
  }
5284
6495
  }
5285
6496
  if (options.mcp !== false) {
5286
- const fs = createNodeFileSystem();
5287
- const irisRoot = options.irisRoot ?? join2(process.cwd(), IrisDir.ROOT);
6497
+ const fs2 = createNodeFileSystem();
6498
+ const irisRoot = options.irisRoot ?? join5(process.cwd(), IrisDir.ROOT);
5288
6499
  const now = options.now ?? (() => Date.now());
5289
- const flows = new FlowStore(fs, irisRoot, { now });
5290
- const project = new ProjectStore(fs, irisRoot, { now });
6500
+ const flows = new FlowStore(fs2, irisRoot, { now });
6501
+ const project = new ProjectStore(fs2, irisRoot, { now });
5291
6502
  const annotations = new AnnotationStore();
5292
6503
  const deps = {
5293
6504
  sessions: bridge.sessions,
@@ -5296,7 +6507,7 @@ async function start(options = {}) {
5296
6507
  annotations,
5297
6508
  flows,
5298
6509
  project,
5299
- fs,
6510
+ fs: fs2,
5300
6511
  irisRoot,
5301
6512
  now
5302
6513
  };
@@ -5321,17 +6532,17 @@ async function start(options = {}) {
5321
6532
 
5322
6533
  // ../test/dist/boot.js
5323
6534
  function defaultBuildDeps(server, opts) {
5324
- const fs = createNodeFileSystem();
5325
- const irisRoot = opts.irisRoot ?? join3(process.cwd(), IrisDir.ROOT);
6535
+ const fs2 = createNodeFileSystem();
6536
+ const irisRoot = opts.irisRoot ?? join6(process.cwd(), IrisDir.ROOT);
5326
6537
  const now = opts.now ?? (() => Date.now());
5327
6538
  const base = {
5328
6539
  sessions: server.bridge.sessions,
5329
6540
  baselines: new BaselineStore(),
5330
6541
  recordings: new RecordingStore(),
5331
- flows: new FlowStore(fs, irisRoot, { now }),
5332
- project: new ProjectStore(fs, irisRoot, { now }),
6542
+ flows: new FlowStore(fs2, irisRoot, { now }),
6543
+ project: new ProjectStore(fs2, irisRoot, { now }),
5333
6544
  annotations: new AnnotationStore(),
5334
- fs,
6545
+ fs: fs2,
5335
6546
  irisRoot,
5336
6547
  now
5337
6548
  };
@@ -5596,53 +6807,6 @@ function createTestContext(invoke, options = {}) {
5596
6807
  };
5597
6808
  }
5598
6809
 
5599
- // ../test/dist/success-assert.js
5600
- function successToPredicate(success, dynamic) {
5601
- const parts = [];
5602
- if (success.signal !== void 0) {
5603
- parts.push(success.signalData !== void 0 ? { kind: PredicateKind.SIGNAL, name: success.signal, dataMatches: success.signalData } : { kind: PredicateKind.SIGNAL, name: success.signal });
5604
- }
5605
- if (success.net !== void 0) {
5606
- const net = { kind: PredicateKind.NET };
5607
- if (success.net.method !== void 0)
5608
- net.method = success.net.method;
5609
- if (success.net.urlContains !== void 0)
5610
- net.urlContains = success.net.urlContains;
5611
- if (success.net.status !== void 0)
5612
- net.status = success.net.status;
5613
- parts.push(net);
5614
- }
5615
- const element = success.element;
5616
- if (element !== void 0) {
5617
- const testid = element.testid;
5618
- if (testid === void 0 || !dynamic.has(testid)) {
5619
- const query = {};
5620
- if (testid !== void 0)
5621
- query["testid"] = testid;
5622
- if (element.role !== void 0)
5623
- query["role"] = element.role;
5624
- if (element.name !== void 0)
5625
- query["name"] = element.name;
5626
- if (Object.keys(query).length > 0) {
5627
- parts.push({ kind: PredicateKind.ELEMENT, query });
5628
- }
5629
- }
5630
- }
5631
- if (parts.length === 0)
5632
- return void 0;
5633
- if (parts.length === 1)
5634
- return parts[0];
5635
- return { kind: "allOf", predicates: parts };
5636
- }
5637
- async function assertSuccess(session, success, dynamic, waitForSignal, timeoutMs) {
5638
- if (success === void 0)
5639
- return { pass: true };
5640
- const predicate = successToPredicate(success, dynamic);
5641
- if (predicate === void 0)
5642
- return { pass: true };
5643
- return waitForSignal(session, predicate, timeoutMs);
5644
- }
5645
-
5646
6810
  // ../test/dist/flow-spec.js
5647
6811
  function toDynamicSet(flow) {
5648
6812
  return new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));