@syrin/iris 0.5.0 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -5,6 +5,30 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  // ../protocol/dist/constants.js
6
6
  var IRIS_DEFAULT_PORT = 4400;
7
7
  var IRIS_WS_PATH = "/iris";
8
+ var IRIS_PROTOCOL_VERSION = 1;
9
+ var TRANSPORT_LIMITS = {
10
+ MAX_MESSAGE_BYTES: 1024 * 1024,
11
+ MAX_MESSAGES_PER_SECOND: 1e3,
12
+ MAX_SESSIONS: 32,
13
+ MAX_PENDING_CONNECTIONS: 16,
14
+ HELLO_TIMEOUT_MS: 5e3,
15
+ MAX_BUFFER_BYTES: 8 * 1024 * 1024,
16
+ MAX_SESSION_ID_LENGTH: 128,
17
+ MAX_URL_LENGTH: 4096,
18
+ MAX_TITLE_LENGTH: 512,
19
+ MAX_ADAPTERS: 32,
20
+ MAX_ADAPTER_NAME_LENGTH: 128,
21
+ MAX_TOKEN_LENGTH: 512,
22
+ MAX_COMMAND_ID_LENGTH: 128,
23
+ MAX_COMMAND_NAME_LENGTH: 128,
24
+ MAX_REF_LENGTH: 128,
25
+ MAX_ERROR_LENGTH: 4096,
26
+ MAX_SERIALIZE_DEPTH: 8,
27
+ MAX_COLLECTION_ITEMS: 200,
28
+ MAX_OBJECT_KEYS: 200,
29
+ MAX_STRING_LENGTH: 64 * 1024
30
+ };
31
+ var DANGEROUS_ACTION_CONFIRM_ARG = "confirmDangerous";
8
32
  var REPLAY_PROGRAM_VERSION = 1;
9
33
  var IrisDir = {
10
34
  ROOT: ".iris",
@@ -106,7 +130,7 @@ var ReplayStatus = {
106
130
  DRIFT: "drift",
107
131
  // an anchor missed (testid renamed / signal not observed) — legible drift returned
108
132
  ERROR: "error"
109
- // the flow file could not be loaded (missing/invalid) no steps ran
133
+ // the flow could not load or a resolved action failed
110
134
  };
111
135
  var DriftReason = {
112
136
  TESTID_NOT_FOUND: "testid_not_found",
@@ -140,8 +164,10 @@ var HealStatus = {
140
164
  // drift exists but no proposal cleared the confidence floor
141
165
  NOTHING_TO_HEAL: "nothing_to_heal",
142
166
  // replay was green
167
+ CONSEQUENCE_BROKEN: "consequence_broken",
168
+ // rebind resolves a locator but the flow's success consequence no longer fires — REFUSED (file untouched)
143
169
  ERROR: "error"
144
- // flow missing/malformed/invalid-name no steps ran
170
+ // flow missing/malformed/invalid-name, or a resolved action failed
145
171
  };
146
172
  var HEAL_CONFIDENCE_MIN = 0.5;
147
173
  var AnnotationTarget = {
@@ -157,7 +183,8 @@ var AnnotationErrorCode = {
157
183
  var COMPILED_PREDICATE_PREFIX = "will";
158
184
  var RING_BUFFER_DEFAULTS = {
159
185
  MAX_EVENTS: 2e3,
160
- MAX_AGE_MS: 6e4
186
+ MAX_AGE_MS: 6e4,
187
+ MAX_BYTES: TRANSPORT_LIMITS.MAX_BUFFER_BYTES
161
188
  };
162
189
  var EventType = {
163
190
  DOM_ADDED: "dom.added",
@@ -342,6 +369,8 @@ var MessageKind = {
342
369
 
343
370
  // ../protocol/dist/messages.js
344
371
  import { z } from "zod";
372
+ var sessionIdSchema = z.string().min(1).max(TRANSPORT_LIMITS.MAX_SESSION_ID_LENGTH);
373
+ var refSchema = z.string().max(TRANSPORT_LIMITS.MAX_REF_LENGTH);
345
374
  var HumanControlDataSchema = z.object({
346
375
  kind: z.nativeEnum(HumanControlKind),
347
376
  text: z.string().optional()
@@ -349,35 +378,37 @@ var HumanControlDataSchema = z.object({
349
378
  var IrisEventSchema = z.object({
350
379
  t: z.number(),
351
380
  type: z.nativeEnum(EventType),
352
- sessionId: z.string(),
381
+ sessionId: sessionIdSchema,
353
382
  /** Stable element reference this event concerns, when applicable (e.g. "e7"). */
354
- ref: z.string().optional(),
383
+ ref: refSchema.optional(),
355
384
  /** Event-type-specific payload. Kept open here; refined per observer at the edges. */
356
385
  data: z.record(z.unknown()).default({})
357
386
  });
358
387
  var HelloMessageSchema = z.object({
359
388
  kind: z.literal(MessageKind.HELLO),
360
- protocolVersion: z.number(),
361
- sessionId: z.string(),
362
- url: z.string(),
363
- title: z.string(),
364
- adapters: z.array(z.string()),
389
+ protocolVersion: z.literal(IRIS_PROTOCOL_VERSION),
390
+ sessionId: sessionIdSchema,
391
+ url: z.string().max(TRANSPORT_LIMITS.MAX_URL_LENGTH),
392
+ title: z.string().max(TRANSPORT_LIMITS.MAX_TITLE_LENGTH),
393
+ adapters: z.array(z.string().max(TRANSPORT_LIMITS.MAX_ADAPTER_NAME_LENGTH)).max(TRANSPORT_LIMITS.MAX_ADAPTERS),
394
+ /** Optional browser/bridge pairing token. Required when the bridge configures one. */
395
+ token: z.string().max(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH).optional(),
365
396
  /** Whether the app has advertised a capability registry (iris.describe). */
366
397
  hasCapabilities: z.boolean().optional()
367
398
  });
368
399
  var CommandMessageSchema = z.object({
369
400
  kind: z.literal(MessageKind.COMMAND),
370
- id: z.string(),
371
- sessionId: z.string().optional(),
372
- name: z.string(),
401
+ id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH),
402
+ sessionId: sessionIdSchema.optional(),
403
+ name: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_NAME_LENGTH),
373
404
  args: z.record(z.unknown()).default({})
374
405
  });
375
406
  var CommandResultSchema = z.object({
376
407
  kind: z.literal(MessageKind.COMMAND_RESULT),
377
- id: z.string(),
408
+ id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH),
378
409
  ok: z.boolean(),
379
410
  result: z.unknown().optional(),
380
- error: z.string().optional()
411
+ error: z.string().max(TRANSPORT_LIMITS.MAX_ERROR_LENGTH).optional()
381
412
  });
382
413
  var EventMessageSchema = z.object({
383
414
  kind: z.literal(MessageKind.EVENT),
@@ -390,6 +421,25 @@ var IrisMessageSchema = z.discriminatedUnion("kind", [
390
421
  EventMessageSchema
391
422
  ]);
392
423
 
424
+ // ../protocol/dist/security.js
425
+ 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;
426
+ function isLoopbackHostname(hostname) {
427
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
428
+ if (normalized === "localhost" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") {
429
+ return true;
430
+ }
431
+ const octets = normalized.split(".");
432
+ return octets.length === 4 && octets[0] === "127" && octets.every((octet) => {
433
+ if (!/^\d{1,3}$/.test(octet))
434
+ return false;
435
+ const value = Number(octet);
436
+ return value >= 0 && value <= 255;
437
+ });
438
+ }
439
+ function isDangerousActionText(text) {
440
+ return DANGEROUS_ACTION.test(text.replace(/[_-]+/g, " "));
441
+ }
442
+
393
443
  // ../protocol/dist/toon.js
394
444
  var ROLE_MAP = {
395
445
  button: "btn",
@@ -737,6 +787,7 @@ function createSharedServer() {
737
787
  }
738
788
 
739
789
  // ../server/dist/bridge.js
790
+ import { timingSafeEqual } from "crypto";
740
791
  import * as http2 from "http";
741
792
  import { WebSocketServer } from "ws";
742
793
 
@@ -744,14 +795,21 @@ import { WebSocketServer } from "ws";
744
795
  var RingBuffer = class {
745
796
  #maxEvents;
746
797
  #maxAgeMs;
798
+ #maxBytes;
747
799
  #events = [];
800
+ #eventBytes = [];
801
+ #totalBytes = 0;
748
802
  #droppedCount = 0;
749
803
  constructor(options = {}) {
750
804
  this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
751
805
  this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
806
+ this.#maxBytes = options.maxBytes ?? RING_BUFFER_DEFAULTS.MAX_BYTES;
752
807
  }
753
808
  push(event, now) {
754
809
  this.#events.push(event);
810
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8");
811
+ this.#eventBytes.push(bytes);
812
+ this.#totalBytes += bytes;
755
813
  this.#evict(now);
756
814
  }
757
815
  /** Events at or after a given timestamp cursor. */
@@ -777,10 +835,14 @@ var RingBuffer = class {
777
835
  #evict(now) {
778
836
  const before = this.#events.length;
779
837
  const cutoff = now - this.#maxAgeMs;
780
- if (this.#events.length > this.#maxEvents) {
781
- this.#events = this.#events.slice(this.#events.length - this.#maxEvents);
838
+ while (this.#events.length > this.#maxEvents || this.#totalBytes > this.#maxBytes && this.#events.length > 0) {
839
+ this.#events.shift();
840
+ this.#totalBytes -= this.#eventBytes.shift() ?? 0;
841
+ }
842
+ while ((this.#events[0]?.t ?? cutoff) < cutoff) {
843
+ this.#events.shift();
844
+ this.#totalBytes -= this.#eventBytes.shift() ?? 0;
782
845
  }
783
- this.#events = this.#events.filter((e) => e.t >= cutoff);
784
846
  this.#droppedCount += before - this.#events.length;
785
847
  }
786
848
  /** Snapshot of buffer health for the agent — total events held and cumulative drops since connect. */
@@ -1017,6 +1079,14 @@ var Session = class {
1017
1079
  this.#pending.delete(id);
1018
1080
  }
1019
1081
  }
1082
+ /** End this transport without letting a stale socket remove its replacement session. */
1083
+ disconnect(reason) {
1084
+ this.rejectAll(reason);
1085
+ try {
1086
+ this.#socket.close(1008, reason);
1087
+ } catch {
1088
+ }
1089
+ }
1020
1090
  // ── Live-control: state machine + human→agent inbox (server-owned) ───────────────
1021
1091
  getState() {
1022
1092
  return this.#state;
@@ -1138,11 +1208,15 @@ var Session = class {
1138
1208
  var SessionManager = class {
1139
1209
  #sessions = /* @__PURE__ */ new Map();
1140
1210
  add(session) {
1211
+ const previous = this.#sessions.get(session.id);
1141
1212
  this.#sessions.set(session.id, session);
1213
+ return previous;
1142
1214
  }
1143
- remove(sessionId) {
1144
- this.#sessions.get(sessionId)?.rejectAll("session disconnected");
1145
- this.#sessions.delete(sessionId);
1215
+ remove(session) {
1216
+ if (this.#sessions.get(session.id) !== session)
1217
+ return false;
1218
+ session.rejectAll("session disconnected");
1219
+ return this.#sessions.delete(session.id);
1146
1220
  }
1147
1221
  get(sessionId) {
1148
1222
  return this.#sessions.get(sessionId);
@@ -1210,6 +1284,20 @@ var SessionManager = class {
1210
1284
  };
1211
1285
 
1212
1286
  // ../server/dist/bridge.js
1287
+ function normalizeOrigin(origin) {
1288
+ try {
1289
+ return new URL(origin).origin;
1290
+ } catch {
1291
+ return null;
1292
+ }
1293
+ }
1294
+ function tokensMatch(expected, received) {
1295
+ if (received === void 0)
1296
+ return false;
1297
+ const expectedBytes = Buffer.from(expected);
1298
+ const receivedBytes = Buffer.from(received);
1299
+ return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
1300
+ }
1213
1301
  function rawToString(raw) {
1214
1302
  if (typeof raw === "string")
1215
1303
  return raw;
@@ -1225,11 +1313,41 @@ var Bridge = class {
1225
1313
  ready;
1226
1314
  #wss;
1227
1315
  #clock;
1316
+ #token;
1317
+ #allowedOrigins;
1318
+ #maxMessagesPerSecond;
1319
+ #maxSessions;
1320
+ #maxPendingConnections;
1321
+ #helloTimeoutMs;
1322
+ #pendingConnections = 0;
1228
1323
  constructor(options) {
1324
+ const host = options.host ?? "127.0.0.1";
1325
+ if ((options.token?.length ?? 0) > TRANSPORT_LIMITS.MAX_TOKEN_LENGTH) {
1326
+ throw new Error(`Iris pairing token exceeds ${String(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH)} characters`);
1327
+ }
1328
+ if (!isLoopbackHostname(host) && (options.token === void 0 || options.token.length === 0)) {
1329
+ throw new Error("a pairing token is required when the Iris bridge binds beyond localhost");
1330
+ }
1229
1331
  this.#clock = options.clock ?? (() => Date.now());
1332
+ this.#token = options.token !== void 0 && options.token.length > 0 ? options.token : void 0;
1333
+ this.#allowedOrigins = new Set((options.allowedOrigins ?? []).map(normalizeOrigin).filter((origin) => origin !== null));
1334
+ this.#maxMessagesPerSecond = options.maxMessagesPerSecond ?? TRANSPORT_LIMITS.MAX_MESSAGES_PER_SECOND;
1335
+ this.#maxSessions = options.maxSessions ?? TRANSPORT_LIMITS.MAX_SESSIONS;
1336
+ this.#maxPendingConnections = options.maxPendingConnections ?? TRANSPORT_LIMITS.MAX_PENDING_CONNECTIONS;
1337
+ this.#helloTimeoutMs = options.helloTimeoutMs ?? TRANSPORT_LIMITS.HELLO_TIMEOUT_MS;
1230
1338
  if (options.server !== void 0) {
1231
1339
  const srv = options.server;
1232
- this.#wss = new WebSocketServer({ server: srv, path: IRIS_WS_PATH });
1340
+ this.#wss = new WebSocketServer({
1341
+ server: srv,
1342
+ path: IRIS_WS_PATH,
1343
+ maxPayload: TRANSPORT_LIMITS.MAX_MESSAGE_BYTES,
1344
+ verifyClient: ({ origin }, done) => {
1345
+ const allowed = this.#originAllowed(origin);
1346
+ if (!allowed)
1347
+ log("origin_rejected", { origin: origin ?? "missing" });
1348
+ done(allowed, 403, "Forbidden");
1349
+ }
1350
+ });
1233
1351
  this.ready = new Promise((resolve) => {
1234
1352
  if (srv.listening) {
1235
1353
  resolve(srv.address().port);
@@ -1242,8 +1360,15 @@ var Bridge = class {
1242
1360
  } else {
1243
1361
  this.#wss = new WebSocketServer({
1244
1362
  port: options.port,
1245
- host: options.host ?? "127.0.0.1",
1246
- path: IRIS_WS_PATH
1363
+ host,
1364
+ path: IRIS_WS_PATH,
1365
+ maxPayload: TRANSPORT_LIMITS.MAX_MESSAGE_BYTES,
1366
+ verifyClient: ({ origin }, done) => {
1367
+ const allowed = this.#originAllowed(origin);
1368
+ if (!allowed)
1369
+ log("origin_rejected", { origin: origin ?? "missing" });
1370
+ done(allowed, 403, "Forbidden");
1371
+ }
1247
1372
  });
1248
1373
  this.ready = new Promise((resolve) => {
1249
1374
  this.#wss.on("listening", () => {
@@ -1256,14 +1381,64 @@ var Bridge = class {
1256
1381
  });
1257
1382
  }
1258
1383
  #onConnection(socket) {
1384
+ if (this.#pendingConnections >= this.#maxPendingConnections) {
1385
+ socket.close(1013, "too many pending handshakes");
1386
+ return;
1387
+ }
1388
+ this.#pendingConnections += 1;
1389
+ let awaitingHello = true;
1259
1390
  let session;
1391
+ let messageWindowStartedAt = this.#clock();
1392
+ let messagesInWindow = 0;
1393
+ const releasePending = () => {
1394
+ if (!awaitingHello)
1395
+ return;
1396
+ awaitingHello = false;
1397
+ this.#pendingConnections -= 1;
1398
+ };
1399
+ const helloTimer = setTimeout(() => {
1400
+ if (!awaitingHello)
1401
+ return;
1402
+ releasePending();
1403
+ socket.close(1008, "hello timeout");
1404
+ }, this.#helloTimeoutMs);
1260
1405
  socket.on("message", (raw) => {
1406
+ const now = this.#clock();
1407
+ if (now - messageWindowStartedAt >= 1e3) {
1408
+ messageWindowStartedAt = now;
1409
+ messagesInWindow = 0;
1410
+ }
1411
+ messagesInWindow += 1;
1412
+ if (messagesInWindow > this.#maxMessagesPerSecond) {
1413
+ log("message_rate_exceeded", {});
1414
+ socket.close(1008, "message rate exceeded");
1415
+ return;
1416
+ }
1261
1417
  const parsed = this.#parse(rawToString(raw));
1262
- if (parsed === null)
1418
+ if (parsed === null) {
1419
+ socket.close(1008, "invalid message");
1263
1420
  return;
1421
+ }
1264
1422
  if (parsed.kind === MessageKind.HELLO) {
1423
+ if (session !== void 0) {
1424
+ socket.close(1008, "hello already received");
1425
+ return;
1426
+ }
1427
+ if (this.#token !== void 0 && !tokensMatch(this.#token, parsed.token)) {
1428
+ log("authentication_failed", {});
1429
+ socket.close(1008, "authentication failed");
1430
+ return;
1431
+ }
1432
+ const existing = this.sessions.get(parsed.sessionId);
1433
+ if (existing === void 0 && this.sessions.count() >= this.#maxSessions) {
1434
+ socket.close(1013, "session limit reached");
1435
+ return;
1436
+ }
1437
+ clearTimeout(helloTimer);
1438
+ releasePending();
1265
1439
  session = new Session(parsed, socket, this.#clock);
1266
- this.sessions.add(session);
1440
+ const replaced = this.sessions.add(session);
1441
+ replaced?.disconnect("session replaced by a newer connection");
1267
1442
  log("session_connected", { sessionId: session.id, url: session.url });
1268
1443
  return;
1269
1444
  }
@@ -1277,15 +1452,28 @@ var Bridge = class {
1277
1452
  }
1278
1453
  });
1279
1454
  socket.on("close", () => {
1455
+ clearTimeout(helloTimer);
1456
+ releasePending();
1280
1457
  if (session !== void 0) {
1281
- this.sessions.remove(session.id);
1282
- log("session_disconnected", { sessionId: session.id });
1458
+ if (this.sessions.remove(session)) {
1459
+ log("session_disconnected", { sessionId: session.id });
1460
+ }
1283
1461
  }
1284
1462
  });
1285
1463
  socket.on("error", (err) => {
1286
1464
  log("socket_error", { error: err.message });
1287
1465
  });
1288
1466
  }
1467
+ #originAllowed(origin) {
1468
+ if (origin === void 0)
1469
+ return true;
1470
+ const normalized = normalizeOrigin(origin);
1471
+ if (normalized === null)
1472
+ return false;
1473
+ if (this.#allowedOrigins.has(normalized))
1474
+ return true;
1475
+ return isLoopbackHostname(new URL(normalized).hostname);
1476
+ }
1289
1477
  #parse(text) {
1290
1478
  let json;
1291
1479
  try {
@@ -1302,6 +1490,8 @@ var Bridge = class {
1302
1490
  }
1303
1491
  close() {
1304
1492
  return new Promise((resolve) => {
1493
+ for (const client of this.#wss.clients)
1494
+ client.terminate();
1305
1495
  this.#wss.close(() => {
1306
1496
  resolve();
1307
1497
  });
@@ -1416,6 +1606,7 @@ var IrisTool = {
1416
1606
  STATE: "iris_state",
1417
1607
  CAPABILITIES: "iris_capabilities",
1418
1608
  CONTRACT_SAVE: "iris_contract_save",
1609
+ DOMAIN: "iris_domain",
1419
1610
  FLOW_SAVE: "iris_flow_save",
1420
1611
  FLOW_LIST: "iris_flow_list",
1421
1612
  FLOW_LOAD: "iris_flow_load",
@@ -1458,189 +1649,558 @@ var IrisTool = {
1458
1649
  ROLLBACK: "iris_rollback"
1459
1650
  };
1460
1651
 
1461
- // ../server/dist/project/iris-dir.js
1462
- import { join } from "path";
1463
- function irisDirPaths(root) {
1464
- return {
1465
- root,
1466
- contract: join(root, IrisDir.CONTRACT_FILE),
1467
- flows: join(root, IrisDir.FLOWS_SUBDIR),
1468
- baselines: join(root, IrisDir.BASELINES_SUBDIR),
1469
- project: join(root, IrisDir.PROJECT_FILE),
1470
- visual: join(root, IrisDir.VISUAL_SUBDIR)
1471
- };
1472
- }
1473
- function visualPath(root, name) {
1474
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.png`);
1475
- }
1476
- function visualDiffPath(root, name) {
1477
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.diff.png`);
1478
- }
1479
- function flowPath(root, name) {
1480
- return join(root, IrisDir.FLOWS_SUBDIR, `${name}.json`);
1481
- }
1482
- function isValidFlowName(name) {
1483
- return FLOW_NAME_PATTERN.test(name) && !name.includes("..");
1484
- }
1485
- function baselinePath(root, name) {
1486
- return join(root, IrisDir.BASELINES_SUBDIR, `${name}.json`);
1487
- }
1488
- async function ensureIrisDir(fs2, root) {
1489
- const p = irisDirPaths(root);
1490
- await fs2.mkdir(p.root);
1491
- await fs2.mkdir(p.flows);
1492
- await fs2.mkdir(p.baselines);
1493
- }
1494
- var JSON_INDENT = 2;
1495
- function stableSerialize(capabilities, generatedAt) {
1496
- const envelope = {
1497
- version: CONTRACT_FILE_VERSION,
1498
- generatedAt,
1499
- capabilities: {
1500
- testids: [...capabilities.testids].sort(),
1501
- signals: [...capabilities.signals].sort(),
1502
- stores: [...capabilities.stores].sort(),
1503
- 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)
1652
+ // ../server/dist/tools/tools-helpers.js
1653
+ function parseInteractive(tree) {
1654
+ const items = [];
1655
+ for (const line of tree.split("\n")) {
1656
+ const match = /\(ref=(e\d+)\)/.exec(line);
1657
+ if (match !== null) {
1658
+ items.push({ ref: match[1] ?? "", desc: line.replace(/\s*\(ref=e\d+\)/, "").trim() });
1504
1659
  }
1505
- };
1506
- return `${JSON.stringify(envelope, null, JSON_INDENT)}
1507
- `;
1508
- }
1509
- async function writeContract(fs2, root, capabilities, now) {
1510
- await ensureIrisDir(fs2, root);
1511
- await fs2.writeFile(irisDirPaths(root).contract, stableSerialize(capabilities, now()));
1512
- }
1513
- async function readContract(fs2, root) {
1514
- const path = irisDirPaths(root).contract;
1515
- if (!await fs2.exists(path))
1516
- return { ok: false, reason: ContractReadError.MISSING };
1517
- let text;
1518
- try {
1519
- text = await fs2.readFile(path);
1520
- } catch (error) {
1521
- return {
1522
- ok: false,
1523
- reason: fs2.isNotFound(error) ? ContractReadError.MISSING : ContractReadError.MALFORMED
1524
- };
1525
1660
  }
1526
- let parsed;
1527
- try {
1528
- parsed = JSON.parse(text);
1529
- } catch {
1530
- return { ok: false, reason: ContractReadError.MALFORMED };
1531
- }
1532
- const result = ContractFileSchema.safeParse(parsed);
1533
- if (!result.success)
1534
- return { ok: false, reason: ContractReadError.MALFORMED };
1535
- return { ok: true, capabilities: result.data.capabilities, generatedAt: result.data.generatedAt };
1661
+ return items;
1536
1662
  }
1537
-
1538
- // ../server/dist/flows/flows.js
1539
1663
  function asString(value) {
1540
1664
  return typeof value === "string" ? value : void 0;
1541
1665
  }
1666
+ function asNumber(value) {
1667
+ return typeof value === "number" ? value : void 0;
1668
+ }
1542
1669
  function asRecord(value) {
1543
1670
  return typeof value === "object" && value !== null ? value : {};
1544
1671
  }
1545
- function degradedAnchor() {
1546
- return { kind: AnchorKind.ROLE, role: DEGRADED_ANCHOR_ROLE };
1672
+
1673
+ // ../server/dist/flows/replay.js
1674
+ function asString2(value) {
1675
+ return typeof value === "string" ? value : void 0;
1547
1676
  }
1548
- function subStepToFlowStep(raw) {
1549
- const sub = asRecord(raw);
1550
- const by = asString(sub["by"]);
1551
- const value = asString(sub["value"]);
1552
- const action = asString(sub["action"]);
1553
- const args = asRecord(sub["args"]);
1554
- if (by === QueryBy.TESTID && value !== void 0) {
1555
- return buildStep(IrisTool.ACT, { kind: AnchorKind.TESTID, value }, action, args, false);
1556
- }
1557
- return buildStep(IrisTool.ACT, degradedAnchor(), action, args, true);
1677
+ function asRecord2(value) {
1678
+ return typeof value === "object" && value !== null ? value : {};
1558
1679
  }
1559
- function buildStep(tool, anchor, action, args, degraded) {
1560
- const step = { tool, anchor, args };
1561
- if (action !== void 0)
1562
- step.action = action;
1563
- if (degraded)
1564
- step.degraded = true;
1565
- return step;
1680
+ function replayActionArgs(value, confirmDangerous = false) {
1681
+ const args = { ...asRecord2(value) };
1682
+ delete args[DANGEROUS_ACTION_CONFIRM_ARG];
1683
+ if (confirmDangerous)
1684
+ args[DANGEROUS_ACTION_CONFIRM_ARG] = true;
1685
+ return args;
1566
1686
  }
1567
- function recordedStepToFlowStep(step) {
1568
- if (step.tool === IrisTool.ACT_SEQUENCE) {
1569
- const rawSubs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
1570
- const subs = rawSubs.map(subStepToFlowStep);
1571
- const degraded = subs.some((s) => s.degraded === true);
1572
- const anchor = subs[0]?.anchor ?? degradedAnchor();
1573
- const out2 = { tool: IrisTool.ACT_SEQUENCE, anchor, steps: subs };
1574
- if (degraded)
1575
- out2.degraded = true;
1576
- if (step.expect !== void 0)
1577
- out2.expect = step.expect;
1578
- return out2;
1687
+ function compileActStep(args, res) {
1688
+ const testid = asString2(asRecord2(res)["testid"]);
1689
+ const action = asString2(args["action"]) ?? "";
1690
+ const actArgs = replayActionArgs(args["args"]);
1691
+ if (testid !== void 0) {
1692
+ return {
1693
+ tool: IrisTool.ACT,
1694
+ stable: true,
1695
+ args: { by: QueryBy.TESTID, value: testid, action, args: actArgs }
1696
+ };
1579
1697
  }
1580
- const by = asString(step.args["by"]);
1581
- const value = asString(step.args["value"]);
1582
- const action = asString(step.args["action"]);
1583
- const args = asRecord(step.args["args"]);
1584
- 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);
1585
- if (step.expect !== void 0)
1586
- out.expect = step.expect;
1587
- return out;
1698
+ return {
1699
+ tool: IrisTool.ACT,
1700
+ stable: false,
1701
+ args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
1702
+ };
1588
1703
  }
1589
- function withAnnotations(flow, ann) {
1590
- if (ann === void 0)
1591
- return flow;
1592
- const steps = flow.steps.map((step, i) => {
1593
- const expect = ann.stepExpect.get(i);
1594
- return expect === void 0 ? step : { ...step, expect };
1704
+ function compileSequenceStep(args, res) {
1705
+ const inputSteps = Array.isArray(args["steps"]) ? args["steps"] : [];
1706
+ const resolved = Array.isArray(asRecord2(res)["steps"]) ? asRecord2(res)["steps"] : [];
1707
+ let stable = inputSteps.length > 0;
1708
+ const subSteps = inputSteps.map((raw, i) => {
1709
+ const step = asRecord2(raw);
1710
+ const action = asString2(step["action"]) ?? "";
1711
+ const stepArgs = replayActionArgs(step["args"]);
1712
+ const testid = asString2(asRecord2(resolved[i])["testid"]);
1713
+ if (testid !== void 0) {
1714
+ return { by: QueryBy.TESTID, value: testid, action, args: stepArgs };
1715
+ }
1716
+ stable = false;
1717
+ return { ref: asString2(step["ref"]) ?? "", action, args: stepArgs };
1595
1718
  });
1596
- const out = { ...flow, steps };
1597
- if (ann.dynamic.length > 0) {
1598
- out.dynamic = ann.dynamic.map((value) => ({ kind: AnchorKind.TESTID, value }));
1599
- }
1600
- if (ann.success !== void 0)
1601
- out.success = ann.success;
1602
- return out;
1719
+ return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
1603
1720
  }
1604
- var JSON_INDENT2 = 2;
1605
- var FLOW_SUFFIX = ".json";
1606
- var FlowStore = class {
1607
- #fs;
1608
- #root;
1609
- #clock;
1610
- constructor(fs2, root, clock) {
1611
- this.#fs = fs2;
1612
- this.#root = root;
1613
- this.#clock = clock;
1614
- }
1615
- /**
1616
- * The single byte-stable flow serializer: 2-space indent + one trailing newline. save(),
1617
- * saveFlow() and heal() all route through it so an unchanged flow that round-trips through any
1618
- * of them produces byte-identical on-disk content (locked by the byte-stability tests).
1619
- */
1620
- #serialize(flow) {
1621
- return `${JSON.stringify(flow, null, JSON_INDENT2)}
1622
- `;
1721
+ async function resolveRef(session, step) {
1722
+ const by = asString2(step.by);
1723
+ const value = asString2(step.value);
1724
+ if (by === QueryBy.TESTID && value !== void 0) {
1725
+ const result = await session.command(IrisCommand.QUERY, { by, value });
1726
+ if (!result.ok)
1727
+ throw new Error(result.error ?? "query failed");
1728
+ const elements = Array.isArray(asRecord2(result.result)["elements"]) ? asRecord2(result.result)["elements"] : [];
1729
+ const ref2 = asString2(asRecord2(elements[0])["ref"]);
1730
+ if (ref2 === void 0)
1731
+ throw new Error(`testid '${value}' did not resolve in current page`);
1732
+ return elements.length > 1 ? { ref: ref2, note: `ambiguous testid '${value}', used first match` } : { ref: ref2 };
1623
1733
  }
1624
- /**
1625
- * Convert a CompiledProgram (testid-normalized) into an anchored, on-disk flow + write it.
1626
- * Optionally fold structured annotations (per-step expect, dynamic[], success) onto
1627
- * the flow before writing. Omitting `annotations` reproduces the same bytes.
1628
- */
1629
- async save(program, annotations) {
1630
- if (!isValidFlowName(program.name)) {
1631
- return { ok: false, code: FlowErrorCode.INVALID_NAME };
1632
- }
1633
- const steps = program.steps.map(recordedStepToFlowStep);
1634
- const base = {
1635
- version: FLOW_FILE_VERSION,
1636
- name: program.name,
1637
- createdAt: this.#clock.now(),
1638
- steps
1639
- };
1640
- const flow = withAnnotations(base, annotations);
1641
- await this.#fs.mkdir(irisDirPaths(this.#root).flows);
1642
- await this.#fs.writeFile(flowPath(this.#root, program.name), this.#serialize(flow));
1643
- const degraded = flow.steps.filter((s) => s.degraded === true).length;
1734
+ const ref = asString2(step.ref);
1735
+ if (ref === void 0 || ref.length === 0)
1736
+ throw new Error("step has no testid or ref to resolve");
1737
+ return { ref, note: "replayed by stale ref (not portable across sessions)" };
1738
+ }
1739
+ async function replayProgram(session, program, confirmDangerous = false) {
1740
+ const results = [];
1741
+ for (const step of program.steps) {
1742
+ try {
1743
+ if (step.tool === IrisTool.ACT_SEQUENCE) {
1744
+ const subs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
1745
+ const notes = [];
1746
+ const liveSteps = [];
1747
+ for (const raw of subs) {
1748
+ const sub = asRecord2(raw);
1749
+ const { ref, note } = await resolveRef(session, sub);
1750
+ if (note !== void 0)
1751
+ notes.push(note);
1752
+ liveSteps.push({
1753
+ ref,
1754
+ action: asString2(sub["action"]) ?? "",
1755
+ args: replayActionArgs(sub["args"], confirmDangerous)
1756
+ });
1757
+ }
1758
+ const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps });
1759
+ results.push(buildResult(step.tool, r.ok, r.error, notes));
1760
+ if (!r.ok)
1761
+ break;
1762
+ } else {
1763
+ const { ref, note } = await resolveRef(session, step.args);
1764
+ const r = await session.command(IrisCommand.ACT, {
1765
+ ref,
1766
+ action: asString2(step.args["action"]) ?? "",
1767
+ args: replayActionArgs(step.args["args"], confirmDangerous)
1768
+ });
1769
+ results.push(buildResult(step.tool, r.ok, r.error, note !== void 0 ? [note] : []));
1770
+ if (!r.ok)
1771
+ break;
1772
+ }
1773
+ } catch (e) {
1774
+ results.push({
1775
+ tool: step.tool,
1776
+ ok: false,
1777
+ error: e instanceof Error ? e.message : String(e)
1778
+ });
1779
+ break;
1780
+ }
1781
+ }
1782
+ return results;
1783
+ }
1784
+ function buildResult(tool, ok, error, notes) {
1785
+ const base = { tool, ok };
1786
+ if (!ok)
1787
+ base.error = error ?? "command failed";
1788
+ if (notes.length > 0)
1789
+ base.note = notes.join("; ");
1790
+ return base;
1791
+ }
1792
+
1793
+ // ../server/dist/flows/flow-replay.js
1794
+ function editDistance(a, b) {
1795
+ const s = a.toLowerCase();
1796
+ const t = b.toLowerCase();
1797
+ const rows = s.length + 1;
1798
+ const cols = t.length + 1;
1799
+ const prev = new Array(cols);
1800
+ const curr = new Array(cols);
1801
+ for (let j = 0; j < cols; j++)
1802
+ prev[j] = j;
1803
+ for (let i = 1; i < rows; i++) {
1804
+ curr[0] = i;
1805
+ for (let j = 1; j < cols; j++) {
1806
+ const cost = s[i - 1] === t[j - 1] ? 0 : 1;
1807
+ curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
1808
+ }
1809
+ for (let j = 0; j < cols; j++)
1810
+ prev[j] = curr[j] ?? 0;
1811
+ }
1812
+ return prev[cols - 1] ?? 0;
1813
+ }
1814
+ function nearestTestid(missing, present) {
1815
+ let best = null;
1816
+ let bestDistance = Number.POSITIVE_INFINITY;
1817
+ for (const candidate of present) {
1818
+ const distance = editDistance(missing, candidate);
1819
+ if (distance < bestDistance || distance === bestDistance && best !== null && candidate.length < best.length || distance === bestDistance && best !== null && candidate.length === best.length && candidate < best) {
1820
+ best = candidate;
1821
+ bestDistance = distance;
1822
+ }
1823
+ }
1824
+ return best;
1825
+ }
1826
+ function readQuery(result) {
1827
+ if (!result.ok)
1828
+ return { refs: [] };
1829
+ const payload = asRecord(result.result);
1830
+ const elements = Array.isArray(payload["elements"]) ? payload["elements"] : [];
1831
+ const refs = elements.map((e) => asString(asRecord(e)["ref"]) ?? "").filter((r) => r.length > 0);
1832
+ const rawHint = payload["hint"];
1833
+ if (typeof rawHint === "object" && rawHint !== null) {
1834
+ const hint = asRecord(rawHint);
1835
+ const present = Array.isArray(hint["presentTestids"]) ? hint["presentTestids"].filter((t) => typeof t === "string") : [];
1836
+ return {
1837
+ refs,
1838
+ hint: {
1839
+ route: asString(hint["route"]) ?? "",
1840
+ presentTestids: present,
1841
+ presentRegions: [],
1842
+ knownEmptyState: hint["knownEmptyState"] === true
1843
+ }
1844
+ };
1845
+ }
1846
+ return { refs };
1847
+ }
1848
+ function nearestIsAmbiguous(missing, present) {
1849
+ if (present.length < 2)
1850
+ return false;
1851
+ let min = Number.POSITIVE_INFINITY;
1852
+ let count = 0;
1853
+ for (const candidate of present) {
1854
+ const distance = editDistance(missing, candidate);
1855
+ if (distance < min) {
1856
+ min = distance;
1857
+ count = 1;
1858
+ } else if (distance === min) {
1859
+ count += 1;
1860
+ }
1861
+ }
1862
+ return count >= 2;
1863
+ }
1864
+ function testidDrift(value, hint) {
1865
+ const present = hint?.presentTestids ?? [];
1866
+ const drift = {
1867
+ reasonKind: DriftReason.TESTID_NOT_FOUND,
1868
+ reason: `testid "${value}" not found`,
1869
+ anchor: value,
1870
+ nearest: nearestTestid(value, present)
1871
+ };
1872
+ if (nearestIsAmbiguous(value, present))
1873
+ drift.ambiguous = true;
1874
+ return drift;
1875
+ }
1876
+ function anchorLabel(anchor) {
1877
+ if (anchor.kind === AnchorKind.TESTID)
1878
+ return anchor.value;
1879
+ if (anchor.kind === AnchorKind.SIGNAL)
1880
+ return anchor.name;
1881
+ return anchor.name ?? anchor.role;
1882
+ }
1883
+ async function runTestidStep(session, step, index, value, dynamic, confirmDangerous) {
1884
+ const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value });
1885
+ const { refs, hint } = readQuery(queryResult);
1886
+ if (refs.length === 0) {
1887
+ return {
1888
+ step: index,
1889
+ tool: step.tool,
1890
+ anchor: value,
1891
+ ok: false,
1892
+ drift: testidDrift(value, hint)
1893
+ };
1894
+ }
1895
+ const ref = refs[0] ?? "";
1896
+ const note = refs.length > 1 ? `ambiguous testid '${value}', used first match` : void 0;
1897
+ const act = await session.command(IrisCommand.ACT, {
1898
+ ref,
1899
+ action: step.action ?? "",
1900
+ args: replayActionArgs(step.args, confirmDangerous)
1901
+ });
1902
+ const result = { step: index, tool: step.tool, anchor: value, ok: act.ok };
1903
+ if (!act.ok) {
1904
+ result.error = act.error ?? "command failed";
1905
+ if (note !== void 0)
1906
+ result.note = note;
1907
+ return result;
1908
+ }
1909
+ const expectTestid = step.expect?.element?.testid;
1910
+ if (expectTestid !== void 0 && !dynamic.has(expectTestid)) {
1911
+ const expectQuery = await session.command(IrisCommand.QUERY, {
1912
+ by: QueryBy.TESTID,
1913
+ value: expectTestid
1914
+ });
1915
+ const expectRefs = readQuery(expectQuery);
1916
+ if (expectRefs.refs.length === 0) {
1917
+ return {
1918
+ step: index,
1919
+ tool: step.tool,
1920
+ anchor: expectTestid,
1921
+ ok: false,
1922
+ drift: testidDrift(expectTestid, expectRefs.hint)
1923
+ };
1924
+ }
1925
+ }
1926
+ if (note !== void 0)
1927
+ result.note = note;
1928
+ return result;
1929
+ }
1930
+ async function runSignalStep(session, step, index, name, waitForSignal, signalTimeoutMs) {
1931
+ const verdict = await waitForSignal(session, { kind: "signal", name }, signalTimeoutMs);
1932
+ if (verdict.pass)
1933
+ return { step: index, tool: step.tool, anchor: name, ok: true };
1934
+ return {
1935
+ step: index,
1936
+ tool: step.tool,
1937
+ anchor: name,
1938
+ ok: false,
1939
+ drift: {
1940
+ reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
1941
+ reason: `signal "${name}" not observed`,
1942
+ anchor: name,
1943
+ nearest: null
1944
+ }
1945
+ };
1946
+ }
1947
+ async function replayFlow(session, flow, waitForSignal, signalTimeoutMs, confirmDangerous = false) {
1948
+ const results = [];
1949
+ const dynamic = new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
1950
+ let index = 0;
1951
+ for (const step of flow.steps) {
1952
+ const label = anchorLabel(step.anchor);
1953
+ let result;
1954
+ if (step.anchor.kind === AnchorKind.SIGNAL) {
1955
+ result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs);
1956
+ } else {
1957
+ result = await runTestidStep(session, step, index, label, dynamic, confirmDangerous);
1958
+ }
1959
+ results.push(result);
1960
+ if (result.drift !== void 0 || !result.ok)
1961
+ break;
1962
+ index += 1;
1963
+ }
1964
+ return results;
1965
+ }
1966
+
1967
+ // ../server/dist/flows/heal.js
1968
+ function confidenceFor(from, to) {
1969
+ if (from === to)
1970
+ return 1;
1971
+ const span = Math.max(from.length, to.length);
1972
+ if (span === 0)
1973
+ return 1;
1974
+ const raw = 1 - editDistance(from, to) / span;
1975
+ if (raw >= 1)
1976
+ return 1;
1977
+ if (raw <= 0)
1978
+ return Number.EPSILON;
1979
+ return raw;
1980
+ }
1981
+ function applyHealChanges(flow, changes) {
1982
+ const byStep = /* @__PURE__ */ new Map();
1983
+ for (const change of changes)
1984
+ byStep.set(change.step, change);
1985
+ const applied = [];
1986
+ const steps = flow.steps.map((step, index) => {
1987
+ const change = byStep.get(index);
1988
+ if (change === void 0 || step.anchor.kind !== AnchorKind.TESTID || step.anchor.value !== change.from) {
1989
+ return step;
1990
+ }
1991
+ applied.push(change);
1992
+ return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
1993
+ });
1994
+ return { flow: { ...flow, steps }, applied };
1995
+ }
1996
+ function proposeRebindWith(drift, step, minConfidence) {
1997
+ if (drift.reasonKind !== DriftReason.TESTID_NOT_FOUND)
1998
+ return void 0;
1999
+ if (drift.ambiguous === true)
2000
+ return void 0;
2001
+ const to = drift.nearest;
2002
+ if (to === null)
2003
+ return void 0;
2004
+ const confidence = confidenceFor(drift.anchor, to);
2005
+ if (confidence < minConfidence)
2006
+ return void 0;
2007
+ return { step, from: drift.anchor, to, confidence };
2008
+ }
2009
+ function collectProposals(steps, minConfidence = HEAL_CONFIDENCE_MIN) {
2010
+ const proposals = [];
2011
+ for (const step of steps) {
2012
+ if (step.drift === void 0)
2013
+ continue;
2014
+ const proposal = proposeRebindWith(step.drift, step.step, minConfidence);
2015
+ if (proposal !== void 0)
2016
+ proposals.push(proposal);
2017
+ }
2018
+ return proposals;
2019
+ }
2020
+
2021
+ // ../server/dist/project/iris-dir.js
2022
+ import { join } from "path";
2023
+ function irisDirPaths(root) {
2024
+ return {
2025
+ root,
2026
+ contract: join(root, IrisDir.CONTRACT_FILE),
2027
+ flows: join(root, IrisDir.FLOWS_SUBDIR),
2028
+ baselines: join(root, IrisDir.BASELINES_SUBDIR),
2029
+ project: join(root, IrisDir.PROJECT_FILE),
2030
+ visual: join(root, IrisDir.VISUAL_SUBDIR)
2031
+ };
2032
+ }
2033
+ function visualPath(root, name) {
2034
+ return join(root, IrisDir.VISUAL_SUBDIR, `${name}.png`);
2035
+ }
2036
+ function visualDiffPath(root, name) {
2037
+ return join(root, IrisDir.VISUAL_SUBDIR, `${name}.diff.png`);
2038
+ }
2039
+ function flowPath(root, name) {
2040
+ return join(root, IrisDir.FLOWS_SUBDIR, `${name}.json`);
2041
+ }
2042
+ function isValidFlowName(name) {
2043
+ return FLOW_NAME_PATTERN.test(name) && !name.includes("..");
2044
+ }
2045
+ function baselinePath(root, name) {
2046
+ return join(root, IrisDir.BASELINES_SUBDIR, `${name}.json`);
2047
+ }
2048
+ async function ensureIrisDir(fs2, root) {
2049
+ const p = irisDirPaths(root);
2050
+ await fs2.mkdir(p.root);
2051
+ await fs2.mkdir(p.flows);
2052
+ await fs2.mkdir(p.baselines);
2053
+ }
2054
+ var JSON_INDENT = 2;
2055
+ function stableSerialize(capabilities, generatedAt) {
2056
+ const envelope = {
2057
+ version: CONTRACT_FILE_VERSION,
2058
+ generatedAt,
2059
+ capabilities: {
2060
+ testids: [...capabilities.testids].sort(),
2061
+ signals: [...capabilities.signals].sort(),
2062
+ stores: [...capabilities.stores].sort(),
2063
+ 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)
2064
+ }
2065
+ };
2066
+ return `${JSON.stringify(envelope, null, JSON_INDENT)}
2067
+ `;
2068
+ }
2069
+ async function writeContract(fs2, root, capabilities, now) {
2070
+ await ensureIrisDir(fs2, root);
2071
+ await fs2.writeFile(irisDirPaths(root).contract, stableSerialize(capabilities, now()));
2072
+ }
2073
+ async function readContract(fs2, root) {
2074
+ const path = irisDirPaths(root).contract;
2075
+ if (!await fs2.exists(path))
2076
+ return { ok: false, reason: ContractReadError.MISSING };
2077
+ let text;
2078
+ try {
2079
+ text = await fs2.readFile(path);
2080
+ } catch (error) {
2081
+ return {
2082
+ ok: false,
2083
+ reason: fs2.isNotFound(error) ? ContractReadError.MISSING : ContractReadError.MALFORMED
2084
+ };
2085
+ }
2086
+ let parsed;
2087
+ try {
2088
+ parsed = JSON.parse(text);
2089
+ } catch {
2090
+ return { ok: false, reason: ContractReadError.MALFORMED };
2091
+ }
2092
+ const result = ContractFileSchema.safeParse(parsed);
2093
+ if (!result.success)
2094
+ return { ok: false, reason: ContractReadError.MALFORMED };
2095
+ return { ok: true, capabilities: result.data.capabilities, generatedAt: result.data.generatedAt };
2096
+ }
2097
+
2098
+ // ../server/dist/flows/flows.js
2099
+ function asString3(value) {
2100
+ return typeof value === "string" ? value : void 0;
2101
+ }
2102
+ function asRecord3(value) {
2103
+ return typeof value === "object" && value !== null ? value : {};
2104
+ }
2105
+ function degradedAnchor() {
2106
+ return { kind: AnchorKind.ROLE, role: DEGRADED_ANCHOR_ROLE };
2107
+ }
2108
+ function subStepToFlowStep(raw) {
2109
+ const sub = asRecord3(raw);
2110
+ const by = asString3(sub["by"]);
2111
+ const value = asString3(sub["value"]);
2112
+ const action = asString3(sub["action"]);
2113
+ const args = asRecord3(sub["args"]);
2114
+ if (by === QueryBy.TESTID && value !== void 0) {
2115
+ return buildStep(IrisTool.ACT, { kind: AnchorKind.TESTID, value }, action, args, false);
2116
+ }
2117
+ return buildStep(IrisTool.ACT, degradedAnchor(), action, args, true);
2118
+ }
2119
+ function buildStep(tool, anchor, action, args, degraded) {
2120
+ const step = { tool, anchor, args };
2121
+ if (action !== void 0)
2122
+ step.action = action;
2123
+ if (degraded)
2124
+ step.degraded = true;
2125
+ return step;
2126
+ }
2127
+ function recordedStepToFlowStep(step) {
2128
+ if (step.tool === IrisTool.ACT_SEQUENCE) {
2129
+ const rawSubs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
2130
+ const subs = rawSubs.map(subStepToFlowStep);
2131
+ const degraded = subs.some((s) => s.degraded === true);
2132
+ const anchor = subs[0]?.anchor ?? degradedAnchor();
2133
+ const out2 = { tool: IrisTool.ACT_SEQUENCE, anchor, steps: subs };
2134
+ if (degraded)
2135
+ out2.degraded = true;
2136
+ if (step.expect !== void 0)
2137
+ out2.expect = step.expect;
2138
+ return out2;
2139
+ }
2140
+ const by = asString3(step.args["by"]);
2141
+ const value = asString3(step.args["value"]);
2142
+ const action = asString3(step.args["action"]);
2143
+ const args = asRecord3(step.args["args"]);
2144
+ 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);
2145
+ if (step.expect !== void 0)
2146
+ out.expect = step.expect;
2147
+ return out;
2148
+ }
2149
+ function withAnnotations(flow, ann) {
2150
+ if (ann === void 0)
2151
+ return flow;
2152
+ const steps = flow.steps.map((step, i) => {
2153
+ const expect = ann.stepExpect.get(i);
2154
+ return expect === void 0 ? step : { ...step, expect };
2155
+ });
2156
+ const out = { ...flow, steps };
2157
+ if (ann.dynamic.length > 0) {
2158
+ out.dynamic = ann.dynamic.map((value) => ({ kind: AnchorKind.TESTID, value }));
2159
+ }
2160
+ if (ann.success !== void 0)
2161
+ out.success = ann.success;
2162
+ return out;
2163
+ }
2164
+ var JSON_INDENT2 = 2;
2165
+ var FLOW_SUFFIX = ".json";
2166
+ var FlowStore = class {
2167
+ #fs;
2168
+ #root;
2169
+ #clock;
2170
+ constructor(fs2, root, clock) {
2171
+ this.#fs = fs2;
2172
+ this.#root = root;
2173
+ this.#clock = clock;
2174
+ }
2175
+ /**
2176
+ * The single byte-stable flow serializer: 2-space indent + one trailing newline. save(),
2177
+ * saveFlow() and heal() all route through it so an unchanged flow that round-trips through any
2178
+ * of them produces byte-identical on-disk content (locked by the byte-stability tests).
2179
+ */
2180
+ #serialize(flow) {
2181
+ return `${JSON.stringify(flow, null, JSON_INDENT2)}
2182
+ `;
2183
+ }
2184
+ /**
2185
+ * Convert a CompiledProgram (testid-normalized) into an anchored, on-disk flow + write it.
2186
+ * Optionally fold structured annotations (per-step expect, dynamic[], success) onto
2187
+ * the flow before writing. Omitting `annotations` reproduces the same bytes.
2188
+ */
2189
+ async save(program, annotations) {
2190
+ if (!isValidFlowName(program.name)) {
2191
+ return { ok: false, code: FlowErrorCode.INVALID_NAME };
2192
+ }
2193
+ const steps = program.steps.map(recordedStepToFlowStep);
2194
+ const base = {
2195
+ version: FLOW_FILE_VERSION,
2196
+ name: program.name,
2197
+ createdAt: this.#clock.now(),
2198
+ steps
2199
+ };
2200
+ const flow = withAnnotations(base, annotations);
2201
+ await this.#fs.mkdir(irisDirPaths(this.#root).flows);
2202
+ await this.#fs.writeFile(flowPath(this.#root, program.name), this.#serialize(flow));
2203
+ const degraded = flow.steps.filter((s) => s.degraded === true).length;
1644
2204
  return {
1645
2205
  ok: true,
1646
2206
  value: {
@@ -1694,19 +2254,7 @@ var FlowStore = class {
1694
2254
  if (!loaded.ok)
1695
2255
  return { ok: false, code: loaded.code };
1696
2256
  const flow = loaded.value;
1697
- const byStep = /* @__PURE__ */ new Map();
1698
- for (const change of changes)
1699
- byStep.set(change.step, change);
1700
- const applied = [];
1701
- const steps = flow.steps.map((step, index) => {
1702
- const change = byStep.get(index);
1703
- if (change === void 0 || step.anchor.kind !== AnchorKind.TESTID || step.anchor.value !== change.from) {
1704
- return step;
1705
- }
1706
- applied.push(change);
1707
- return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
1708
- });
1709
- const next = { ...flow, steps };
2257
+ const { flow: next, applied } = applyHealChanges(flow, changes);
1710
2258
  await this.#fs.writeFile(flowPath(this.#root, name), this.#serialize(next));
1711
2259
  return { ok: true, value: { name, changed: applied } };
1712
2260
  }
@@ -1934,10 +2482,10 @@ function createNodeFileSystem() {
1934
2482
 
1935
2483
  // ../server/dist/mcp.js
1936
2484
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1937
- import { z as z16 } from "zod";
2485
+ import { z as z17 } from "zod";
1938
2486
 
1939
2487
  // ../server/dist/tools/tools.js
1940
- import { z as z15 } from "zod";
2488
+ import { z as z16 } from "zod";
1941
2489
 
1942
2490
  // ../server/dist/input/real-input.js
1943
2491
  var DriveError = class extends Error {
@@ -1998,8 +2546,10 @@ async function performGesture(page, action, box, args, sleep) {
1998
2546
  }
1999
2547
  return { performed: false, center };
2000
2548
  }
2549
+ var HIDE_IRIS_CHROME_CSS = "[data-iris-overlay]{display:none !important}";
2550
+ var SCREENSHOT_DETERMINISM = { style: HIDE_IRIS_CHROME_CSS, animations: "disabled" };
2001
2551
  async function capturePage(page, opts) {
2002
- const buf = await page.screenshot(opts.clip !== void 0 ? { clip: opts.clip } : opts.fullPage === true ? { fullPage: true } : {});
2552
+ const buf = await page.screenshot(opts.clip !== void 0 ? { ...SCREENSHOT_DETERMINISM, clip: opts.clip } : opts.fullPage === true ? { ...SCREENSHOT_DETERMINISM, fullPage: true } : { ...SCREENSHOT_DETERMINISM });
2003
2553
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
2004
2554
  }
2005
2555
  var nodeSleep = (ms) => new Promise((resolve) => {
@@ -2234,6 +2784,7 @@ var PredicateSchema = z3.lazy(() => z3.discriminatedUnion("kind", [
2234
2784
  name: z3.string().optional(),
2235
2785
  dataMatches: z3.record(z3.unknown()).optional()
2236
2786
  }),
2787
+ z3.object({ kind: z3.literal("settled"), quietMs: z3.number().positive().optional() }),
2237
2788
  z3.object({ kind: z3.literal("allOf"), predicates: z3.array(PredicateSchema) }),
2238
2789
  z3.object({ kind: z3.literal("anyOf"), predicates: z3.array(PredicateSchema) }),
2239
2790
  z3.object({ kind: z3.literal("not"), predicate: PredicateSchema })
@@ -2430,6 +2981,39 @@ function evalSignal(events, p) {
2430
2981
  evidence: sameName.length > 0 ? { nearMiss: sameName } : void 0
2431
2982
  };
2432
2983
  }
2984
+ var SETTLE_ACTIVITY = /* @__PURE__ */ new Set([
2985
+ EventType.NET_REQUEST,
2986
+ EventType.DOM_ADDED,
2987
+ EventType.DOM_REMOVED,
2988
+ EventType.DOM_ATTR
2989
+ ]);
2990
+ var DEFAULT_QUIET_MS = 500;
2991
+ function evalSettled(events, p, now) {
2992
+ const quietMs = p.quietMs ?? DEFAULT_QUIET_MS;
2993
+ let lastT = -1;
2994
+ let lastType;
2995
+ for (const e of events) {
2996
+ if (SETTLE_ACTIVITY.has(e.type) && e.t > lastT) {
2997
+ lastT = e.t;
2998
+ lastType = e.type;
2999
+ }
3000
+ }
3001
+ if (lastT < 0) {
3002
+ return {
3003
+ pass: true,
3004
+ evidence: { settled: true, quietForMs: null, note: "no activity to settle" }
3005
+ };
3006
+ }
3007
+ const quietForMs = now - lastT;
3008
+ if (quietForMs >= quietMs) {
3009
+ return { pass: true, evidence: { settled: true, quietForMs, lastActivity: lastType } };
3010
+ }
3011
+ return {
3012
+ pass: false,
3013
+ failureReason: `not settled: last activity (${String(lastType)}) ${String(quietForMs)}ms ago, need ${String(quietMs)}ms quiet`,
3014
+ evidence: { quietForMs, lastActivity: lastType }
3015
+ };
3016
+ }
2433
3017
  async function evaluatePredicate(session, predicate, since = 0) {
2434
3018
  const events = session.eventsSince(since);
2435
3019
  switch (predicate.kind) {
@@ -2447,6 +3031,8 @@ async function evaluatePredicate(session, predicate, since = 0) {
2447
3031
  return evalAnimation(events, predicate);
2448
3032
  case "signal":
2449
3033
  return evalSignal(events, predicate);
3034
+ case "settled":
3035
+ return evalSettled(events, predicate, session.elapsed());
2450
3036
  case "allOf": {
2451
3037
  const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since)));
2452
3038
  const failed = results.find((r) => !r.pass);
@@ -2472,6 +3058,10 @@ async function evaluatePredicate(session, predicate, since = 0) {
2472
3058
  function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2473
3059
  return new Promise((resolve) => {
2474
3060
  let done = false;
3061
+ const failed = (error) => ({
3062
+ pass: false,
3063
+ failureReason: error instanceof Error ? error.message : String(error)
3064
+ });
2475
3065
  const finish = (result) => {
2476
3066
  if (done)
2477
3067
  return;
@@ -2485,6 +3075,8 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2485
3075
  void evaluatePredicate(session, predicate, since).then((r) => {
2486
3076
  if (r.pass)
2487
3077
  finish(r);
3078
+ }).catch((error) => {
3079
+ finish(failed(error));
2488
3080
  });
2489
3081
  };
2490
3082
  const unsub = session.onEvent(() => {
@@ -2498,141 +3090,30 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2498
3090
  evidence: r.evidence,
2499
3091
  failureReason: r.failureReason ?? "timed out waiting for predicate"
2500
3092
  });
3093
+ }).catch((error) => {
3094
+ finish(failed(error));
2501
3095
  });
2502
3096
  }, timeoutMs);
2503
3097
  check();
2504
3098
  });
2505
3099
  }
2506
3100
 
2507
- // ../server/dist/flows/replay.js
2508
- function asString2(value) {
2509
- return typeof value === "string" ? value : void 0;
2510
- }
2511
- function asRecord2(value) {
2512
- return typeof value === "object" && value !== null ? value : {};
2513
- }
2514
- function compileActStep(args, res) {
2515
- const testid = asString2(asRecord2(res)["testid"]);
2516
- const action = asString2(args["action"]) ?? "";
2517
- const actArgs = asRecord2(args["args"]);
2518
- if (testid !== void 0) {
2519
- return {
2520
- tool: IrisTool.ACT,
2521
- stable: true,
2522
- args: { by: QueryBy.TESTID, value: testid, action, args: actArgs }
2523
- };
2524
- }
2525
- return {
2526
- tool: IrisTool.ACT,
2527
- stable: false,
2528
- args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
2529
- };
2530
- }
2531
- function compileSequenceStep(args, res) {
2532
- const inputSteps = Array.isArray(args["steps"]) ? args["steps"] : [];
2533
- const resolved = Array.isArray(asRecord2(res)["steps"]) ? asRecord2(res)["steps"] : [];
2534
- let stable = inputSteps.length > 0;
2535
- const subSteps = inputSteps.map((raw, i) => {
2536
- const step = asRecord2(raw);
2537
- const action = asString2(step["action"]) ?? "";
2538
- const stepArgs = asRecord2(step["args"]);
2539
- const testid = asString2(asRecord2(resolved[i])["testid"]);
2540
- if (testid !== void 0) {
2541
- return { by: QueryBy.TESTID, value: testid, action, args: stepArgs };
2542
- }
2543
- stable = false;
2544
- return { ref: asString2(step["ref"]) ?? "", action, args: stepArgs };
2545
- });
2546
- return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
2547
- }
2548
- async function resolveRef(session, step) {
2549
- const by = asString2(step.by);
2550
- const value = asString2(step.value);
2551
- if (by === QueryBy.TESTID && value !== void 0) {
2552
- const result = await session.command(IrisCommand.QUERY, { by, value });
2553
- if (!result.ok)
2554
- throw new Error(result.error ?? "query failed");
2555
- const elements = Array.isArray(asRecord2(result.result)["elements"]) ? asRecord2(result.result)["elements"] : [];
2556
- const ref2 = asString2(asRecord2(elements[0])["ref"]);
2557
- if (ref2 === void 0)
2558
- throw new Error(`testid '${value}' did not resolve in current page`);
2559
- return elements.length > 1 ? { ref: ref2, note: `ambiguous testid '${value}', used first match` } : { ref: ref2 };
2560
- }
2561
- const ref = asString2(step.ref);
2562
- if (ref === void 0 || ref.length === 0)
2563
- throw new Error("step has no testid or ref to resolve");
2564
- return { ref, note: "replayed by stale ref (not portable across sessions)" };
2565
- }
2566
- async function replayProgram(session, program) {
2567
- const results = [];
2568
- for (const step of program.steps) {
2569
- try {
2570
- if (step.tool === IrisTool.ACT_SEQUENCE) {
2571
- const subs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
2572
- const notes = [];
2573
- const liveSteps = [];
2574
- for (const raw of subs) {
2575
- const sub = asRecord2(raw);
2576
- const { ref, note } = await resolveRef(session, sub);
2577
- if (note !== void 0)
2578
- notes.push(note);
2579
- liveSteps.push({
2580
- ref,
2581
- action: asString2(sub["action"]) ?? "",
2582
- args: asRecord2(sub["args"])
2583
- });
2584
- }
2585
- const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps });
2586
- results.push(buildResult(step.tool, r.ok, r.error, notes));
2587
- if (!r.ok)
2588
- break;
2589
- } else {
2590
- const { ref, note } = await resolveRef(session, step.args);
2591
- const r = await session.command(IrisCommand.ACT, {
2592
- ref,
2593
- action: asString2(step.args["action"]) ?? "",
2594
- args: asRecord2(step.args["args"])
2595
- });
2596
- results.push(buildResult(step.tool, r.ok, r.error, note !== void 0 ? [note] : []));
2597
- if (!r.ok)
2598
- break;
2599
- }
2600
- } catch (e) {
2601
- results.push({
2602
- tool: step.tool,
2603
- ok: false,
2604
- error: e instanceof Error ? e.message : String(e)
2605
- });
2606
- break;
2607
- }
2608
- }
2609
- return results;
2610
- }
2611
- function buildResult(tool, ok, error, notes) {
2612
- const base = { tool, ok };
2613
- if (!ok)
2614
- base.error = error ?? "command failed";
2615
- if (notes.length > 0)
2616
- base.note = notes.join("; ");
2617
- return base;
2618
- }
2619
-
2620
3101
  // ../server/dist/events/event-filters.js
2621
- function asString3(value) {
3102
+ function asString4(value) {
2622
3103
  return typeof value === "string" ? value : void 0;
2623
3104
  }
2624
- function asNumber(value) {
3105
+ function asNumber2(value) {
2625
3106
  return typeof value === "number" ? value : void 0;
2626
3107
  }
2627
3108
  function matchNet(e, method, urlContains, status) {
2628
3109
  const d = e.data;
2629
- if (method !== void 0 && asString3(d["method"])?.toUpperCase() !== method.toUpperCase()) {
3110
+ if (method !== void 0 && asString4(d["method"])?.toUpperCase() !== method.toUpperCase()) {
2630
3111
  return false;
2631
3112
  }
2632
- if (urlContains !== void 0 && !(asString3(d["url"]) ?? "").includes(urlContains)) {
3113
+ if (urlContains !== void 0 && !(asString4(d["url"]) ?? "").includes(urlContains)) {
2633
3114
  return false;
2634
3115
  }
2635
- if (status !== void 0 && asNumber(d["status"]) !== status)
3116
+ if (status !== void 0 && asNumber2(d["status"]) !== status)
2636
3117
  return false;
2637
3118
  return true;
2638
3119
  }
@@ -2649,8 +3130,8 @@ function matchConsole(e, level) {
2649
3130
  var HINT_SAMPLE_MAX = 5;
2650
3131
  function netEmptyHint(allNet) {
2651
3132
  const present = allNet.slice(-HINT_SAMPLE_MAX).reverse().map((e) => {
2652
- const status = asNumber(e.data["status"]);
2653
- const base = { method: asString3(e.data["method"]) ?? "", url: asString3(e.data["url"]) ?? "" };
3133
+ const status = asNumber2(e.data["status"]);
3134
+ const base = { method: asString4(e.data["method"]) ?? "", url: asString4(e.data["url"]) ?? "" };
2654
3135
  return status === void 0 ? base : { ...base, status };
2655
3136
  });
2656
3137
  return { totalInWindow: allNet.length, present };
@@ -2681,6 +3162,8 @@ function refuseIfThrottled(session, refuse) {
2681
3162
  }
2682
3163
 
2683
3164
  // ../server/dist/session/output-budget.js
3165
+ var LARGE_TIMELINE_EVENTS = 80;
3166
+ var LARGE_TIMELINE_BYTES = 8e3;
2684
3167
  function applyEventBudget(events, maxEvents) {
2685
3168
  if (maxEvents === void 0 || maxEvents < 0 || events.length <= maxEvents) {
2686
3169
  return { events, droppedOldest: 0 };
@@ -2691,8 +3174,92 @@ function applyEventBudget(events, maxEvents) {
2691
3174
  };
2692
3175
  }
2693
3176
  function costHint(payload, events, droppedOldest = 0) {
2694
- const bytes = JSON.stringify(payload)?.length ?? 0;
2695
- return droppedOldest > 0 ? { events, bytes, droppedOldest } : { events, bytes };
3177
+ const json = JSON.stringify(payload) ?? "";
3178
+ const bytes = json.length;
3179
+ const base = droppedOldest > 0 ? { events, bytes, droppedOldest } : { events, bytes };
3180
+ if (events >= LARGE_TIMELINE_EVENTS || bytes >= LARGE_TIMELINE_BYTES) {
3181
+ 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`;
3182
+ }
3183
+ return base;
3184
+ }
3185
+ var CHARS_PER_TOKEN = 4;
3186
+ function estimateTokens(text) {
3187
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
3188
+ }
3189
+ function sizeCost(payload) {
3190
+ const json = JSON.stringify(payload) ?? "";
3191
+ return { bytes: json.length, tokens: estimateTokens(json) };
3192
+ }
3193
+ function withSizeCost(result) {
3194
+ if (typeof result !== "object" || result === null)
3195
+ return result;
3196
+ return { ...result, cost: sizeCost(result) };
3197
+ }
3198
+
3199
+ // ../server/dist/tools/snapshot-delta.js
3200
+ var SnapshotDeltaMode = {
3201
+ FULL: "full",
3202
+ DELTA: "delta",
3203
+ UNCHANGED: "unchanged"
3204
+ };
3205
+ function snapshotDelta(prevTree, nextTree) {
3206
+ if (prevTree === void 0)
3207
+ return { mode: SnapshotDeltaMode.FULL };
3208
+ const { added, removed } = diffLines(normalizeLines(prevTree), normalizeLines(nextTree));
3209
+ if (added.length === 0 && removed.length === 0)
3210
+ return { mode: SnapshotDeltaMode.UNCHANGED };
3211
+ return {
3212
+ mode: SnapshotDeltaMode.DELTA,
3213
+ delta: { added, removed, addedCount: added.length, removedCount: removed.length }
3214
+ };
3215
+ }
3216
+ var DEFAULT_MAX_ENTRIES = 50;
3217
+ var SnapshotCache = class {
3218
+ #map = /* @__PURE__ */ new Map();
3219
+ #max;
3220
+ constructor(max = DEFAULT_MAX_ENTRIES) {
3221
+ this.#max = max;
3222
+ }
3223
+ /** Last tree for this key IF the route still matches; undefined when absent or route changed. */
3224
+ recall(key, route) {
3225
+ const entry = this.#map.get(key);
3226
+ return entry !== void 0 && entry.route === route ? entry.tree : void 0;
3227
+ }
3228
+ remember(key, route, tree) {
3229
+ if (this.#map.size >= this.#max && !this.#map.has(key)) {
3230
+ const oldest = this.#map.keys().next().value;
3231
+ if (oldest !== void 0)
3232
+ this.#map.delete(oldest);
3233
+ }
3234
+ this.#map.set(key, { route, tree });
3235
+ }
3236
+ };
3237
+ function snapshotCacheKey(sessionId, scope, mode) {
3238
+ return `${sessionId}\0${scope}\0${mode}`;
3239
+ }
3240
+ function applySnapshotDelta(raw, opts, cache) {
3241
+ if (typeof raw !== "object" || raw === null)
3242
+ return raw;
3243
+ const r = raw;
3244
+ if (typeof r["tree"] !== "string")
3245
+ return raw;
3246
+ const tree = r["tree"];
3247
+ const status = typeof r["status"] === "object" && r["status"] !== null ? r["status"] : {};
3248
+ const route = typeof status["route"] === "string" ? status["route"] : "";
3249
+ const key = snapshotCacheKey(opts.sessionId, opts.scope, opts.mode);
3250
+ if (!opts.diff) {
3251
+ cache.remember(key, route, tree);
3252
+ return raw;
3253
+ }
3254
+ const prev = cache.recall(key, route);
3255
+ cache.remember(key, route, tree);
3256
+ const decision = snapshotDelta(prev, tree);
3257
+ if (decision.mode === SnapshotDeltaMode.FULL)
3258
+ return raw;
3259
+ if (decision.mode === SnapshotDeltaMode.UNCHANGED) {
3260
+ return { mode: SnapshotDeltaMode.UNCHANGED, status: r["status"] };
3261
+ }
3262
+ return { mode: SnapshotDeltaMode.DELTA, delta: decision.delta, status: r["status"] };
2696
3263
  }
2697
3264
 
2698
3265
  // ../server/dist/session/state-select.js
@@ -2743,25 +3310,55 @@ function capDepth(value, maxDepth) {
2743
3310
  return value;
2744
3311
  }
2745
3312
 
2746
- // ../server/dist/tools/tools-helpers.js
2747
- function parseInteractive(tree) {
2748
- const items = [];
2749
- for (const line of tree.split("\n")) {
2750
- const match = /\(ref=(e\d+)\)/.exec(line);
2751
- if (match !== null) {
2752
- items.push({ ref: match[1] ?? "", desc: line.replace(/\s*\(ref=e\d+\)/, "").trim() });
2753
- }
3313
+ // ../server/dist/tools/query-paginate.js
3314
+ function paginateQueryResult(result, limit, countOnly) {
3315
+ if (typeof result !== "object" || result === null)
3316
+ return result;
3317
+ const record = result;
3318
+ const elements = record["elements"];
3319
+ if (!Array.isArray(elements))
3320
+ return result;
3321
+ const total = elements.length;
3322
+ if (countOnly) {
3323
+ const { elements: _dropped, ...rest } = record;
3324
+ return { ...rest, count: total };
2754
3325
  }
2755
- return items;
2756
- }
2757
- function asString4(value) {
2758
- return typeof value === "string" ? value : void 0;
3326
+ if (limit !== void 0 && limit >= 0 && total > limit) {
3327
+ return { ...record, elements: elements.slice(0, limit), total, truncated: true };
3328
+ }
3329
+ return result;
2759
3330
  }
2760
- function asNumber2(value) {
2761
- return typeof value === "number" ? value : void 0;
3331
+
3332
+ // ../server/dist/tools/assert-grade.js
3333
+ 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.";
3334
+ function walk(predicate) {
3335
+ switch (predicate.kind) {
3336
+ case "signal":
3337
+ case "net":
3338
+ return { consequence: true, presence: false };
3339
+ case "element":
3340
+ case "text":
3341
+ return { consequence: false, presence: true };
3342
+ case "route":
3343
+ case "console":
3344
+ case "animation":
3345
+ case "settled":
3346
+ return { consequence: false, presence: false };
3347
+ case "allOf":
3348
+ case "anyOf": {
3349
+ const subs = predicate.predicates.map(walk);
3350
+ return {
3351
+ consequence: subs.some((s) => s.consequence),
3352
+ presence: subs.some((s) => s.presence)
3353
+ };
3354
+ }
3355
+ case "not":
3356
+ return walk(predicate.predicate);
3357
+ }
2762
3358
  }
2763
- function asRecord3(value) {
2764
- return typeof value === "object" && value !== null ? value : {};
3359
+ function isPresenceOnlyAssertion(predicate) {
3360
+ const kinds = walk(predicate);
3361
+ return kinds.presence && !kinds.consequence;
2765
3362
  }
2766
3363
 
2767
3364
  // ../server/dist/tools/contract-tools.js
@@ -2795,7 +3392,7 @@ var CONTRACT_TOOLS = [
2795
3392
  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");
2796
3393
  return { ...r.capabilities, source: "disk", generatedAt: r.generatedAt };
2797
3394
  }
2798
- const caps = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3395
+ const caps = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2799
3396
  return { ...caps, source: "live" };
2800
3397
  }
2801
3398
  },
@@ -2810,7 +3407,7 @@ var CONTRACT_TOOLS = [
2810
3407
  signalCount: z4.number()
2811
3408
  },
2812
3409
  handler: async (deps, args) => {
2813
- const res = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3410
+ const res = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2814
3411
  const caps = CapabilitiesSchema.parse(res);
2815
3412
  await writeContract(deps.fs, deps.irisRoot, caps, deps.now);
2816
3413
  return {
@@ -2823,10 +3420,345 @@ var CONTRACT_TOOLS = [
2823
3420
  }
2824
3421
  ];
2825
3422
 
3423
+ // ../server/dist/domain/domain-tools.js
3424
+ import { z as z5 } from "zod";
3425
+
3426
+ // ../server/dist/flows/flow-classify.js
3427
+ var FlowAssertionGrade = {
3428
+ /** At least one step (or the success end-condition) asserts a signal/network consequence. */
3429
+ ASSERTED: "asserted",
3430
+ /** Only element-presence checks — a healed-but-wrong locator could still pass. */
3431
+ PRESENCE_ONLY: "presence-only",
3432
+ /** Performs actions but asserts nothing observable — passes even if the feature is broken. */
3433
+ ASSERTION_FREE: "assertion-free"
3434
+ };
3435
+ 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.";
3436
+ 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).";
3437
+ function expectIsConsequence(e) {
3438
+ return e !== void 0 && (e.signal !== void 0 || e.net !== void 0);
3439
+ }
3440
+ function expectIsWeak(e) {
3441
+ return e !== void 0 && e.element !== void 0 && e.signal === void 0 && e.net === void 0;
3442
+ }
3443
+ function flattenSteps(steps) {
3444
+ const out = [];
3445
+ for (const s of steps) {
3446
+ out.push(s);
3447
+ if (s.steps !== void 0)
3448
+ out.push(...flattenSteps(s.steps));
3449
+ }
3450
+ return out;
3451
+ }
3452
+ function classifyFlowAssertions(flow) {
3453
+ const all = flattenSteps(flow.steps);
3454
+ let consequenceSteps = 0;
3455
+ let weakSteps = 0;
3456
+ for (const s of all) {
3457
+ if (expectIsConsequence(s.expect))
3458
+ consequenceSteps++;
3459
+ else if (expectIsWeak(s.expect))
3460
+ weakSteps++;
3461
+ }
3462
+ const successIsConsequence = expectIsConsequence(flow.success);
3463
+ const successIsWeak = expectIsWeak(flow.success);
3464
+ const hasConsequenceAssertion = consequenceSteps > 0 || successIsConsequence;
3465
+ const hasAnyAssertion = hasConsequenceAssertion || weakSteps > 0 || successIsWeak;
3466
+ let grade;
3467
+ let warning;
3468
+ if (hasConsequenceAssertion) {
3469
+ grade = FlowAssertionGrade.ASSERTED;
3470
+ } else if (hasAnyAssertion) {
3471
+ grade = FlowAssertionGrade.PRESENCE_ONLY;
3472
+ warning = PRESENCE_ONLY_WARNING;
3473
+ } else {
3474
+ grade = FlowAssertionGrade.ASSERTION_FREE;
3475
+ warning = ASSERTION_FREE_WARNING;
3476
+ }
3477
+ return {
3478
+ grade,
3479
+ hasConsequenceAssertion,
3480
+ totalSteps: all.length,
3481
+ consequenceSteps,
3482
+ weakSteps,
3483
+ successIsConsequence,
3484
+ ...warning !== void 0 ? { warning } : {}
3485
+ };
3486
+ }
3487
+
3488
+ // ../server/dist/flows/flow-success.js
3489
+ function dynamicTestids(flow) {
3490
+ return new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
3491
+ }
3492
+ function successLabel(success) {
3493
+ if (success.signal !== void 0)
3494
+ return success.signal;
3495
+ if (success.net !== void 0)
3496
+ return success.net.urlContains ?? success.net.method ?? "net";
3497
+ return success.element?.testid ?? success.element?.name ?? success.element?.role ?? "success";
3498
+ }
3499
+ function successToPredicate(success, dynamic) {
3500
+ const parts = [];
3501
+ if (success.signal !== void 0) {
3502
+ parts.push(success.signalData !== void 0 ? { kind: "signal", name: success.signal, dataMatches: success.signalData } : { kind: "signal", name: success.signal });
3503
+ }
3504
+ if (success.net !== void 0) {
3505
+ const net = { kind: "net" };
3506
+ if (success.net.method !== void 0)
3507
+ net.method = success.net.method;
3508
+ if (success.net.urlContains !== void 0)
3509
+ net.urlContains = success.net.urlContains;
3510
+ if (success.net.status !== void 0)
3511
+ net.status = success.net.status;
3512
+ parts.push(net);
3513
+ }
3514
+ const element = success.element;
3515
+ if (element !== void 0) {
3516
+ const testid = element.testid;
3517
+ if (testid === void 0 || !dynamic.has(testid)) {
3518
+ const query = {};
3519
+ if (testid !== void 0)
3520
+ query["testid"] = testid;
3521
+ if (element.role !== void 0)
3522
+ query["role"] = element.role;
3523
+ if (element.name !== void 0)
3524
+ query["name"] = element.name;
3525
+ if (Object.keys(query).length > 0)
3526
+ parts.push({ kind: "element", query });
3527
+ }
3528
+ }
3529
+ const [first] = parts;
3530
+ if (parts.length === 0)
3531
+ return void 0;
3532
+ if (parts.length === 1 && first !== void 0)
3533
+ return first;
3534
+ return { kind: "allOf", predicates: parts };
3535
+ }
3536
+ async function assertSuccess(session, success, dynamic, waitForSignal, timeoutMs, since = 0) {
3537
+ if (success === void 0)
3538
+ return { pass: true };
3539
+ const predicate = successToPredicate(success, dynamic);
3540
+ if (predicate === void 0)
3541
+ return { pass: true };
3542
+ return waitForSignal(session, predicate, timeoutMs, since);
3543
+ }
3544
+
3545
+ // ../server/dist/domain/flow-risk.js
3546
+ var RiskLevel = {
3547
+ HIGH: "high",
3548
+ MEDIUM: "medium",
3549
+ LOW: "low",
3550
+ UNKNOWN: "unknown"
3551
+ };
3552
+ var RANK = {
3553
+ [RiskLevel.HIGH]: 3,
3554
+ [RiskLevel.MEDIUM]: 2,
3555
+ [RiskLevel.UNKNOWN]: 1,
3556
+ [RiskLevel.LOW]: 0
3557
+ };
3558
+ function latestRun(name, runs) {
3559
+ let best;
3560
+ for (const run of runs) {
3561
+ if (run.name === name && (best === void 0 || run.at > best.at))
3562
+ best = run;
3563
+ }
3564
+ return best;
3565
+ }
3566
+ function runRisk(run) {
3567
+ if (run === void 0)
3568
+ return { level: RiskLevel.UNKNOWN, reason: "never run" };
3569
+ if (run.status === RunStatus.ERROR || run.status === RunStatus.FAIL) {
3570
+ return { level: RiskLevel.HIGH, reason: "last run failed" };
3571
+ }
3572
+ if (run.status === RunStatus.DRIFT)
3573
+ return { level: RiskLevel.HIGH, reason: "last run drifted" };
3574
+ const errors = (run.evidence?.consoleErrors ?? 0) + (run.evidence?.networkErrors ?? 0);
3575
+ if (errors > 0) {
3576
+ return {
3577
+ level: RiskLevel.MEDIUM,
3578
+ reason: `last run passed but logged ${String(errors)} error(s)`
3579
+ };
3580
+ }
3581
+ return { level: RiskLevel.LOW, reason: "last run passed clean" };
3582
+ }
3583
+ function gradeRisk(grade) {
3584
+ if (grade === FlowAssertionGrade.ASSERTION_FREE) {
3585
+ return {
3586
+ level: RiskLevel.MEDIUM,
3587
+ reason: "asserts no consequence \u2014 a green run proves little"
3588
+ };
3589
+ }
3590
+ if (grade === FlowAssertionGrade.PRESENCE_ONLY) {
3591
+ return { level: RiskLevel.LOW, reason: "presence-only assertion" };
3592
+ }
3593
+ return { level: RiskLevel.LOW, reason: "asserts a consequence" };
3594
+ }
3595
+ function flowRisk(grade, run) {
3596
+ const r = runRisk(run);
3597
+ const g = gradeRisk(grade);
3598
+ const top = RANK[r.level] >= RANK[g.level] ? r : g;
3599
+ return run === void 0 ? { level: top.level, reason: top.reason } : { level: top.level, reason: top.reason, lastStatus: run.status };
3600
+ }
3601
+ function rankByRisk(entries) {
3602
+ return [...entries].sort((a, b) => RANK[b.risk.level] - RANK[a.risk.level] || a.name.localeCompare(b.name)).map((e) => e.name);
3603
+ }
3604
+
3605
+ // ../server/dist/domain/domain-model.js
3606
+ function flatten(steps) {
3607
+ const out = [];
3608
+ for (const s of steps) {
3609
+ out.push(s);
3610
+ if (s.steps !== void 0)
3611
+ out.push(...flatten(s.steps));
3612
+ }
3613
+ return out;
3614
+ }
3615
+ function flowSignals(flow) {
3616
+ const set = /* @__PURE__ */ new Set();
3617
+ for (const step of flatten(flow.steps)) {
3618
+ if (step.anchor.kind === AnchorKind.SIGNAL)
3619
+ set.add(step.anchor.name);
3620
+ if (step.expect?.signal !== void 0)
3621
+ set.add(step.expect.signal);
3622
+ }
3623
+ if (flow.success?.signal !== void 0)
3624
+ set.add(flow.success.signal);
3625
+ return [...set];
3626
+ }
3627
+ function flowTestids(flow) {
3628
+ const set = /* @__PURE__ */ new Set();
3629
+ for (const step of flatten(flow.steps)) {
3630
+ if (step.anchor.kind === AnchorKind.TESTID)
3631
+ set.add(step.anchor.value);
3632
+ if (step.expect?.element?.testid !== void 0)
3633
+ set.add(step.expect.element.testid);
3634
+ }
3635
+ if (flow.success?.element?.testid !== void 0)
3636
+ set.add(flow.success.element.testid);
3637
+ return [...set];
3638
+ }
3639
+ var EMPTY_CONTRACT = { testids: [], signals: [], stores: [], flows: [] };
3640
+ function buildDomainModel(flows, contract, runs = []) {
3641
+ const caps = contract ?? EMPTY_CONTRACT;
3642
+ const hasHistory = runs.length > 0;
3643
+ const flowSummaries = flows.map((flow) => {
3644
+ const c = classifyFlowAssertions(flow);
3645
+ const summary = {
3646
+ name: flow.name,
3647
+ steps: c.totalSteps,
3648
+ grade: c.grade,
3649
+ asserts: c.hasConsequenceAssertion,
3650
+ signals: flowSignals(flow),
3651
+ testids: flowTestids(flow)
3652
+ };
3653
+ if (flow.success !== void 0)
3654
+ summary.mustHold = successLabel(flow.success);
3655
+ if (c.warning !== void 0)
3656
+ summary.warning = c.warning;
3657
+ if (hasHistory)
3658
+ summary.risk = flowRisk(c.grade, latestRun(flow.name, runs));
3659
+ return summary;
3660
+ });
3661
+ const testedSignals = new Set(flowSummaries.flatMap((f) => f.signals));
3662
+ const testedTestids = new Set(flowSummaries.flatMap((f) => f.testids));
3663
+ const coverage = {
3664
+ asserted: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.ASSERTED).length,
3665
+ presenceOnly: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.PRESENCE_ONLY).length,
3666
+ assertionFree: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.ASSERTION_FREE).length
3667
+ };
3668
+ const gaps = {
3669
+ unassertedFlows: flowSummaries.filter((f) => !f.asserts).map((f) => f.name),
3670
+ declaredUntestedSignals: caps.signals.filter((s) => !testedSignals.has(s)),
3671
+ declaredUntestedTestids: caps.testids.filter((t) => !testedTestids.has(t))
3672
+ };
3673
+ const riskRanked = hasHistory ? rankByRisk(flowSummaries.filter((f) => f.risk !== void 0).map((f) => ({ name: f.name, risk: f.risk }))) : [];
3674
+ const top = riskRanked[0];
3675
+ const topFlow = top === void 0 ? void 0 : flowSummaries.find((f) => f.name === top);
3676
+ 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;
3677
+ return {
3678
+ flowCount: flows.length,
3679
+ flows: flowSummaries,
3680
+ declared: { testids: caps.testids.length, signals: caps.signals, stores: caps.stores },
3681
+ coverage,
3682
+ gaps,
3683
+ riskRanked,
3684
+ summary: buildSummary(flows.length, coverage, gaps, topRisk)
3685
+ };
3686
+ }
3687
+ function buildSummary(flowCount, coverage, gaps, topRisk) {
3688
+ if (flowCount === 0) {
3689
+ return "No saved flows yet \u2014 record the critical journeys (iris_record_start) so the agent learns the app.";
3690
+ }
3691
+ const parts = [
3692
+ `${String(flowCount)} flow${flowCount === 1 ? "" : "s"}: ${String(coverage.asserted)} asserted, ${String(coverage.presenceOnly)} presence-only, ${String(coverage.assertionFree)} assertion-free`
3693
+ ];
3694
+ if (topRisk !== void 0) {
3695
+ parts.push(`test first: ${topRisk.name} (${topRisk.reason})`);
3696
+ }
3697
+ if (gaps.declaredUntestedSignals.length > 0) {
3698
+ parts.push(`${String(gaps.declaredUntestedSignals.length)} declared signal(s) no flow asserts (${gaps.declaredUntestedSignals.join(", ")})`);
3699
+ }
3700
+ if (gaps.unassertedFlows.length > 0) {
3701
+ parts.push(`${String(gaps.unassertedFlows.length)} flow(s) assert no consequence`);
3702
+ }
3703
+ return parts.join(". ") + ".";
3704
+ }
3705
+
3706
+ // ../server/dist/domain/domain-tools.js
3707
+ var DOMAIN_TOOLS = [
3708
+ {
3709
+ name: IrisTool.DOMAIN,
3710
+ 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).",
3711
+ inputSchema: {},
3712
+ outputSchema: {
3713
+ flowCount: z5.number(),
3714
+ flows: z5.array(z5.object({
3715
+ name: z5.string(),
3716
+ steps: z5.number(),
3717
+ grade: z5.string(),
3718
+ asserts: z5.boolean(),
3719
+ mustHold: z5.string().optional().describe("The success consequence that must hold for this flow (what it actually tests)."),
3720
+ warning: z5.string().optional(),
3721
+ signals: z5.array(z5.string()),
3722
+ testids: z5.array(z5.string())
3723
+ })),
3724
+ declared: z5.object({
3725
+ testids: z5.number(),
3726
+ signals: z5.array(z5.string()),
3727
+ stores: z5.array(z5.string())
3728
+ }),
3729
+ coverage: z5.object({
3730
+ asserted: z5.number(),
3731
+ presenceOnly: z5.number(),
3732
+ assertionFree: z5.number()
3733
+ }),
3734
+ gaps: z5.object({
3735
+ unassertedFlows: z5.array(z5.string()),
3736
+ declaredUntestedSignals: z5.array(z5.string()),
3737
+ declaredUntestedTestids: z5.array(z5.string())
3738
+ }),
3739
+ riskRanked: z5.array(z5.string()).describe("Flow names worst-risk first (run history + assertion quality). Test these first."),
3740
+ summary: z5.string()
3741
+ },
3742
+ handler: async (deps) => {
3743
+ const names = await deps.flows.list();
3744
+ const flows = [];
3745
+ for (const name of names) {
3746
+ const loaded = await deps.flows.load(name);
3747
+ if (loaded.ok)
3748
+ flows.push(loaded.value);
3749
+ }
3750
+ const contract = await readContract(deps.fs, deps.irisRoot);
3751
+ const project = await deps.project.read();
3752
+ const runs = project.ok ? project.file.runs : [];
3753
+ return buildDomainModel(flows, contract.ok ? contract.capabilities : null, runs);
3754
+ }
3755
+ }
3756
+ ];
3757
+
2826
3758
  // ../server/dist/tools/browser-tools.js
2827
- import { z as z5 } from "zod";
3759
+ import { z as z6 } from "zod";
2828
3760
  var sessionIdShape2 = {
2829
- sessionId: z5.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3761
+ sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
2830
3762
  };
2831
3763
  async function commandOrThrow2(deps, sessionId, name, args) {
2832
3764
  const session = deps.sessions.resolve(sessionId);
@@ -2840,34 +3772,38 @@ var BROWSER_TOOLS = [
2840
3772
  name: IrisTool.NAVIGATE,
2841
3773
  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.",
2842
3774
  inputSchema: {
2843
- url: z5.string().describe("The URL to navigate to."),
3775
+ url: z6.string().describe("The URL to navigate to."),
2844
3776
  ...sessionIdShape2
2845
3777
  },
2846
3778
  outputSchema: {
2847
- ok: z5.boolean(),
2848
- url: z5.string().optional(),
2849
- reason: z5.string().optional()
3779
+ ok: z6.boolean(),
3780
+ url: z6.string().optional(),
3781
+ reason: z6.string().optional()
2850
3782
  },
2851
3783
  handler: async (deps, args) => {
2852
- const url = asString4(args["url"]);
3784
+ const url = asString(args["url"]);
2853
3785
  if (url === void 0 || url.length === 0)
2854
3786
  return { ok: false, reason: "url required" };
2855
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.NAVIGATE, { url });
2856
- return { ok: true, url };
3787
+ const result = await commandOrThrow2(deps, asString(args["sessionId"]), IrisCommand.NAVIGATE, { url });
3788
+ return {
3789
+ ok: result.ok === true,
3790
+ ...typeof result.url === "string" ? { url: result.url } : {},
3791
+ ...typeof result.reason === "string" ? { reason: result.reason } : {}
3792
+ };
2857
3793
  }
2858
3794
  },
2859
3795
  {
2860
3796
  name: IrisTool.REFRESH,
2861
3797
  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.",
2862
3798
  inputSchema: {
2863
- hard: z5.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
3799
+ hard: z6.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
2864
3800
  ...sessionIdShape2
2865
3801
  },
2866
3802
  outputSchema: {
2867
- ok: z5.boolean()
3803
+ ok: z6.boolean()
2868
3804
  },
2869
3805
  handler: async (deps, args) => {
2870
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.REFRESH, {
3806
+ await commandOrThrow2(deps, asString(args["sessionId"]), IrisCommand.REFRESH, {
2871
3807
  hard: args["hard"] === true
2872
3808
  });
2873
3809
  return { ok: true };
@@ -2876,200 +3812,7 @@ var BROWSER_TOOLS = [
2876
3812
  ];
2877
3813
 
2878
3814
  // ../server/dist/flows/flow-tools.js
2879
- import { z as z6 } from "zod";
2880
-
2881
- // ../server/dist/flows/flow-replay.js
2882
- function editDistance(a, b) {
2883
- const s = a.toLowerCase();
2884
- const t = b.toLowerCase();
2885
- const rows = s.length + 1;
2886
- const cols = t.length + 1;
2887
- const prev = new Array(cols);
2888
- const curr = new Array(cols);
2889
- for (let j = 0; j < cols; j++)
2890
- prev[j] = j;
2891
- for (let i = 1; i < rows; i++) {
2892
- curr[0] = i;
2893
- for (let j = 1; j < cols; j++) {
2894
- const cost = s[i - 1] === t[j - 1] ? 0 : 1;
2895
- curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
2896
- }
2897
- for (let j = 0; j < cols; j++)
2898
- prev[j] = curr[j] ?? 0;
2899
- }
2900
- return prev[cols - 1] ?? 0;
2901
- }
2902
- function nearestTestid(missing, present) {
2903
- let best = null;
2904
- let bestDistance = Number.POSITIVE_INFINITY;
2905
- for (const candidate of present) {
2906
- const distance = editDistance(missing, candidate);
2907
- if (distance < bestDistance || distance === bestDistance && best !== null && candidate.length < best.length || distance === bestDistance && best !== null && candidate.length === best.length && candidate < best) {
2908
- best = candidate;
2909
- bestDistance = distance;
2910
- }
2911
- }
2912
- return best;
2913
- }
2914
- function readQuery(result) {
2915
- if (!result.ok)
2916
- return { refs: [] };
2917
- const payload = asRecord3(result.result);
2918
- const elements = Array.isArray(payload["elements"]) ? payload["elements"] : [];
2919
- const refs = elements.map((e) => asString4(asRecord3(e)["ref"]) ?? "").filter((r) => r.length > 0);
2920
- const rawHint = payload["hint"];
2921
- if (typeof rawHint === "object" && rawHint !== null) {
2922
- const hint = asRecord3(rawHint);
2923
- const present = Array.isArray(hint["presentTestids"]) ? hint["presentTestids"].filter((t) => typeof t === "string") : [];
2924
- return {
2925
- refs,
2926
- hint: {
2927
- route: asString4(hint["route"]) ?? "",
2928
- presentTestids: present,
2929
- presentRegions: [],
2930
- knownEmptyState: hint["knownEmptyState"] === true
2931
- }
2932
- };
2933
- }
2934
- return { refs };
2935
- }
2936
- function testidDrift(value, hint) {
2937
- return {
2938
- reasonKind: DriftReason.TESTID_NOT_FOUND,
2939
- reason: `testid "${value}" not found`,
2940
- anchor: value,
2941
- nearest: nearestTestid(value, hint?.presentTestids ?? [])
2942
- };
2943
- }
2944
- function anchorLabel(anchor) {
2945
- if (anchor.kind === AnchorKind.TESTID)
2946
- return anchor.value;
2947
- if (anchor.kind === AnchorKind.SIGNAL)
2948
- return anchor.name;
2949
- return anchor.name ?? anchor.role;
2950
- }
2951
- async function runTestidStep(session, step, index, value, dynamic) {
2952
- const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value });
2953
- const { refs, hint } = readQuery(queryResult);
2954
- if (refs.length === 0) {
2955
- return {
2956
- step: index,
2957
- tool: step.tool,
2958
- anchor: value,
2959
- ok: false,
2960
- drift: testidDrift(value, hint)
2961
- };
2962
- }
2963
- const ref = refs[0] ?? "";
2964
- const note = refs.length > 1 ? `ambiguous testid '${value}', used first match` : void 0;
2965
- const act = await session.command(IrisCommand.ACT, {
2966
- ref,
2967
- action: step.action ?? "",
2968
- args: step.args ?? {}
2969
- });
2970
- const result = { step: index, tool: step.tool, anchor: value, ok: act.ok };
2971
- if (!act.ok) {
2972
- result.error = act.error ?? "command failed";
2973
- if (note !== void 0)
2974
- result.note = note;
2975
- return result;
2976
- }
2977
- const expectTestid = step.expect?.element?.testid;
2978
- if (expectTestid !== void 0 && !dynamic.has(expectTestid)) {
2979
- const expectQuery = await session.command(IrisCommand.QUERY, {
2980
- by: QueryBy.TESTID,
2981
- value: expectTestid
2982
- });
2983
- const expectRefs = readQuery(expectQuery);
2984
- if (expectRefs.refs.length === 0) {
2985
- return {
2986
- step: index,
2987
- tool: step.tool,
2988
- anchor: expectTestid,
2989
- ok: false,
2990
- drift: testidDrift(expectTestid, expectRefs.hint)
2991
- };
2992
- }
2993
- }
2994
- if (note !== void 0)
2995
- result.note = note;
2996
- return result;
2997
- }
2998
- async function runSignalStep(session, step, index, name, waitForSignal, signalTimeoutMs) {
2999
- const verdict = await waitForSignal(session, { kind: "signal", name }, signalTimeoutMs);
3000
- if (verdict.pass)
3001
- return { step: index, tool: step.tool, anchor: name, ok: true };
3002
- return {
3003
- step: index,
3004
- tool: step.tool,
3005
- anchor: name,
3006
- ok: false,
3007
- drift: {
3008
- reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
3009
- reason: `signal "${name}" not observed`,
3010
- anchor: name,
3011
- nearest: null
3012
- }
3013
- };
3014
- }
3015
- async function replayFlow(session, flow, waitForSignal, signalTimeoutMs) {
3016
- const results = [];
3017
- const dynamic = new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
3018
- let index = 0;
3019
- for (const step of flow.steps) {
3020
- const label = anchorLabel(step.anchor);
3021
- let result;
3022
- if (step.anchor.kind === AnchorKind.SIGNAL) {
3023
- result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs);
3024
- } else {
3025
- result = await runTestidStep(session, step, index, label, dynamic);
3026
- }
3027
- results.push(result);
3028
- if (result.drift !== void 0 || !result.ok)
3029
- break;
3030
- index += 1;
3031
- }
3032
- return results;
3033
- }
3034
-
3035
- // ../server/dist/flows/heal.js
3036
- function confidenceFor(from, to) {
3037
- if (from === to)
3038
- return 1;
3039
- const span = Math.max(from.length, to.length);
3040
- if (span === 0)
3041
- return 1;
3042
- const raw = 1 - editDistance(from, to) / span;
3043
- if (raw >= 1)
3044
- return 1;
3045
- if (raw <= 0)
3046
- return Number.EPSILON;
3047
- return raw;
3048
- }
3049
- function proposeRebindWith(drift, step, minConfidence) {
3050
- if (drift.reasonKind !== DriftReason.TESTID_NOT_FOUND)
3051
- return void 0;
3052
- const to = drift.nearest;
3053
- if (to === null)
3054
- return void 0;
3055
- const confidence = confidenceFor(drift.anchor, to);
3056
- if (confidence < minConfidence)
3057
- return void 0;
3058
- return { step, from: drift.anchor, to, confidence };
3059
- }
3060
- function collectProposals(steps, minConfidence = HEAL_CONFIDENCE_MIN) {
3061
- const proposals = [];
3062
- for (const step of steps) {
3063
- if (step.drift === void 0)
3064
- continue;
3065
- const proposal = proposeRebindWith(step.drift, step.step, minConfidence);
3066
- if (proposal !== void 0)
3067
- proposals.push(proposal);
3068
- }
3069
- return proposals;
3070
- }
3071
-
3072
- // ../server/dist/flows/flow-tools.js
3815
+ import { z as z7 } from "zod";
3073
3816
  function latestRecordedFlow(events) {
3074
3817
  for (let i = events.length - 1; i >= 0; i--) {
3075
3818
  const event = events[i];
@@ -3115,18 +3858,26 @@ async function recordReplayRun(deps, name, status, driftSteps, durationMs) {
3115
3858
  var FLOW_TOOLS = [
3116
3859
  {
3117
3860
  name: IrisTool.FLOW_SAVE,
3118
- 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 }.',
3861
+ 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).',
3119
3862
  inputSchema: {
3120
- flowName: z6.string().describe("Name for the flow file (saved to .iris/flows/<flowName>.json). Use again in iris_flow_load/iris_flow_replay.")
3863
+ flowName: z7.string().describe("Name for the flow file (saved to .iris/flows/<flowName>.json). Use again in iris_flow_load/iris_flow_replay.")
3121
3864
  },
3122
3865
  outputSchema: {
3123
- saved: z6.boolean(),
3124
- path: z6.string(),
3125
- stepCount: z6.number().optional(),
3126
- degraded: z6.number().optional()
3866
+ saved: z7.boolean(),
3867
+ path: z7.string(),
3868
+ stepCount: z7.number().optional(),
3869
+ degraded: z7.number().optional(),
3870
+ assertions: z7.object({
3871
+ grade: z7.string().describe("asserted | presence-only | assertion-free"),
3872
+ hasConsequenceAssertion: z7.boolean(),
3873
+ totalSteps: z7.number(),
3874
+ consequenceSteps: z7.number(),
3875
+ weakSteps: z7.number(),
3876
+ warning: z7.string().optional()
3877
+ }).optional()
3127
3878
  },
3128
3879
  handler: (deps, args) => {
3129
- const name = asString4(args["flowName"]) ?? "";
3880
+ const name = asString(args["flowName"]) ?? "";
3130
3881
  const program = deps.recordings.getCompiled(name);
3131
3882
  if (program === void 0) {
3132
3883
  return Promise.resolve({
@@ -3140,10 +3891,12 @@ var FLOW_TOOLS = [
3140
3891
  dynamic: deps.annotations.dynamic(name),
3141
3892
  ...success !== void 0 ? { success } : {}
3142
3893
  };
3143
- return deps.flows.save(program, annotations).then((res) => {
3144
- if (res.ok)
3145
- deps.annotations.clear(name);
3146
- return res.ok ? res.value : { error: flowErrorMessage(res.code), code: res.code };
3894
+ return deps.flows.save(program, annotations).then(async (res) => {
3895
+ if (!res.ok)
3896
+ return { error: flowErrorMessage(res.code), code: res.code };
3897
+ deps.annotations.clear(name);
3898
+ const loaded = await deps.flows.load(res.value.name);
3899
+ return loaded.ok ? { ...res.value, assertions: classifyFlowAssertions(loaded.value) } : res.value;
3147
3900
  });
3148
3901
  }
3149
3902
  },
@@ -3152,22 +3905,27 @@ var FLOW_TOOLS = [
3152
3905
  description: "List saved flow names under .iris/flows (a fresh agent learns the demonstrated journeys without a browser).",
3153
3906
  inputSchema: {},
3154
3907
  outputSchema: {
3155
- flows: z6.array(z6.object({ name: z6.string(), path: z6.string(), createdAt: z6.number().optional() }))
3908
+ flows: z7.array(z7.object({ name: z7.string(), path: z7.string(), createdAt: z7.number().optional() }))
3156
3909
  },
3157
- handler: (deps) => deps.flows.list().then((flows) => ({ flows }))
3910
+ // Return {name, path} objects to MATCH the declared outputSchema. Returning bare name strings
3911
+ // (the prior bug) made schema-validating MCP clients reject the result ("expected object,
3912
+ // received string") — caught driving the live demo.
3913
+ handler: (deps) => deps.flows.list().then((names) => ({
3914
+ flows: names.map((name) => ({ name, path: flowPath(deps.irisRoot, name) }))
3915
+ }))
3158
3916
  },
3159
3917
  {
3160
3918
  name: IrisTool.FLOW_LOAD,
3161
3919
  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 }.",
3162
3920
  inputSchema: {
3163
- flowName: z6.string().describe("Flow file name (without .json extension) from iris_flow_list.")
3921
+ flowName: z7.string().describe("Flow file name (without .json extension) from iris_flow_list.")
3164
3922
  },
3165
3923
  outputSchema: {
3166
- flowName: z6.string(),
3167
- steps: z6.array(z6.unknown()),
3168
- createdAt: z6.number().optional()
3924
+ flowName: z7.string(),
3925
+ steps: z7.array(z7.unknown()),
3926
+ createdAt: z7.number().optional()
3169
3927
  },
3170
- handler: (deps, args) => deps.flows.load(asString4(args["flowName"]) ?? "").then((res) => {
3928
+ handler: (deps, args) => deps.flows.load(asString(args["flowName"]) ?? "").then((res) => {
3171
3929
  if (!res.ok)
3172
3930
  return { error: flowErrorMessage(res.code), code: res.code };
3173
3931
  const { name, ...rest } = res.value;
@@ -3176,20 +3934,21 @@ var FLOW_TOOLS = [
3176
3934
  },
3177
3935
  {
3178
3936
  name: IrisTool.FLOW_REPLAY,
3179
- 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).`,
3937
+ 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).`,
3180
3938
  inputSchema: {
3181
- flowName: z6.string().describe("Flow file name (without .json extension) from iris_flow_list."),
3182
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3939
+ flowName: z7.string().describe("Flow file name (without .json extension) from iris_flow_list."),
3940
+ confirmDangerous: z7.boolean().optional().describe("Set true to allow destructive controls during this replay only."),
3941
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3183
3942
  },
3184
3943
  outputSchema: {
3185
- status: z6.string().describe("ok | drift | error"),
3186
- steps: z6.array(z6.unknown()),
3187
- proposals: z6.array(z6.unknown()).optional(),
3188
- error: z6.object({ code: z6.string(), message: z6.string() }).optional()
3944
+ status: z7.string().describe("ok | drift | error"),
3945
+ steps: z7.array(z7.unknown()),
3946
+ proposals: z7.array(z7.unknown()).optional(),
3947
+ error: z7.object({ code: z7.string(), message: z7.string() }).optional()
3189
3948
  },
3190
3949
  handler: async (deps, args) => {
3191
3950
  const startedAt = deps.now();
3192
- const name = asString4(args["flowName"]) ?? "";
3951
+ const name = asString(args["flowName"]) ?? "";
3193
3952
  const loaded = await deps.flows.load(name);
3194
3953
  if (!loaded.ok) {
3195
3954
  await recordReplayRun(deps, name, ReplayStatus.ERROR, 0, deps.now() - startedAt);
@@ -3200,12 +3959,35 @@ var FLOW_TOOLS = [
3200
3959
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3201
3960
  };
3202
3961
  }
3203
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3204
- const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS);
3962
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
3963
+ const replayFloor = session.elapsed();
3964
+ const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
3965
+ const stepsClean = steps.length > 0 && steps.every((s) => s.ok && s.drift === void 0);
3966
+ if (stepsClean && loaded.value.success !== void 0) {
3967
+ const verdict = await assertSuccess(session, loaded.value.success, dynamicTestids(loaded.value), waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, replayFloor);
3968
+ const row = {
3969
+ step: steps.length,
3970
+ tool: "success",
3971
+ anchor: successLabel(loaded.value.success),
3972
+ ok: verdict.pass,
3973
+ ...verdict.pass ? {} : { error: verdict.failureReason ?? "flow.success not satisfied" }
3974
+ };
3975
+ steps.push(row);
3976
+ }
3205
3977
  const driftSteps = steps.filter((s) => s.drift !== void 0).length;
3206
3978
  const allOk = steps.every((s) => s.ok);
3207
- const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.DRIFT;
3979
+ const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.ERROR;
3208
3980
  await recordReplayRun(deps, name, status, driftSteps, deps.now() - startedAt);
3981
+ const failed = steps.find((step) => !step.ok && step.drift === void 0);
3982
+ if (failed !== void 0) {
3983
+ const message = failed.error ?? "flow action failed";
3984
+ return {
3985
+ name,
3986
+ status,
3987
+ steps,
3988
+ error: { code: ReplayStatus.ERROR, message }
3989
+ };
3990
+ }
3209
3991
  return { name, status, steps };
3210
3992
  }
3211
3993
  },
@@ -3213,20 +3995,20 @@ var FLOW_TOOLS = [
3213
3995
  name: IrisTool.FLOW_SAVE_RECORDED,
3214
3996
  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).",
3215
3997
  inputSchema: {
3216
- flowName: z6.string().optional().describe("Override the flow name embedded in the recorded flow. Omit to use the recorder-assigned name."),
3998
+ flowName: z7.string().optional().describe("Override the flow name embedded in the recorded flow. Omit to use the recorder-assigned name."),
3217
3999
  ...{
3218
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4000
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3219
4001
  }
3220
4002
  },
3221
4003
  outputSchema: {
3222
- flowName: z6.string().optional(),
3223
- stepCount: z6.number().optional(),
3224
- degraded: z6.number().optional(),
3225
- error: z6.string().optional(),
3226
- code: z6.string().optional()
4004
+ flowName: z7.string().optional(),
4005
+ stepCount: z7.number().optional(),
4006
+ degraded: z7.number().optional(),
4007
+ error: z7.string().optional(),
4008
+ code: z7.string().optional()
3227
4009
  },
3228
4010
  handler: async (deps, args) => {
3229
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4011
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
3230
4012
  const recorded = latestRecordedFlow(session.eventsSince(0));
3231
4013
  if (recorded === void 0) {
3232
4014
  return {
@@ -3234,7 +4016,7 @@ var FLOW_TOOLS = [
3234
4016
  code: RecordedSaveError.NO_RECORDED_FLOW
3235
4017
  };
3236
4018
  }
3237
- const override = asString4(args["flowName"]);
4019
+ const override = asString(args["flowName"]);
3238
4020
  const flow = override !== void 0 ? { ...recorded.flow, name: override } : recorded.flow;
3239
4021
  const res = await deps.flows.saveFlow(flow);
3240
4022
  if (!res.ok)
@@ -3245,35 +4027,38 @@ var FLOW_TOOLS = [
3245
4027
  },
3246
4028
  {
3247
4029
  name: IrisTool.FLOW_HEAL,
3248
- 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 }.",
4030
+ 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 }.",
3249
4031
  inputSchema: {
3250
- flowName: z6.string().describe("Flow file name to heal (from iris_flow_list)."),
3251
- apply: z6.boolean().optional(),
3252
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4032
+ flowName: z7.string().describe("Flow file name to heal (from iris_flow_list)."),
4033
+ apply: z7.boolean().optional(),
4034
+ confirmDangerous: z7.boolean().optional().describe("Set true to allow destructive controls during this heal replay only."),
4035
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3253
4036
  },
3254
4037
  outputSchema: {
3255
- flowName: z6.string(),
3256
- status: z6.string(),
3257
- applied: z6.boolean(),
3258
- proposals: z6.array(z6.unknown()),
3259
- changed: z6.array(z6.unknown()),
3260
- message: z6.string(),
3261
- error: z6.object({ code: z6.string(), message: z6.string() }).optional()
4038
+ flowName: z7.string(),
4039
+ status: z7.string(),
4040
+ applied: z7.boolean(),
4041
+ proposals: z7.array(z7.unknown()),
4042
+ changed: z7.array(z7.unknown()),
4043
+ message: z7.string(),
4044
+ error: z7.object({ code: z7.string(), message: z7.string() }).optional()
3262
4045
  },
3263
4046
  handler: (deps, args) => healFlow(deps, args).then(({ name, ...rest }) => ({ flowName: name, ...rest }))
3264
4047
  }
3265
4048
  ];
3266
4049
  var HEAL_MESSAGES = {
3267
4050
  NOTHING: "nothing to heal \u2014 every anchor resolved on replay",
3268
- HEALED: "rewrote drifted testid anchors to their nearest surviving match",
4051
+ HEALED: "rewrote drifted testid anchors to their nearest surviving match and re-verified the flow's success consequence still fires",
3269
4052
  DRIFT_DRY: "confident rebind(s) proposed \u2014 re-run with apply:true to write them to disk",
3270
- 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`
4053
+ 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`,
4054
+ 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.",
4055
+ 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"
3271
4056
  };
3272
4057
  function toChange(proposal) {
3273
4058
  return { step: proposal.step, from: proposal.from, to: proposal.to };
3274
4059
  }
3275
4060
  async function healFlow(deps, args) {
3276
- const name = asString4(args["flowName"]) ?? "";
4061
+ const name = asString(args["flowName"]) ?? "";
3277
4062
  const apply = args["apply"] === true;
3278
4063
  const loaded = await deps.flows.load(name);
3279
4064
  if (!loaded.ok) {
@@ -3287,9 +4072,22 @@ async function healFlow(deps, args) {
3287
4072
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3288
4073
  };
3289
4074
  }
3290
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3291
- const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS);
4075
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4076
+ const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
3292
4077
  const drifted = steps.some((s) => s.drift !== void 0);
4078
+ const failed = steps.find((s) => !s.ok && s.drift === void 0);
4079
+ if (failed !== void 0) {
4080
+ const message = failed.error ?? "flow replay failed before an anchor could be healed";
4081
+ return {
4082
+ name,
4083
+ status: HealStatus.ERROR,
4084
+ applied: false,
4085
+ proposals: [],
4086
+ changed: [],
4087
+ message,
4088
+ error: { code: ReplayStatus.ERROR, message }
4089
+ };
4090
+ }
3293
4091
  if (!drifted) {
3294
4092
  return {
3295
4093
  name,
@@ -3321,6 +4119,23 @@ async function healFlow(deps, args) {
3321
4119
  message: HEAL_MESSAGES.DRIFT_DRY
3322
4120
  };
3323
4121
  }
4122
+ const { flow: healed } = applyHealChanges(loaded.value, proposals.map(toChange));
4123
+ if (healed.success !== void 0) {
4124
+ const verifyFloor = session.elapsed();
4125
+ const verifySteps = await replayFlow(session, healed, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
4126
+ const verifyClean = verifySteps.length > 0 && verifySteps.every((s) => s.ok && s.drift === void 0);
4127
+ 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" };
4128
+ if (!verdict.pass) {
4129
+ return {
4130
+ name,
4131
+ status: HealStatus.CONSEQUENCE_BROKEN,
4132
+ applied: false,
4133
+ proposals,
4134
+ changed: [],
4135
+ message: `${HEAL_MESSAGES.CONSEQUENCE_BROKEN} (${successLabel(healed.success)}: ${verdict.failureReason ?? "not satisfied"})`
4136
+ };
4137
+ }
4138
+ }
3324
4139
  const written = await deps.flows.heal(name, proposals.map(toChange));
3325
4140
  if (!written.ok) {
3326
4141
  return {
@@ -3339,14 +4154,14 @@ async function healFlow(deps, args) {
3339
4154
  applied: written.value.changed.length > 0,
3340
4155
  proposals,
3341
4156
  changed: written.value.changed,
3342
- message: HEAL_MESSAGES.HEALED
4157
+ message: loaded.value.success !== void 0 ? HEAL_MESSAGES.HEALED : HEAL_MESSAGES.HEALED_UNVERIFIED
3343
4158
  };
3344
4159
  }
3345
4160
 
3346
4161
  // ../server/dist/project/project-tools.js
3347
- import { z as z7 } from "zod";
4162
+ import { z as z8 } from "zod";
3348
4163
  var sessionIdShape3 = {
3349
- sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4164
+ sessionId: z8.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3350
4165
  };
3351
4166
  var REGRESSION_STATUSES = /* @__PURE__ */ new Set([
3352
4167
  RunStatus.FAIL,
@@ -3387,12 +4202,12 @@ var PROJECT_TOOLS = [
3387
4202
  name: IrisTool.PROJECT,
3388
4203
  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.',
3389
4204
  inputSchema: {
3390
- name: z7.string().optional().describe("Filter runs by this name. Omit to return all runs."),
4205
+ name: z8.string().optional().describe("Filter runs by this name. Omit to return all runs."),
3391
4206
  ...sessionIdShape3
3392
4207
  },
3393
4208
  outputSchema: {
3394
- runs: z7.array(z7.unknown()),
3395
- diff: z7.unknown().optional()
4209
+ runs: z8.array(z8.unknown()),
4210
+ diff: z8.unknown().optional()
3396
4211
  },
3397
4212
  handler: async (deps, args) => {
3398
4213
  const read = await deps.project.read();
@@ -3402,7 +4217,7 @@ var PROJECT_TOOLS = [
3402
4217
  reason: read.reason
3403
4218
  };
3404
4219
  }
3405
- const name = asString4(args["name"]);
4220
+ const name = asString(args["name"]);
3406
4221
  if (name === void 0) {
3407
4222
  return { runs: read.file.runs, learned: read.file.learned };
3408
4223
  }
@@ -3420,22 +4235,22 @@ var PROJECT_TOOLS = [
3420
4235
  name: IrisTool.RUN_RECORD,
3421
4236
  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 }.",
3422
4237
  inputSchema: {
3423
- name: z7.string().describe("Run name for grouping in iris_project history."),
3424
- status: z7.nativeEnum(RunStatus).describe("Outcome: pass | fail | drift | error"),
3425
- kind: z7.nativeEnum(RunKind).optional(),
3426
- summary: z7.string().optional().describe("One-line human summary of what this run covered."),
4238
+ name: z8.string().describe("Run name for grouping in iris_project history."),
4239
+ status: z8.nativeEnum(RunStatus).describe("Outcome: pass | fail | drift | error"),
4240
+ kind: z8.nativeEnum(RunKind).optional(),
4241
+ summary: z8.string().optional().describe("One-line human summary of what this run covered."),
3427
4242
  ...sessionIdShape3
3428
4243
  },
3429
4244
  outputSchema: {
3430
- recorded: z7.boolean(),
3431
- runName: z7.string(),
3432
- status: z7.string()
4245
+ recorded: z8.boolean(),
4246
+ runName: z8.string(),
4247
+ status: z8.string()
3433
4248
  },
3434
4249
  handler: async (deps, args) => {
3435
- const name = asString4(args["name"]) ?? "";
4250
+ const name = asString(args["name"]) ?? "";
3436
4251
  const status = args["status"];
3437
4252
  const kindArg = args["kind"];
3438
- const summary = asString4(args["summary"]);
4253
+ const summary = asString(args["summary"]);
3439
4254
  await deps.project.recordRun({
3440
4255
  kind: typeof kindArg === "string" ? kindArg : RunKind.MANUAL,
3441
4256
  name,
@@ -3448,7 +4263,7 @@ var PROJECT_TOOLS = [
3448
4263
  ];
3449
4264
 
3450
4265
  // ../server/dist/visual/visual-tools.js
3451
- import { z as z8 } from "zod";
4266
+ import { z as z9 } from "zod";
3452
4267
 
3453
4268
  // ../server/dist/visual/visual-diff.js
3454
4269
  async function loadDeps() {
@@ -3599,13 +4414,13 @@ var VisualStore = class {
3599
4414
 
3600
4415
  // ../server/dist/visual/visual-tools.js
3601
4416
  var sessionIdShape4 = {
3602
- sessionId: z8.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4417
+ sessionId: z9.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3603
4418
  };
3604
- var rectShape = z8.object({
3605
- x: z8.number(),
3606
- y: z8.number(),
3607
- width: z8.number(),
3608
- height: z8.number()
4419
+ var rectShape = z9.object({
4420
+ x: z9.number(),
4421
+ y: z9.number(),
4422
+ width: z9.number(),
4423
+ height: z9.number()
3609
4424
  });
3610
4425
  function screenshotProvider(deps) {
3611
4426
  const p = deps.realInput;
@@ -3617,11 +4432,11 @@ var noProvider = {
3617
4432
  recommendation: VISUAL_NO_PROVIDER_RECOMMENDATION
3618
4433
  };
3619
4434
  function asBox(value) {
3620
- const b = asRecord3(asRecord3(value)["box"]);
3621
- const x = asNumber2(b["x"]);
3622
- const y = asNumber2(b["y"]);
3623
- const w = asNumber2(b["width"]);
3624
- const h = asNumber2(b["height"]);
4435
+ const b = asRecord(asRecord(value)["box"]);
4436
+ const x = asNumber(b["x"]);
4437
+ const y = asNumber(b["y"]);
4438
+ const w = asNumber(b["width"]);
4439
+ const h = asNumber(b["height"]);
3625
4440
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
3626
4441
  return void 0;
3627
4442
  if (w <= 0 || h <= 0)
@@ -3631,12 +4446,12 @@ function asBox(value) {
3631
4446
  async function buildOpts(deps, sessionId, args) {
3632
4447
  const clipArg = args["clip"];
3633
4448
  if (clipArg !== void 0) {
3634
- const c = asRecord3(clipArg);
4449
+ const c = asRecord(clipArg);
3635
4450
  const box = asBox({ box: c });
3636
4451
  if (box !== void 0)
3637
4452
  return { clip: box };
3638
4453
  }
3639
- const ref = asString4(args["ref"]);
4454
+ const ref = asString(args["ref"]);
3640
4455
  if (ref !== void 0) {
3641
4456
  const session = deps.sessions.resolve(sessionId);
3642
4457
  const res = await session.command(IrisCommand.INSPECT, { ref });
@@ -3662,31 +4477,31 @@ var VISUAL_TOOLS = [
3662
4477
  name: IrisTool.SCREENSHOT,
3663
4478
  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.",
3664
4479
  inputSchema: {
3665
- name: z8.string().describe("Baseline name \u2014 saved as .iris/visual/<name>.png. Use the same name in iris_visual_diff to compare."),
3666
- fullPage: z8.boolean().optional().describe("Capture the full scroll height. Default: viewport only."),
3667
- ref: z8.string().optional().describe("Element ref to screenshot (scopes to element bounding box). Omit for full page."),
4480
+ name: z9.string().describe("Baseline name \u2014 saved as .iris/visual/<name>.png. Use the same name in iris_visual_diff to compare."),
4481
+ fullPage: z9.boolean().optional().describe("Capture the full scroll height. Default: viewport only."),
4482
+ ref: z9.string().optional().describe("Element ref to screenshot (scopes to element bounding box). Omit for full page."),
3668
4483
  clip: rectShape.optional().describe("Explicit { x, y, width, height } clip rectangle in page coordinates."),
3669
4484
  ...sessionIdShape4
3670
4485
  },
3671
4486
  outputSchema: {
3672
- ok: z8.boolean(),
3673
- saved: z8.boolean().optional(),
3674
- name: z8.string().optional(),
3675
- path: z8.string().optional(),
3676
- bytes: z8.number().optional(),
3677
- reason: z8.string().optional(),
3678
- recommendation: z8.string().optional()
4487
+ ok: z9.boolean(),
4488
+ saved: z9.boolean().optional(),
4489
+ name: z9.string().optional(),
4490
+ path: z9.string().optional(),
4491
+ bytes: z9.number().optional(),
4492
+ reason: z9.string().optional(),
4493
+ recommendation: z9.string().optional()
3679
4494
  },
3680
4495
  handler: async (deps, args) => {
3681
4496
  const provider = screenshotProvider(deps);
3682
4497
  if (provider === void 0)
3683
4498
  return noProvider;
3684
- const sessionId = asString4(args["sessionId"]);
4499
+ const sessionId = asString(args["sessionId"]);
3685
4500
  const session = deps.sessions.resolve(sessionId);
3686
4501
  const png = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3687
4502
  if (png === void 0)
3688
4503
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3689
- const name = asString4(args["name"]) ?? "default";
4504
+ const name = asString(args["name"]) ?? "default";
3690
4505
  const store = new VisualStore(deps.fs, deps.irisRoot);
3691
4506
  const path = await store.saveBaseline(name, png);
3692
4507
  return { ok: true, saved: true, name, path, bytes: png.length };
@@ -3696,39 +4511,39 @@ var VISUAL_TOOLS = [
3696
4511
  name: IrisTool.VISUAL_DIFF,
3697
4512
  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).",
3698
4513
  inputSchema: {
3699
- baseline: z8.string().describe("Baseline screenshot name (from iris_screenshot). Used to compare with the current screenshot."),
3700
- fullPage: z8.boolean().optional(),
3701
- ref: z8.string().optional(),
4514
+ baseline: z9.string().describe("Baseline screenshot name (from iris_screenshot). Used to compare with the current screenshot."),
4515
+ fullPage: z9.boolean().optional(),
4516
+ ref: z9.string().optional(),
3702
4517
  clip: rectShape.optional(),
3703
- masks: z8.array(rectShape).optional(),
3704
- maxRatio: z8.number().optional(),
3705
- threshold: z8.number().optional().describe("Pixel difference threshold (0\u20131). Default: 0.01."),
4518
+ masks: z9.array(rectShape).optional(),
4519
+ maxRatio: z9.number().optional(),
4520
+ threshold: z9.number().optional().describe("Pixel difference threshold (0\u20131). Default: 0.01."),
3706
4521
  ...sessionIdShape4
3707
4522
  },
3708
4523
  outputSchema: {
3709
- ok: z8.boolean(),
3710
- match: z8.boolean().optional(),
3711
- diffPct: z8.number().optional(),
3712
- diffPath: z8.string().optional(),
3713
- reason: z8.string().optional()
4524
+ ok: z9.boolean(),
4525
+ match: z9.boolean().optional(),
4526
+ diffPct: z9.number().optional(),
4527
+ diffPath: z9.string().optional(),
4528
+ reason: z9.string().optional()
3714
4529
  },
3715
4530
  handler: async (deps, args) => {
3716
4531
  const provider = screenshotProvider(deps);
3717
4532
  if (provider === void 0)
3718
4533
  return noProvider;
3719
- const baseline = asString4(args["baseline"]) ?? "";
4534
+ const baseline = asString(args["baseline"]) ?? "";
3720
4535
  const store = new VisualStore(deps.fs, deps.irisRoot);
3721
4536
  const baselineBytes = await store.readBaseline(baseline);
3722
4537
  if (baselineBytes === void 0)
3723
4538
  return { ok: false, reason: VisualReason.BASELINE_MISSING };
3724
- const sessionId = asString4(args["sessionId"]);
4539
+ const sessionId = asString(args["sessionId"]);
3725
4540
  const session = deps.sessions.resolve(sessionId);
3726
4541
  const current = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3727
4542
  if (current === void 0)
3728
4543
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3729
4544
  const masks = rectsFrom(args["masks"]);
3730
- const threshold = asNumber2(args["threshold"]);
3731
- const maxRatio = asNumber2(args["maxRatio"]);
4545
+ const threshold = asNumber(args["threshold"]);
4546
+ const maxRatio = asNumber(args["maxRatio"]);
3732
4547
  const result = await diffPng(baselineBytes, current, {
3733
4548
  ...threshold !== void 0 ? { threshold } : {},
3734
4549
  ...maxRatio !== void 0 ? { maxRatio } : {},
@@ -3745,7 +4560,7 @@ var VISUAL_TOOLS = [
3745
4560
  ];
3746
4561
 
3747
4562
  // ../server/dist/crawl/crawl-tools.js
3748
- import { z as z9 } from "zod";
4563
+ import { z as z10 } from "zod";
3749
4564
 
3750
4565
  // ../server/dist/crawl/crawl.js
3751
4566
  function isActivity(e) {
@@ -3758,7 +4573,7 @@ function failedRequests(events, floor) {
3758
4573
  return events.filter((e) => {
3759
4574
  if (e.type !== EventType.NET_REQUEST)
3760
4575
  return false;
3761
- const status = asNumber2(e.data["status"]);
4576
+ const status = asNumber(e.data["status"]);
3762
4577
  return status !== void 0 && status >= floor;
3763
4578
  });
3764
4579
  }
@@ -3788,7 +4603,7 @@ async function crawl(session, opts, sleep) {
3788
4603
  const act = await session.command(IrisCommand.ACT, {
3789
4604
  ref: item.ref,
3790
4605
  action: ActionType.CLICK,
3791
- args: {}
4606
+ args: opts.confirmDangerous === true ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {}
3792
4607
  });
3793
4608
  await sleep(settleMs);
3794
4609
  const events = session.eventsSince(since);
@@ -3799,14 +4614,14 @@ async function crawl(session, opts, sleep) {
3799
4614
  kind: CrawlAnomalyKind.CONSOLE_ERROR,
3800
4615
  ref: item.ref,
3801
4616
  desc: item.desc,
3802
- detail: asString4(e.data["message"]) ?? e.type
4617
+ detail: asString(e.data["message"]) ?? e.type
3803
4618
  });
3804
4619
  }
3805
4620
  for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
3806
4621
  counts.failedRequests += 1;
3807
- const method = asString4(e.data["method"]) ?? "";
3808
- const url = asString4(e.data["url"]) ?? "";
3809
- const status = asNumber2(e.data["status"]);
4622
+ const method = asString(e.data["method"]) ?? "";
4623
+ const url = asString(e.data["url"]) ?? "";
4624
+ const status = asNumber(e.data["status"]);
3810
4625
  anomalies.push({
3811
4626
  kind: CrawlAnomalyKind.FAILED_REQUEST,
3812
4627
  ref: item.ref,
@@ -3814,7 +4629,7 @@ async function crawl(session, opts, sleep) {
3814
4629
  detail: `${method} ${url} \u2192 ${status ?? ""}`.trim()
3815
4630
  });
3816
4631
  }
3817
- const dispatched = asRecord3(act.result)["dispatched"] !== false && act.ok;
4632
+ const dispatched = asRecord(act.result)["dispatched"] !== false && act.ok;
3818
4633
  if (dispatched && errs.length === 0 && !events.some(isActivity)) {
3819
4634
  counts.deadControls += 1;
3820
4635
  anomalies.push({
@@ -3842,32 +4657,34 @@ var CRAWL_TOOLS = [
3842
4657
  name: IrisTool.CRAWL,
3843
4658
  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 }.",
3844
4659
  inputSchema: {
3845
- maxSteps: z9.number().optional().describe("Maximum number of controls to click. Default: 25."),
3846
- settleMs: z9.number().optional().describe("Milliseconds to wait after each click for the app to react. Default: 500."),
3847
- scope: z9.string().optional().describe("CSS selector or element ref to restrict crawling to a subtree."),
3848
- sessionId: z9.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4660
+ maxSteps: z10.number().optional().describe("Maximum number of controls to click. Default: 25."),
4661
+ settleMs: z10.number().optional().describe("Milliseconds to wait after each click for the app to react. Default: 500."),
4662
+ scope: z10.string().optional().describe("CSS selector or element ref to restrict crawling to a subtree."),
4663
+ confirmDangerous: z10.boolean().optional().describe("Set true to allow controls classified as destructive. Default false; those controls are blocked by the browser."),
4664
+ sessionId: z10.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3849
4665
  },
3850
4666
  outputSchema: {
3851
- interactiveFound: z9.number(),
3852
- stepsRun: z9.number(),
3853
- anomalies: z9.array(z9.object({
3854
- kind: z9.string(),
3855
- ref: z9.string(),
3856
- desc: z9.string(),
3857
- detail: z9.string().optional()
4667
+ interactiveFound: z10.number(),
4668
+ stepsRun: z10.number(),
4669
+ anomalies: z10.array(z10.object({
4670
+ kind: z10.string(),
4671
+ ref: z10.string(),
4672
+ desc: z10.string(),
4673
+ detail: z10.string().optional()
3858
4674
  })),
3859
- counts: z9.record(z9.number()),
3860
- truncated: z9.boolean()
4675
+ counts: z10.record(z10.number()),
4676
+ truncated: z10.boolean()
3861
4677
  },
3862
4678
  handler: (deps, args) => {
3863
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3864
- const maxSteps = asNumber2(args["maxSteps"]);
3865
- const settleMs = asNumber2(args["settleMs"]);
3866
- const scope = asString4(args["scope"]);
4679
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4680
+ const maxSteps = asNumber(args["maxSteps"]);
4681
+ const settleMs = asNumber(args["settleMs"]);
4682
+ const scope = asString(args["scope"]);
3867
4683
  const opts = {
3868
4684
  ...maxSteps !== void 0 ? { maxSteps } : {},
3869
4685
  ...settleMs !== void 0 ? { settleMs } : {},
3870
- ...scope !== void 0 ? { scope } : {}
4686
+ ...scope !== void 0 ? { scope } : {},
4687
+ ...args["confirmDangerous"] === true ? { confirmDangerous: true } : {}
3871
4688
  };
3872
4689
  return crawl(session, opts, nodeSleep2);
3873
4690
  }
@@ -3875,7 +4692,7 @@ var CRAWL_TOOLS = [
3875
4692
  ];
3876
4693
 
3877
4694
  // ../server/dist/input/scroll-tools.js
3878
- import { z as z10 } from "zod";
4695
+ import { z as z11 } from "zod";
3879
4696
 
3880
4697
  // ../server/dist/input/scroll-find.js
3881
4698
  async function queryFirst(session, q) {
@@ -3884,9 +4701,9 @@ async function queryFirst(session, q) {
3884
4701
  value: q.value,
3885
4702
  ...q.name !== void 0 ? { name: q.name } : {}
3886
4703
  });
3887
- const elements = asRecord3(res.result)["elements"];
4704
+ const elements = asRecord(res.result)["elements"];
3888
4705
  if (Array.isArray(elements) && elements.length > 0)
3889
- return asRecord3(elements[0]);
4706
+ return asRecord(elements[0]);
3890
4707
  return void 0;
3891
4708
  }
3892
4709
  async function scrollToFind(session, q, opts = {}) {
@@ -3905,7 +4722,7 @@ async function scrollToFind(session, q, opts = {}) {
3905
4722
  const hit = await queryFirst(session, q);
3906
4723
  if (hit !== void 0)
3907
4724
  return { found: true, element: hit, scrolls, exhausted: false };
3908
- const data = asRecord3(sr.result);
4725
+ const data = asRecord(sr.result);
3909
4726
  if (data["atEnd"] === true || data["scrolled"] === false) {
3910
4727
  return { found: false, scrolls, exhausted: true };
3911
4728
  }
@@ -3913,7 +4730,7 @@ async function scrollToFind(session, q, opts = {}) {
3913
4730
  for (let i = 0; i < max; i += 1) {
3914
4731
  const sr = await session.command(IrisCommand.SCROLL, q.container !== void 0 ? { ref: q.container } : {});
3915
4732
  scrolls += 1;
3916
- const data = asRecord3(sr.result);
4733
+ const data = asRecord(sr.result);
3917
4734
  const hit = await queryFirst(session, q);
3918
4735
  if (hit !== void 0)
3919
4736
  return { found: true, element: hit, scrolls, exhausted: false };
@@ -3930,58 +4747,58 @@ var SCROLL_TOOLS = [
3930
4747
  name: IrisTool.SCROLL_TO,
3931
4748
  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 }.",
3932
4749
  inputSchema: {
3933
- by: z10.string().describe("Query strategy for finding the target: role | text | testid | label | placeholder | alt"),
3934
- value: z10.string().describe("Query value for the selected strategy (the element to scroll into view)."),
3935
- name: z10.string().optional().describe("Optional accessible name filter when using by=role."),
3936
- container: z10.string().optional().describe("Element ref for the scrollable container. Omit to scroll the document."),
3937
- maxScrolls: z10.number().optional().describe("Maximum number of scroll steps before giving up. Default: 20."),
3938
- 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."),
3939
- totalCount: z10.number().optional().describe("Total item count in the virtualized list. Required for bisection with targetIndex."),
3940
- sessionId: z10.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4750
+ by: z11.string().describe("Query strategy for finding the target: role | text | testid | label | placeholder | alt"),
4751
+ value: z11.string().describe("Query value for the selected strategy (the element to scroll into view)."),
4752
+ name: z11.string().optional().describe("Optional accessible name filter when using by=role."),
4753
+ container: z11.string().optional().describe("Element ref for the scrollable container. Omit to scroll the document."),
4754
+ maxScrolls: z11.number().optional().describe("Maximum number of scroll steps before giving up. Default: 20."),
4755
+ 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."),
4756
+ totalCount: z11.number().optional().describe("Total item count in the virtualized list. Required for bisection with targetIndex."),
4757
+ sessionId: z11.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3941
4758
  },
3942
4759
  outputSchema: {
3943
- found: z10.boolean(),
3944
- element: z10.object({ ref: z10.string(), role: z10.string(), name: z10.string() }).optional(),
3945
- scrolls: z10.number(),
3946
- exhausted: z10.boolean()
4760
+ found: z11.boolean(),
4761
+ element: z11.object({ ref: z11.string(), role: z11.string(), name: z11.string() }).optional(),
4762
+ scrolls: z11.number(),
4763
+ exhausted: z11.boolean()
3947
4764
  },
3948
4765
  handler: (deps, args) => {
3949
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3950
- const name = asString4(args["name"]);
3951
- const container = asString4(args["container"]);
3952
- const targetIndex = asNumber2(args["targetIndex"]);
3953
- const totalCount = asNumber2(args["totalCount"]);
4766
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4767
+ const name = asString(args["name"]);
4768
+ const container = asString(args["container"]);
4769
+ const targetIndex = asNumber(args["targetIndex"]);
4770
+ const totalCount = asNumber(args["totalCount"]);
3954
4771
  const q = {
3955
- by: asString4(args["by"]) ?? "",
3956
- value: asString4(args["value"]) ?? "",
4772
+ by: asString(args["by"]) ?? "",
4773
+ value: asString(args["value"]) ?? "",
3957
4774
  ...name !== void 0 ? { name } : {},
3958
4775
  ...container !== void 0 ? { container } : {},
3959
4776
  ...targetIndex !== void 0 ? { targetIndex } : {},
3960
4777
  ...totalCount !== void 0 ? { totalCount } : {}
3961
4778
  };
3962
- const maxScrolls = asNumber2(args["maxScrolls"]);
4779
+ const maxScrolls = asNumber(args["maxScrolls"]);
3963
4780
  return scrollToFind(session, q, maxScrolls !== void 0 ? { maxScrolls } : {});
3964
4781
  }
3965
4782
  }
3966
4783
  ];
3967
4784
 
3968
4785
  // ../server/dist/session/session-tools.js
3969
- import { z as z11 } from "zod";
4786
+ import { z as z12 } from "zod";
3970
4787
  var SESSION_TOOLS = [
3971
4788
  {
3972
4789
  name: IrisTool.SESSION,
3973
4790
  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 }.",
3974
4791
  inputSchema: {
3975
- idleEndMs: z11.number().optional().describe("Idle window in milliseconds after which the presenter session auto-ends. Default: 300000 (5 min). Raise for slow apps."),
3976
- sessionId: z11.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4792
+ idleEndMs: z12.number().optional().describe("Idle window in milliseconds after which the presenter session auto-ends. Default: 300000 (5 min). Raise for slow apps."),
4793
+ sessionId: z12.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3977
4794
  },
3978
4795
  outputSchema: {
3979
- applied: z11.boolean(),
3980
- idleEndMs: z11.number().optional()
4796
+ applied: z12.boolean(),
4797
+ idleEndMs: z12.number().optional()
3981
4798
  },
3982
4799
  handler: async (deps, args) => {
3983
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3984
- const idleEndMs = asNumber2(args["idleEndMs"]);
4800
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4801
+ const idleEndMs = asNumber(args["idleEndMs"]);
3985
4802
  if (idleEndMs !== void 0)
3986
4803
  session.setIdleEndMs(idleEndMs);
3987
4804
  const res = await session.command(IrisCommand.SESSION_CONFIG, idleEndMs !== void 0 ? { idleEndMs } : {});
@@ -3993,7 +4810,7 @@ var SESSION_TOOLS = [
3993
4810
  ];
3994
4811
 
3995
4812
  // ../server/dist/flows/annotate-tools.js
3996
- import { z as z12 } from "zod";
4813
+ import { z as z13 } from "zod";
3997
4814
 
3998
4815
  // ../server/dist/flows/annotate.js
3999
4816
  function compileAnnotation(a, stepCount) {
@@ -4070,23 +4887,23 @@ var ANNOTATE_TOOLS = [
4070
4887
  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.",
4071
4888
  inputSchema: {
4072
4889
  // `flow` selects the recording; `name`/`signal`/`testid`/`dataMatches` are the annotation fields.
4073
- flow: z12.string().optional().describe("Named recording to annotate. Defaults to 'default'."),
4074
- kind: z12.string().describe("Annotation kind: assert-signal | assert-visible | mark-dynamic | success-state."),
4075
- name: z12.string().optional().describe("Signal name for assert-signal annotations."),
4076
- testid: z12.string().optional().describe("data-testid value for assert-visible / mark-dynamic / success-state annotations."),
4077
- signal: z12.string().optional().describe("Signal name for success-state annotations."),
4078
- dataMatches: z12.record(z12.unknown()).optional().describe("Key/value pairs the signal payload must match (assert-signal only)."),
4079
- sessionId: z12.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open."),
4080
- 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.")
4890
+ flow: z13.string().optional().describe("Named recording to annotate. Defaults to 'default'."),
4891
+ kind: z13.string().describe("Annotation kind: assert-signal | assert-visible | mark-dynamic | success-state."),
4892
+ name: z13.string().optional().describe("Signal name for assert-signal annotations."),
4893
+ testid: z13.string().optional().describe("data-testid value for assert-visible / mark-dynamic / success-state annotations."),
4894
+ signal: z13.string().optional().describe("Signal name for success-state annotations."),
4895
+ dataMatches: z13.record(z13.unknown()).optional().describe("Key/value pairs the signal payload must match (assert-signal only)."),
4896
+ sessionId: z13.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open."),
4897
+ 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.")
4081
4898
  },
4082
4899
  outputSchema: {
4083
- ok: z12.boolean(),
4084
- target: z12.string().optional(),
4085
- compiled: z12.string().optional(),
4086
- code: z12.string().optional()
4900
+ ok: z13.boolean(),
4901
+ target: z13.string().optional(),
4902
+ compiled: z13.string().optional(),
4903
+ code: z13.string().optional()
4087
4904
  },
4088
4905
  handler: (deps, args) => {
4089
- const name = asString4(args["flow"]) ?? DEFAULT_RECORDING;
4906
+ const name = asString(args["flow"]) ?? DEFAULT_RECORDING;
4090
4907
  const parsed = AnnotationSchema.safeParse(args);
4091
4908
  if (!parsed.success) {
4092
4909
  return Promise.resolve({ ok: false, code: AnnotationErrorCode.UNKNOWN_KIND });
@@ -4115,19 +4932,19 @@ var ANNOTATE_TOOLS = [
4115
4932
  ];
4116
4933
 
4117
4934
  // ../server/dist/session/live-control-tools.js
4118
- import { z as z13 } from "zod";
4935
+ import { z as z14 } from "zod";
4119
4936
  var sessionIdShape5 = {
4120
- sessionId: z13.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4937
+ sessionId: z14.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4121
4938
  };
4122
4939
  var LIVE_CONTROL_TOOLS = [
4123
4940
  {
4124
4941
  name: IrisTool.END_SESSION,
4125
4942
  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.',
4126
- inputSchema: { summary: z13.string().optional(), ...sessionIdShape5 },
4127
- outputSchema: { ended: z13.boolean(), sessionId: z13.string() },
4943
+ inputSchema: { summary: z14.string().optional(), ...sessionIdShape5 },
4944
+ outputSchema: { ended: z14.boolean(), sessionId: z14.string() },
4128
4945
  handler: (deps, args) => {
4129
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4130
- session.setState(SessionState.ENDED, asString4(args["summary"]));
4946
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4947
+ session.setState(SessionState.ENDED, asString(args["summary"]));
4131
4948
  return Promise.resolve({ ended: true, sessionId: session.id });
4132
4949
  }
4133
4950
  },
@@ -4135,9 +4952,9 @@ var LIVE_CONTROL_TOOLS = [
4135
4952
  name: IrisTool.RESUME,
4136
4953
  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.',
4137
4954
  inputSchema: { ...sessionIdShape5 },
4138
- outputSchema: { ok: z13.boolean() },
4955
+ outputSchema: { ok: z14.boolean() },
4139
4956
  handler: (deps, args) => {
4140
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4957
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4141
4958
  session.setState(SessionState.ACTIVE);
4142
4959
  return Promise.resolve({ ok: true });
4143
4960
  }
@@ -4146,9 +4963,9 @@ var LIVE_CONTROL_TOOLS = [
4146
4963
  name: IrisTool.MESSAGES,
4147
4964
  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.",
4148
4965
  inputSchema: { ...sessionIdShape5 },
4149
- outputSchema: { messages: z13.array(z13.unknown()) },
4966
+ outputSchema: { messages: z14.array(z14.unknown()) },
4150
4967
  handler: (deps, args) => {
4151
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4968
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4152
4969
  return Promise.resolve({ messages: session.drainInbox() });
4153
4970
  }
4154
4971
  }
@@ -4174,7 +4991,7 @@ function withControl(session, result) {
4174
4991
  }
4175
4992
 
4176
4993
  // ../server/dist/update/update-tools.js
4177
- import { z as z14 } from "zod";
4994
+ import { z as z15 } from "zod";
4178
4995
 
4179
4996
  // ../server/dist/update/update-checker.js
4180
4997
  import * as fs from "fs";
@@ -4368,14 +5185,14 @@ var UPDATE_TOOLS = [
4368
5185
  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.",
4369
5186
  inputSchema: {},
4370
5187
  outputSchema: {
4371
- currentVersion: z14.string().describe("The Iris server version currently running."),
4372
- latestVersion: z14.string().optional().describe("Latest published version on npm."),
4373
- updateAvailable: z14.boolean().describe("True when a newer version is available to install."),
4374
- executionKind: z14.string().describe('How iris was launched: "npx" (no install needed \u2014 restart applies update), "global" (npm install -g), or "local" (project node_modules).'),
4375
- changelog: z14.string().optional().describe("Release notes for the latest version."),
4376
- breakingChanges: z14.array(z14.string()).optional().describe("Breaking changes in the latest version that may affect your scripts."),
4377
- rollbackAvailable: z14.boolean().describe("True when a previous version is stored and can be restored."),
4378
- previousVersion: z14.string().optional().describe("The version that would be restored on rollback.")
5188
+ currentVersion: z15.string().describe("The Iris server version currently running."),
5189
+ latestVersion: z15.string().optional().describe("Latest published version on npm."),
5190
+ updateAvailable: z15.boolean().describe("True when a newer version is available to install."),
5191
+ 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).'),
5192
+ changelog: z15.string().optional().describe("Release notes for the latest version."),
5193
+ breakingChanges: z15.array(z15.string()).optional().describe("Breaking changes in the latest version that may affect your scripts."),
5194
+ rollbackAvailable: z15.boolean().describe("True when a previous version is stored and can be restored."),
5195
+ previousVersion: z15.string().optional().describe("The version that would be restored on rollback.")
4379
5196
  },
4380
5197
  handler: async (_deps) => {
4381
5198
  const manifest = await checkForUpdate(SERVER_VERSION);
@@ -4395,11 +5212,11 @@ var UPDATE_TOOLS = [
4395
5212
  name: IrisTool.APPLY_UPDATE,
4396
5213
  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.',
4397
5214
  inputSchema: {
4398
- confirm: z14.boolean().describe("Set to true to confirm the update should be applied. Required to prevent accidental upgrades.")
5215
+ confirm: z15.boolean().describe("Set to true to confirm the update should be applied. Required to prevent accidental upgrades.")
4399
5216
  },
4400
5217
  outputSchema: {
4401
- ok: z14.boolean(),
4402
- message: z14.string().optional()
5218
+ ok: z15.boolean(),
5219
+ message: z15.string().optional()
4403
5220
  },
4404
5221
  handler: async (_deps, args) => {
4405
5222
  if (args["confirm"] !== true) {
@@ -4417,11 +5234,11 @@ var UPDATE_TOOLS = [
4417
5234
  name: IrisTool.ROLLBACK,
4418
5235
  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.",
4419
5236
  inputSchema: {
4420
- confirm: z14.boolean().describe("Set to true to confirm the rollback. Required to prevent accidental downgrades.")
5237
+ confirm: z15.boolean().describe("Set to true to confirm the rollback. Required to prevent accidental downgrades.")
4421
5238
  },
4422
5239
  outputSchema: {
4423
- ok: z14.boolean(),
4424
- message: z14.string().optional()
5240
+ ok: z15.boolean(),
5241
+ message: z15.string().optional()
4425
5242
  },
4426
5243
  handler: async (_deps, args) => {
4427
5244
  if (args["confirm"] !== true) {
@@ -4443,7 +5260,7 @@ async function snapshotTree(deps, sessionId) {
4443
5260
  return { lines: normalizeLines(snap.tree ?? ""), route: snap.status?.route ?? "" };
4444
5261
  }
4445
5262
  var sessionIdShape6 = {
4446
- sessionId: z15.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open \u2014 Iris resolves it automatically.")
5263
+ sessionId: z16.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open \u2014 Iris resolves it automatically.")
4447
5264
  };
4448
5265
  async function commandOrThrow3(deps, sessionId, name, args) {
4449
5266
  const session = deps.sessions.resolve(sessionId);
@@ -4453,11 +5270,11 @@ async function commandOrThrow3(deps, sessionId, name, args) {
4453
5270
  return result.result;
4454
5271
  }
4455
5272
  function asBox2(value) {
4456
- const b = asRecord3(asRecord3(value)["box"]);
4457
- const x = asNumber2(b["x"]);
4458
- const y = asNumber2(b["y"]);
4459
- const w = asNumber2(b["width"]);
4460
- const h = asNumber2(b["height"]);
5273
+ const b = asRecord(asRecord(value)["box"]);
5274
+ const x = asNumber(b["x"]);
5275
+ const y = asNumber(b["y"]);
5276
+ const w = asNumber(b["width"]);
5277
+ const h = asNumber(b["height"]);
4461
5278
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
4462
5279
  return void 0;
4463
5280
  if (w <= 0 || h <= 0)
@@ -4473,29 +5290,51 @@ async function tryRealInput(deps, session, ref, action, args) {
4473
5290
  return synthetic();
4474
5291
  if (!isPointerAction(action))
4475
5292
  return synthetic(InputModeReason.NOT_POINTER);
4476
- const inner = asRecord3(args["args"]);
5293
+ const inner = asRecord(args["args"]);
4477
5294
  if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && inner["native"] !== true) {
4478
5295
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
4479
5296
  }
4480
5297
  if (!await provider.isAvailableFor(session.url))
4481
5298
  return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
4482
- const box = asBox2(await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref }));
5299
+ const inspected = await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref });
5300
+ const confirmed = inner[DANGEROUS_ACTION_CONFIRM_ARG] === true;
5301
+ const dangerousDescriptorText = (value2) => {
5302
+ const descriptor = asRecord(value2);
5303
+ return [
5304
+ asString(descriptor["name"]) ?? "",
5305
+ asString(descriptor["text"]) ?? "",
5306
+ asString(descriptor["value"]) ?? "",
5307
+ asString(descriptor["href"]) ?? "",
5308
+ asString(descriptor["formAction"]) ?? "",
5309
+ asString(descriptor["formText"]) ?? ""
5310
+ ].join(" ");
5311
+ };
5312
+ if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !confirmed && isDangerousActionText(dangerousDescriptorText(inspected))) {
5313
+ throw new Error(`potentially destructive native action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
5314
+ }
5315
+ const box = asBox2(inspected);
4483
5316
  if (box === void 0)
4484
5317
  return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
4485
5318
  let toBox;
4486
5319
  if (action === ActionType.DRAG) {
4487
- const toRef = asString4(inner["toRef"]);
5320
+ const toRef = asString(inner["toRef"]);
4488
5321
  if (toRef === void 0)
4489
5322
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4490
- toBox = asBox2(await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref: toRef }));
5323
+ const targetInspected = await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, {
5324
+ ref: toRef
5325
+ });
5326
+ if (!confirmed && isDangerousActionText(`${dangerousDescriptorText(inspected)} ${dangerousDescriptorText(targetInspected)}`)) {
5327
+ throw new Error(`potentially destructive native action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
5328
+ }
5329
+ toBox = asBox2(targetInspected);
4491
5330
  if (toBox === void 0)
4492
5331
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4493
5332
  }
4494
5333
  const performArgs = {};
4495
- const value = asString4(inner["value"]);
5334
+ const value = asString(inner["value"]);
4496
5335
  if (value !== void 0)
4497
5336
  performArgs.value = value;
4498
- const text = asString4(inner["text"]);
5337
+ const text = asString(inner["text"]);
4499
5338
  if (text !== void 0)
4500
5339
  performArgs.text = text;
4501
5340
  if (toBox !== void 0)
@@ -4514,23 +5353,24 @@ async function tryRealInput(deps, session, ref, action, args) {
4514
5353
  };
4515
5354
  }
4516
5355
  }
5356
+ var SNAPSHOT_CACHE = new SnapshotCache();
4517
5357
  var TOOLS = [
4518
5358
  {
4519
5359
  name: IrisTool.SESSIONS,
4520
5360
  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.",
4521
5361
  inputSchema: {},
4522
5362
  outputSchema: {
4523
- sessions: z15.array(z15.object({
4524
- sessionId: z15.string(),
4525
- url: z15.string(),
4526
- title: z15.string().optional(),
4527
- lastSeenMs: z15.number(),
4528
- throttled: z15.boolean(),
4529
- focused: z15.boolean(),
4530
- hidden: z15.boolean(),
4531
- realInputAvailable: z15.boolean().optional(),
4532
- stale: z15.boolean().optional(),
4533
- recommendation: z15.string().optional()
5363
+ sessions: z16.array(z16.object({
5364
+ sessionId: z16.string(),
5365
+ url: z16.string(),
5366
+ title: z16.string().optional(),
5367
+ lastSeenMs: z16.number(),
5368
+ throttled: z16.boolean(),
5369
+ focused: z16.boolean(),
5370
+ hidden: z16.boolean(),
5371
+ realInputAvailable: z16.boolean().optional(),
5372
+ stale: z16.boolean().optional(),
5373
+ recommendation: z16.string().optional()
4534
5374
  })).describe("Connected browser sessions with health state.")
4535
5375
  },
4536
5376
  handler: async (deps) => {
@@ -4544,71 +5384,95 @@ var TOOLS = [
4544
5384
  },
4545
5385
  {
4546
5386
  name: IrisTool.SNAPSHOT,
4547
- description: "Semantic accessibility snapshot of the page or a subtree. mode: full|interactive|status. Use to see what is on screen right now.",
5387
+ 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.",
4548
5388
  inputSchema: {
4549
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the snapshot to a subtree. Omit to snapshot the whole page."),
4550
- mode: z15.nativeEnum(SnapshotMode).optional().describe("full = all elements; interactive = only clickable/focusable elements; status = only route + title. Default: full."),
5389
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the snapshot to a subtree. Omit to snapshot the whole page."),
5390
+ mode: z16.nativeEnum(SnapshotMode).optional().describe("full = all elements; interactive = only clickable/focusable elements; status = only route + title. Default: full."),
5391
+ 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."),
4551
5392
  ...sessionIdShape6
4552
5393
  },
4553
5394
  outputSchema: {
4554
- tree: z15.string().optional().describe("Indented ARIA tree of every element on the page (or the scoped subtree)."),
4555
- status: z15.object({ route: z15.string(), title: z15.string().optional() }).optional()
5395
+ tree: z16.string().optional().describe("Indented ARIA tree of every element on the page (or the scoped subtree)."),
5396
+ status: z16.object({ route: z16.string(), title: z16.string().optional() }).optional(),
5397
+ mode: z16.string().optional().describe("delta | unchanged when diff:true returned a change set."),
5398
+ delta: z16.object({
5399
+ added: z16.array(z16.string()),
5400
+ removed: z16.array(z16.string()),
5401
+ addedCount: z16.number(),
5402
+ removedCount: z16.number()
5403
+ }).optional().describe("Only present on a diff:true call that found changes."),
5404
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional().describe("Estimated size of this result \u2014 re-scope if large.")
4556
5405
  },
4557
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.SNAPSHOT, {
4558
- scope: args["scope"],
4559
- mode: args["mode"] ?? SnapshotMode.FULL
4560
- })
5406
+ handler: (deps, args) => {
5407
+ const sessionId = asString(args["sessionId"]);
5408
+ const mode = asString(args["mode"]) ?? SnapshotMode.FULL;
5409
+ return commandOrThrow3(deps, sessionId, IrisCommand.SNAPSHOT, {
5410
+ scope: args["scope"],
5411
+ mode
5412
+ }).then((raw) => withSizeCost(applySnapshotDelta(raw, {
5413
+ sessionId: sessionId ?? "default",
5414
+ scope: asString(args["scope"]) ?? "",
5415
+ mode,
5416
+ diff: args["diff"] === true
5417
+ }, SNAPSHOT_CACHE)));
5418
+ }
4561
5419
  },
4562
5420
  {
4563
5421
  name: IrisTool.QUERY,
4564
- 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.",
5422
+ 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.",
4565
5423
  inputSchema: {
4566
- by: z15.string().describe("Query strategy: role | text | label | placeholder | testid | alt"),
4567
- value: z15.string().describe("Query value for the selected strategy (e.g. by=role value=button, or by=testid value=submit-btn)."),
4568
- name: z15.string().optional().describe("Accessible name filter \u2014 narrows results when `by` is role and the page has many elements of that role."),
4569
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the search to a subtree."),
5424
+ by: z16.string().describe("Query strategy: role | text | label | placeholder | testid | alt"),
5425
+ value: z16.string().describe("Query value for the selected strategy (e.g. by=role value=button, or by=testid value=submit-btn)."),
5426
+ name: z16.string().optional().describe("Accessible name filter \u2014 narrows results when `by` is role and the page has many elements of that role."),
5427
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the search to a subtree."),
5428
+ 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."),
5429
+ 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.'),
4570
5430
  ...sessionIdShape6
4571
5431
  },
4572
5432
  outputSchema: {
4573
- elements: z15.array(z15.object({
4574
- ref: z15.string(),
4575
- role: z15.string(),
4576
- name: z15.string(),
4577
- value: z15.string().optional(),
4578
- states: z15.array(z15.string()),
4579
- visible: z15.boolean()
4580
- })),
4581
- hint: z15.object({
4582
- route: z15.string(),
4583
- presentTestids: z15.array(z15.string()),
4584
- knownEmptyState: z15.boolean()
4585
- }).optional().describe("Present only on zero matches \u2014 tells you what IS on the page so you can diagnose the miss.")
5433
+ elements: z16.array(z16.object({
5434
+ ref: z16.string(),
5435
+ role: z16.string(),
5436
+ name: z16.string(),
5437
+ value: z16.string().optional(),
5438
+ states: z16.array(z16.string()),
5439
+ visible: z16.boolean()
5440
+ })).optional(),
5441
+ count: z16.number().optional().describe("Match count \u2014 present when count_only is set."),
5442
+ total: z16.number().optional().describe("Total matches before `limit` truncation \u2014 present only when truncated."),
5443
+ truncated: z16.boolean().optional().describe("True when `limit` dropped some matches."),
5444
+ hint: z16.object({
5445
+ route: z16.string(),
5446
+ presentTestids: z16.array(z16.string()),
5447
+ knownEmptyState: z16.boolean()
5448
+ }).optional().describe("Present only on zero matches \u2014 tells you what IS on the page so you can diagnose the miss."),
5449
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional().describe("Estimated size of this result \u2014 narrow with `name`/`scope`/`limit` if large.")
4586
5450
  },
4587
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.QUERY, {
5451
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.QUERY, {
4588
5452
  by: args["by"],
4589
5453
  value: args["value"],
4590
5454
  name: args["name"],
4591
5455
  scope: args["scope"]
4592
- })
5456
+ }).then((result) => withSizeCost(paginateQueryResult(result, asNumber(args["limit"]), args["count_only"] === true)))
4593
5457
  },
4594
5458
  {
4595
5459
  name: IrisTool.INSPECT,
4596
5460
  description: "Deep info on one element by ref: full a11y props, visibility, box, and (with @syrin/iris-react) component stack + source file.",
4597
5461
  inputSchema: {
4598
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
5462
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
4599
5463
  ...sessionIdShape6
4600
5464
  },
4601
5465
  outputSchema: {
4602
- ref: z15.string(),
4603
- role: z15.string(),
4604
- name: z15.string(),
4605
- value: z15.string().optional(),
4606
- states: z15.array(z15.string()),
4607
- visible: z15.boolean(),
4608
- box: z15.object({ x: z15.number(), y: z15.number(), width: z15.number(), height: z15.number() }).optional(),
4609
- component: z15.object({ name: z15.string().optional(), sourceFile: z15.string().optional() }).optional()
5466
+ ref: z16.string(),
5467
+ role: z16.string(),
5468
+ name: z16.string(),
5469
+ value: z16.string().optional(),
5470
+ states: z16.array(z16.string()),
5471
+ visible: z16.boolean(),
5472
+ box: z16.object({ x: z16.number(), y: z16.number(), width: z16.number(), height: z16.number() }).optional(),
5473
+ component: z16.object({ name: z16.string().optional(), sourceFile: z16.string().optional() }).optional()
4610
5474
  },
4611
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.INSPECT, {
5475
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.INSPECT, {
4612
5476
  ref: args["ref"]
4613
5477
  })
4614
5478
  },
@@ -4616,30 +5480,30 @@ var TOOLS = [
4616
5480
  name: IrisTool.ACT,
4617
5481
  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.',
4618
5482
  inputSchema: {
4619
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
4620
- action: z15.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4621
- args: z15.record(z15.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click."),
4622
- refuseWhenThrottled: z15.boolean().optional().describe("Throw instead of silently sending synthetic events when the tab is throttled/backgrounded. Default: false (synthetic events are still sent)."),
5483
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
5484
+ action: z16.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
5485
+ 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."),
5486
+ 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)."),
4623
5487
  ...sessionIdShape6
4624
5488
  },
4625
5489
  outputSchema: {
4626
- since: z15.number().describe("Cursor \u2014 pass to iris_observe/iris_wait_for/iris_assert to scope reaction queries to this act."),
4627
- dispatched: z15.boolean(),
4628
- settled: z15.boolean().nullable(),
4629
- inputMode: z15.string(),
4630
- result: z15.unknown().optional(),
4631
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5490
+ since: z16.number().describe("Cursor \u2014 pass to iris_observe/iris_wait_for/iris_assert to scope reaction queries to this act."),
5491
+ dispatched: z16.boolean(),
5492
+ settled: z16.boolean().nullable(),
5493
+ inputMode: z16.string(),
5494
+ result: z16.unknown().optional(),
5495
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4632
5496
  },
4633
5497
  handler: async (deps, args) => {
4634
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5498
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4635
5499
  const paused = pausedShortCircuit(session);
4636
5500
  if (paused !== void 0)
4637
5501
  return paused;
4638
5502
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4639
5503
  const since = session.elapsed();
4640
5504
  session.markActCursor(since);
4641
- const ref = asString4(args["ref"]) ?? "";
4642
- const action = asString4(args["action"]) ?? "";
5505
+ const ref = asString(args["ref"]) ?? "";
5506
+ const action = asString(args["action"]) ?? "";
4643
5507
  const real = await tryRealInput(deps, session, ref, action, args);
4644
5508
  if (real.result !== void 0) {
4645
5509
  if (deps.recordings.active().length > 0) {
@@ -4665,7 +5529,7 @@ var TOOLS = [
4665
5529
  if (deps.recordings.active().length > 0) {
4666
5530
  deps.recordings.capture(compileActStep(args, result.result));
4667
5531
  }
4668
- const r = asRecord3(result.result);
5532
+ const r = asRecord(result.result);
4669
5533
  return withControl(session, {
4670
5534
  since,
4671
5535
  inputMode: InputMode.SYNTHETIC,
@@ -4684,17 +5548,17 @@ var TOOLS = [
4684
5548
  name: IrisTool.ACT_SEQUENCE,
4685
5549
  description: "Run multiple actions in order (fill -> fill -> submit) in one round-trip. Returns per-step effects[] (see iris_act).",
4686
5550
  inputSchema: {
4687
- steps: z15.array(z15.record(z15.unknown())).describe("Ordered list of { ref, action, args? } objects. Each step is equivalent to one iris_act call."),
5551
+ 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."),
4688
5552
  ...sessionIdShape6
4689
5553
  },
4690
5554
  outputSchema: {
4691
- since: z15.number(),
4692
- dispatched: z15.boolean(),
4693
- result: z15.unknown().optional(),
4694
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5555
+ since: z16.number(),
5556
+ dispatched: z16.boolean(),
5557
+ result: z16.unknown().optional(),
5558
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4695
5559
  },
4696
5560
  handler: async (deps, args) => {
4697
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5561
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4698
5562
  const paused = pausedShortCircuit(session);
4699
5563
  if (paused !== void 0)
4700
5564
  return paused;
@@ -4706,7 +5570,7 @@ var TOOLS = [
4706
5570
  if (deps.recordings.active().length > 0) {
4707
5571
  deps.recordings.capture(compileSequenceStep(args, result.result));
4708
5572
  }
4709
- const r = asRecord3(result.result);
5573
+ const r = asRecord(result.result);
4710
5574
  return withControl(session, {
4711
5575
  since,
4712
5576
  dispatched: r["count"] !== void 0,
@@ -4717,34 +5581,34 @@ var TOOLS = [
4717
5581
  },
4718
5582
  {
4719
5583
  name: IrisTool.ACT_AND_WAIT,
4720
- 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.",
5584
+ 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.",
4721
5585
  inputSchema: {
4722
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query."),
4723
- action: z15.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4724
- args: z15.record(z15.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press."),
4725
- until: PredicateSchema.describe("Predicate to wait for after the action completes. Same shape accepted by iris_assert."),
4726
- timeout_ms: z15.number().optional().describe("Maximum wait time in milliseconds. 0 = evaluate once without waiting. Default: 4000."),
4727
- refuseWhenThrottled: z15.boolean().optional().describe("Throw if the tab is throttled. Default: false."),
5586
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query."),
5587
+ action: z16.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
5588
+ 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."),
5589
+ 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" }] }.'),
5590
+ timeout_ms: z16.number().optional().describe("Maximum wait time in milliseconds. 0 = evaluate once without waiting. Default: 4000."),
5591
+ refuseWhenThrottled: z16.boolean().optional().describe("Throw if the tab is throttled. Default: false."),
4728
5592
  ...sessionIdShape6
4729
5593
  },
4730
5594
  outputSchema: {
4731
- effect: z15.unknown().describe("The iris_act result (dispatched, settled, inputMode, etc.)."),
4732
- verdict: z15.object({
4733
- pass: z15.boolean(),
4734
- evidence: z15.unknown().optional(),
4735
- failureReason: z15.string().optional()
5595
+ effect: z16.unknown().describe("The iris_act result (dispatched, settled, inputMode, etc.)."),
5596
+ verdict: z16.object({
5597
+ pass: z16.boolean(),
5598
+ evidence: z16.unknown().optional(),
5599
+ failureReason: z16.string().optional()
4736
5600
  }),
4737
- trace: z15.unknown().describe("Reaction report (same shape as iris_observe summary)."),
4738
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5601
+ trace: z16.unknown().describe("Reaction report (same shape as iris_observe summary)."),
5602
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4739
5603
  },
4740
5604
  handler: async (deps, args) => {
4741
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5605
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4742
5606
  const paused = pausedShortCircuit(session);
4743
5607
  if (paused !== void 0)
4744
5608
  return paused;
4745
5609
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4746
- const until = PredicateSchema.parse(args["until"]);
4747
- const timeout = asNumber2(args["timeout_ms"]) ?? 4e3;
5610
+ const until = args["until"] !== void 0 ? PredicateSchema.parse(args["until"]) : { kind: "settled" };
5611
+ const timeout = asNumber(args["timeout_ms"]) ?? 4e3;
4748
5612
  const since = session.elapsed();
4749
5613
  session.markActCursor(since);
4750
5614
  const actResult = await session.command(IrisCommand.ACT, {
@@ -4768,40 +5632,41 @@ var TOOLS = [
4768
5632
  name: IrisTool.OBSERVE,
4769
5633
  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.",
4770
5634
  inputSchema: {
4771
- window_ms: z15.number().optional().describe("Time window to look back. Default: 2000ms. Ignored when `since` is provided."),
4772
- since: z15.number().optional().describe("Cursor from a prior iris_act or iris_observe call. Scopes the event window to exactly that span."),
4773
- filters: z15.array(z15.string()).optional().describe("Event type allowlist: dom | net | route | console | animation | signal. Omit to return all types."),
4774
- max_events: z15.number().optional().describe("Cap the timeline to the most recent N events. Older events are counted in cost.droppedOldest."),
5635
+ window_ms: z16.number().optional().describe("Time window to look back. Default: 2000ms. Ignored when `since` is provided."),
5636
+ since: z16.number().optional().describe("Cursor from a prior iris_act or iris_observe call. Scopes the event window to exactly that span."),
5637
+ filters: z16.array(z16.string()).optional().describe("Event type allowlist: dom | net | route | console | animation | signal. Omit to return all types."),
5638
+ max_events: z16.number().optional().describe("Cap the timeline to the most recent N events. Older events are counted in cost.droppedOldest."),
4775
5639
  ...sessionIdShape6
4776
5640
  },
4777
5641
  outputSchema: {
4778
- events: z15.array(z15.unknown()),
4779
- summary: z15.object({
4780
- total: z15.number(),
4781
- network: z15.number(),
4782
- domAdded: z15.number(),
4783
- domRemoved: z15.number(),
4784
- domChanged: z15.number(),
4785
- routeChanges: z15.number(),
4786
- consoleErrors: z15.number(),
4787
- animations: z15.number(),
4788
- signals: z15.number()
5642
+ events: z16.array(z16.unknown()),
5643
+ summary: z16.object({
5644
+ total: z16.number(),
5645
+ network: z16.number(),
5646
+ domAdded: z16.number(),
5647
+ domRemoved: z16.number(),
5648
+ domChanged: z16.number(),
5649
+ routeChanges: z16.number(),
5650
+ consoleErrors: z16.number(),
5651
+ animations: z16.number(),
5652
+ signals: z16.number()
4789
5653
  }),
4790
- cost: z15.object({
4791
- events: z15.number(),
4792
- bytes: z15.number(),
4793
- droppedOldest: z15.number().optional()
5654
+ cost: z16.object({
5655
+ events: z16.number(),
5656
+ bytes: z16.number(),
5657
+ droppedOldest: z16.number().optional(),
5658
+ recommendation: z16.string().optional().describe("Present when the timeline is large \u2014 scope your next call (filters/max_events).")
4794
5659
  }),
4795
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5660
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4796
5661
  },
4797
5662
  handler: (deps, args) => {
4798
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4799
- const since = asNumber2(args["since"]);
4800
- const windowMs = asNumber2(args["window_ms"]) ?? 2e3;
5663
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5664
+ const since = asNumber(args["since"]);
5665
+ const windowMs = asNumber(args["window_ms"]) ?? 2e3;
4801
5666
  const events = since !== void 0 ? session.eventsSince(since) : session.eventsInWindow(windowMs);
4802
5667
  const filters = Array.isArray(args["filters"]) ? args["filters"] : void 0;
4803
5668
  const filtered = filters === void 0 ? events : events.filter((e) => filters.includes(e.type));
4804
- const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber2(args["max_events"]));
5669
+ const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber(args["max_events"]));
4805
5670
  const report = buildReactionReport(budgeted, windowMs);
4806
5671
  return Promise.resolve(withControl(session, {
4807
5672
  ...report,
@@ -4814,99 +5679,113 @@ var TOOLS = [
4814
5679
  name: IrisTool.WAIT_FOR,
4815
5680
  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.",
4816
5681
  inputSchema: {
4817
- predicate: PredicateSchema.describe("Predicate to wait for: { signal }, { net }, { element } or a combination."),
4818
- timeout_ms: z15.number().optional().describe("Maximum wait in milliseconds. Default: 4000."),
4819
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the wait to events after that act."),
5682
+ 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.'),
5683
+ timeout_ms: z16.number().optional().describe("Maximum wait in milliseconds. Default: 4000."),
5684
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the wait to events after that act."),
4820
5685
  ...sessionIdShape6
4821
5686
  },
4822
5687
  outputSchema: {
4823
- pass: z15.boolean(),
4824
- evidence: z15.unknown().optional(),
4825
- failureReason: z15.string().optional(),
4826
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5688
+ pass: z16.boolean(),
5689
+ evidence: z16.unknown().optional(),
5690
+ failureReason: z16.string().optional(),
5691
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4827
5692
  },
4828
5693
  handler: async (deps, args) => {
4829
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5694
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4830
5695
  const predicate = PredicateSchema.parse(args["predicate"]);
4831
- const since = asNumber2(args["since"]) ?? session.lastActCursor() ?? 0;
4832
- const verdict = await waitForPredicate(session, predicate, asNumber2(args["timeout_ms"]) ?? 4e3, since);
5696
+ const since = asNumber(args["since"]) ?? session.lastActCursor() ?? 0;
5697
+ const verdict = await waitForPredicate(session, predicate, asNumber(args["timeout_ms"]) ?? 4e3, since);
4833
5698
  return withControl(session, { ...verdict, ...healthEnvelope(session) });
4834
5699
  }
4835
5700
  },
4836
5701
  {
4837
5702
  name: IrisTool.ASSERT,
4838
- 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.",
5703
+ 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.",
4839
5704
  inputSchema: {
4840
5705
  predicate: PredicateSchema.describe("Predicate to evaluate: { signal }, { net }, { element } or a combination."),
4841
- timeout_ms: z15.number().optional().describe("If > 0, wait up to this many milliseconds before failing. Default: 0 (evaluate once)."),
4842
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the assertion to events after that act."),
5706
+ timeout_ms: z16.number().optional().describe("If > 0, wait up to this many milliseconds before failing. Default: 0 (evaluate once)."),
5707
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the assertion to events after that act."),
4843
5708
  ...sessionIdShape6
4844
5709
  },
4845
5710
  outputSchema: {
4846
- pass: z15.boolean(),
4847
- evidence: z15.unknown().optional(),
4848
- failureReason: z15.string().optional(),
4849
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5711
+ pass: z16.boolean(),
5712
+ evidence: z16.unknown().optional(),
5713
+ failureReason: z16.string().optional(),
5714
+ advice: z16.string().optional().describe("Present on a PASSING presence-only assertion \u2014 nudges toward a consequence."),
5715
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4850
5716
  },
4851
5717
  handler: async (deps, args) => {
4852
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5718
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4853
5719
  const predicate = PredicateSchema.parse(args["predicate"]);
4854
- const timeout = asNumber2(args["timeout_ms"]) ?? 0;
4855
- const since = asNumber2(args["since"]) ?? session.lastActCursor() ?? 0;
5720
+ const timeout = asNumber(args["timeout_ms"]) ?? 0;
5721
+ const since = asNumber(args["since"]) ?? session.lastActCursor() ?? 0;
4856
5722
  const verdict = timeout > 0 ? await waitForPredicate(session, predicate, timeout, since) : await evaluatePredicate(session, predicate, since);
4857
- return withControl(session, { ...verdict, ...healthEnvelope(session) });
5723
+ const advice = verdict.pass && isPresenceOnlyAssertion(predicate) ? { advice: PRESENCE_ONLY_ADVICE } : {};
5724
+ return withControl(session, { ...verdict, ...advice, ...healthEnvelope(session) });
4858
5725
  }
4859
5726
  },
4860
5727
  {
4861
5728
  name: IrisTool.NETWORK,
4862
5729
  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.',
4863
5730
  inputSchema: {
4864
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to requests fired after that act."),
4865
- method: z15.string().optional().describe("HTTP method filter: GET | POST | PUT | DELETE | PATCH etc."),
4866
- urlContains: z15.string().optional().describe("Substring that the request URL must contain."),
4867
- status: z15.number().optional().describe("HTTP status code filter (e.g. 200, 404, 500)."),
5731
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to requests fired after that act."),
5732
+ method: z16.string().optional().describe("HTTP method filter: GET | POST | PUT | DELETE | PATCH etc."),
5733
+ urlContains: z16.string().optional().describe("Substring that the request URL must contain."),
5734
+ status: z16.number().optional().describe("HTTP status code filter (e.g. 200, 404, 500)."),
5735
+ 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."),
4868
5736
  ...sessionIdShape6
4869
5737
  },
4870
5738
  outputSchema: {
4871
- calls: z15.array(z15.unknown()),
4872
- hint: z15.object({ totalInWindow: z15.number(), present: z15.array(z15.string()) }).optional()
5739
+ calls: z16.array(z16.unknown()),
5740
+ total: z16.number().optional().describe("Total matches before `limit` \u2014 present only when capped."),
5741
+ droppedOldest: z16.number().optional().describe("How many older matches `limit` dropped."),
5742
+ hint: z16.object({ totalInWindow: z16.number(), present: z16.array(z16.string()) }).optional(),
5743
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional()
4873
5744
  },
4874
5745
  handler: (deps, args) => {
4875
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4876
- const since = asNumber2(args["since"]) ?? 0;
4877
- const method = asString4(args["method"]);
4878
- const urlContains = asString4(args["urlContains"]);
4879
- const status = asNumber2(args["status"]);
5746
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5747
+ const since = asNumber(args["since"]) ?? 0;
5748
+ const method = asString(args["method"]);
5749
+ const urlContains = asString(args["urlContains"]);
5750
+ const status = asNumber(args["status"]);
5751
+ const limit = asNumber(args["limit"]);
4880
5752
  const allNet = session.eventsSince(since).filter((e) => e.type === EventType.NET_REQUEST);
4881
- const calls = allNet.filter((e) => matchNet(e, method, urlContains, status));
4882
- if (calls.length === 0 && allNet.length > 0) {
4883
- return Promise.resolve({ calls, hint: netEmptyHint(allNet) });
5753
+ const matched = allNet.filter((e) => matchNet(e, method, urlContains, status));
5754
+ if (matched.length === 0 && allNet.length > 0) {
5755
+ return Promise.resolve(withSizeCost({ calls: matched, hint: netEmptyHint(allNet) }));
4884
5756
  }
4885
- return Promise.resolve({ calls });
5757
+ const { events: calls, droppedOldest } = applyEventBudget(matched, limit);
5758
+ return Promise.resolve(withSizeCost(droppedOldest > 0 ? { calls, total: matched.length, droppedOldest } : { calls }));
4886
5759
  }
4887
5760
  },
4888
5761
  {
4889
5762
  name: IrisTool.CONSOLE,
4890
5763
  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.',
4891
5764
  inputSchema: {
4892
- level: z15.string().optional().describe("Log level filter: error | warn | info | log. Omit to return all levels."),
4893
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to log entries after that act."),
5765
+ level: z16.string().optional().describe("Log level filter: error | warn | info | log. Omit to return all levels."),
5766
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to log entries after that act."),
5767
+ 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."),
4894
5768
  ...sessionIdShape6
4895
5769
  },
4896
5770
  outputSchema: {
4897
- logs: z15.array(z15.unknown()),
4898
- hint: z15.object({ totalInWindow: z15.number(), byLevel: z15.record(z15.number()) }).optional()
5771
+ logs: z16.array(z16.unknown()),
5772
+ total: z16.number().optional().describe("Total matches before `limit` \u2014 present only when capped."),
5773
+ droppedOldest: z16.number().optional().describe("How many older matches `limit` dropped."),
5774
+ hint: z16.object({ totalInWindow: z16.number(), byLevel: z16.record(z16.number()) }).optional(),
5775
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional()
4899
5776
  },
4900
5777
  handler: (deps, args) => {
4901
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4902
- const since = asNumber2(args["since"]) ?? 0;
4903
- const level = asString4(args["level"]);
5778
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5779
+ const since = asNumber(args["since"]) ?? 0;
5780
+ const level = asString(args["level"]);
5781
+ const limit = asNumber(args["limit"]);
4904
5782
  const allConsole = session.eventsSince(since).filter(isConsoleEvent);
4905
- const logs = allConsole.filter((e) => matchConsole(e, level));
4906
- if (logs.length === 0 && allConsole.length > 0) {
4907
- return Promise.resolve({ logs, hint: consoleEmptyHint(allConsole) });
5783
+ const matched = allConsole.filter((e) => matchConsole(e, level));
5784
+ if (matched.length === 0 && allConsole.length > 0) {
5785
+ return Promise.resolve(withSizeCost({ logs: matched, hint: consoleEmptyHint(allConsole) }));
4908
5786
  }
4909
- return Promise.resolve({ logs });
5787
+ const { events: logs, droppedOldest } = applyEventBudget(matched, limit);
5788
+ return Promise.resolve(withSizeCost(droppedOldest > 0 ? { logs, total: matched.length, droppedOldest } : { logs }));
4910
5789
  }
4911
5790
  },
4912
5791
  {
@@ -4914,24 +5793,24 @@ var TOOLS = [
4914
5793
  description: "Currently running + recently completed animations with targets/timing.",
4915
5794
  inputSchema: { ...sessionIdShape6 },
4916
5795
  outputSchema: {
4917
- animations: z15.array(z15.unknown())
5796
+ animations: z16.array(z16.unknown())
4918
5797
  },
4919
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.ANIMATIONS, {})
5798
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.ANIMATIONS, {})
4920
5799
  },
4921
5800
  {
4922
5801
  name: IrisTool.BASELINE_SAVE,
4923
5802
  description: "Snapshot the current semantic state under a name, to diff against later (regression detection).",
4924
5803
  inputSchema: {
4925
- name: z15.string().describe('Label for this baseline snapshot (e.g. "dashboard-initial"). Use the same name in iris_diff to compare.'),
5804
+ name: z16.string().describe('Label for this baseline snapshot (e.g. "dashboard-initial"). Use the same name in iris_diff to compare.'),
4926
5805
  ...sessionIdShape6
4927
5806
  },
4928
5807
  outputSchema: {
4929
- baseline: z15.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
4930
- lineCount: z15.number()
5808
+ baseline: z16.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
5809
+ lineCount: z16.number()
4931
5810
  },
4932
5811
  handler: async (deps, args) => {
4933
- const name = asString4(args["name"]) ?? "default";
4934
- const { lines, route } = await snapshotTree(deps, asString4(args["sessionId"]));
5812
+ const name = asString(args["name"]) ?? "default";
5813
+ const { lines, route } = await snapshotTree(deps, asString(args["sessionId"]));
4935
5814
  deps.baselines.save({ name, lines, route });
4936
5815
  return { baseline: name, lineCount: lines.length };
4937
5816
  }
@@ -4941,7 +5820,7 @@ var TOOLS = [
4941
5820
  description: "List saved baseline names.",
4942
5821
  inputSchema: {},
4943
5822
  outputSchema: {
4944
- baselines: z15.array(z15.string())
5823
+ baselines: z16.array(z16.string())
4945
5824
  },
4946
5825
  handler: (deps) => Promise.resolve({ baselines: deps.baselines.list() })
4947
5826
  },
@@ -4949,23 +5828,23 @@ var TOOLS = [
4949
5828
  name: IrisTool.DIFF,
4950
5829
  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?".',
4951
5830
  inputSchema: {
4952
- baseline: z15.string().describe("Baseline name to compare against. Call iris_baseline_list to get available names; names are created by iris_baseline_save."),
5831
+ baseline: z16.string().describe("Baseline name to compare against. Call iris_baseline_list to get available names; names are created by iris_baseline_save."),
4953
5832
  ...sessionIdShape6
4954
5833
  },
4955
5834
  outputSchema: {
4956
- baseline: z15.string(),
4957
- removed: z15.array(z15.string()),
4958
- added: z15.array(z15.string()),
4959
- consoleErrors: z15.number(),
4960
- routeChanged: z15.boolean()
5835
+ baseline: z16.string(),
5836
+ removed: z16.array(z16.string()),
5837
+ added: z16.array(z16.string()),
5838
+ consoleErrors: z16.number(),
5839
+ routeChanged: z16.boolean()
4961
5840
  },
4962
5841
  handler: async (deps, args) => {
4963
- const name = asString4(args["baseline"]) ?? "default";
5842
+ const name = asString(args["baseline"]) ?? "default";
4964
5843
  const base = deps.baselines.get(name);
4965
5844
  if (base === void 0)
4966
5845
  throw new Error(`no baseline named '${name}'`);
4967
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4968
- const { lines, route } = await snapshotTree(deps, asString4(args["sessionId"]));
5846
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5847
+ const { lines, route } = await snapshotTree(deps, asString(args["sessionId"]));
4969
5848
  const { removed, added } = diffLines(base.lines, lines);
4970
5849
  const consoleErrors = session.eventsSince(0).filter((e) => e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT).length;
4971
5850
  return { baseline: name, removed, added, consoleErrors, routeChanged: base.route !== route };
@@ -4975,16 +5854,16 @@ var TOOLS = [
4975
5854
  name: IrisTool.RECORD_START,
4976
5855
  description: "Start recording the event timeline under a name (for replay / a flow report).",
4977
5856
  inputSchema: {
4978
- recordingName: z15.string().describe("Identifier for this recording. Pass the same name to iris_record_stop and iris_replay."),
5857
+ recordingName: z16.string().describe("Identifier for this recording. Pass the same name to iris_record_stop and iris_replay."),
4979
5858
  ...sessionIdShape6
4980
5859
  },
4981
5860
  outputSchema: {
4982
- recordingName: z15.string(),
4983
- since: z15.number()
5861
+ recordingName: z16.string(),
5862
+ since: z16.number()
4984
5863
  },
4985
5864
  handler: (deps, args) => {
4986
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4987
- const name = asString4(args["recordingName"]) ?? "default";
5865
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5866
+ const name = asString(args["recordingName"]) ?? "default";
4988
5867
  const cursor = session.elapsed();
4989
5868
  deps.recordings.start(name, cursor);
4990
5869
  return Promise.resolve({ recordingName: name, since: cursor });
@@ -4994,17 +5873,17 @@ var TOOLS = [
4994
5873
  name: IrisTool.RECORD_STOP,
4995
5874
  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.",
4996
5875
  inputSchema: {
4997
- recordingName: z15.string().describe("Identifier of an active recording started with iris_record_start."),
5876
+ recordingName: z16.string().describe("Identifier of an active recording started with iris_record_start."),
4998
5877
  ...sessionIdShape6
4999
5878
  },
5000
5879
  outputSchema: {
5001
- recordingName: z15.string(),
5002
- program: z15.unknown(),
5003
- warning: z15.string().optional()
5880
+ recordingName: z16.string(),
5881
+ program: z16.unknown(),
5882
+ warning: z16.string().optional()
5004
5883
  },
5005
5884
  handler: (deps, args) => {
5006
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5007
- const name = asString4(args["recordingName"]) ?? "default";
5885
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5886
+ const name = asString(args["recordingName"]) ?? "default";
5008
5887
  const rec = deps.recordings.stop(name);
5009
5888
  if (rec === void 0)
5010
5889
  throw new Error(`no active recording named '${name}'`);
@@ -5030,29 +5909,30 @@ var TOOLS = [
5030
5909
  },
5031
5910
  {
5032
5911
  name: IrisTool.REPLAY,
5033
- 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?}] }.",
5912
+ 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?}] }.",
5034
5913
  inputSchema: {
5035
- recordingName: z15.string().describe("Name of a compiled recording (from iris_record_stop) to re-execute."),
5914
+ recordingName: z16.string().describe("Name of a compiled recording (from iris_record_stop) to re-execute."),
5915
+ confirmDangerous: z16.boolean().optional().describe("Set true to allow destructive controls during this replay only."),
5036
5916
  ...sessionIdShape6
5037
5917
  },
5038
5918
  outputSchema: {
5039
- recordingName: z15.string(),
5040
- ok: z15.boolean(),
5041
- steps: z15.array(z15.object({
5042
- tool: z15.string(),
5043
- ok: z15.boolean(),
5044
- error: z15.string().optional(),
5045
- note: z15.string().optional()
5919
+ recordingName: z16.string(),
5920
+ ok: z16.boolean(),
5921
+ steps: z16.array(z16.object({
5922
+ tool: z16.string(),
5923
+ ok: z16.boolean(),
5924
+ error: z16.string().optional(),
5925
+ note: z16.string().optional()
5046
5926
  }))
5047
5927
  },
5048
5928
  handler: async (deps, args) => {
5049
- const name = asString4(args["recordingName"]) ?? "default";
5929
+ const name = asString(args["recordingName"]) ?? "default";
5050
5930
  const program = deps.recordings.getCompiled(name);
5051
5931
  if (program === void 0)
5052
5932
  throw new Error(`no compiled recording named '${name}'`);
5053
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5933
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5054
5934
  const since = session.elapsed();
5055
- const steps = await replayProgram(session, program);
5935
+ const steps = await replayProgram(session, program, args["confirmDangerous"] === true);
5056
5936
  return { recordingName: name, since, steps, ok: steps.every((s) => s.ok) };
5057
5937
  }
5058
5938
  },
@@ -5060,13 +5940,13 @@ var TOOLS = [
5060
5940
  name: IrisTool.NARRATE,
5061
5941
  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.",
5062
5942
  inputSchema: {
5063
- text: z15.string().describe("Short sentence describing your next action, shown on the presenter HUD for the developer watching."),
5064
- level: z15.string().optional().describe("Display severity: info | warn | error. Default: info."),
5943
+ text: z16.string().describe("Short sentence describing your next action, shown on the presenter HUD for the developer watching."),
5944
+ level: z16.string().optional().describe("Display severity: info | warn | error. Default: info."),
5065
5945
  ...sessionIdShape6
5066
5946
  },
5067
- outputSchema: { ok: z15.boolean() },
5947
+ outputSchema: { ok: z16.boolean() },
5068
5948
  handler: async (deps, args) => {
5069
- const result = await commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.NARRATE, {
5949
+ const result = await commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.NARRATE, {
5070
5950
  text: args["text"],
5071
5951
  level: args["level"]
5072
5952
  });
@@ -5077,16 +5957,16 @@ var TOOLS = [
5077
5957
  name: IrisTool.CLOCK,
5078
5958
  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.",
5079
5959
  inputSchema: {
5080
- freeze: z15.boolean().optional().describe("Freeze the fake clock. Time stops advancing until advanceMs or reset."),
5081
- advanceMs: z15.number().optional().describe("Fast-forward time by this many milliseconds \u2014 triggers debounces, toasts, auto-dismiss timers."),
5082
- reset: z15.boolean().optional().describe("Restore the real clock."),
5960
+ freeze: z16.boolean().optional().describe("Freeze the fake clock. Time stops advancing until advanceMs or reset."),
5961
+ advanceMs: z16.number().optional().describe("Fast-forward time by this many milliseconds \u2014 triggers debounces, toasts, auto-dismiss timers."),
5962
+ reset: z16.boolean().optional().describe("Restore the real clock."),
5083
5963
  ...sessionIdShape6
5084
5964
  },
5085
5965
  outputSchema: {
5086
- ok: z15.boolean().optional(),
5087
- elapsed: z15.number().optional()
5966
+ ok: z16.boolean().optional(),
5967
+ elapsed: z16.number().optional()
5088
5968
  },
5089
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.CLOCK, {
5969
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.CLOCK, {
5090
5970
  freeze: args["freeze"],
5091
5971
  advanceMs: args["advanceMs"],
5092
5972
  reset: args["reset"]
@@ -5096,27 +5976,27 @@ var TOOLS = [
5096
5976
  name: IrisTool.STATE,
5097
5977
  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? }.",
5098
5978
  inputSchema: {
5099
- ref: z15.string().optional().describe("Element ref \u2014 attempts a best-effort read of the nearest React component's hook state."),
5100
- store: z15.string().optional().describe("Registered store name (e.g. 'workspace'). Omit to read all stores."),
5101
- path: z15.string().optional().describe("Dot-path into the store (e.g. 'captionCache.v3'). Numeric array indices are supported."),
5102
- depth: z15.number().optional().describe("Collapse anything deeper than N levels to a size marker \u2014 avoids huge outputs for large stores."),
5979
+ ref: z16.string().optional().describe("Element ref \u2014 attempts a best-effort read of the nearest React component's hook state."),
5980
+ store: z16.string().optional().describe("Registered store name (e.g. 'workspace'). Omit to read all stores."),
5981
+ path: z16.string().optional().describe("Dot-path into the store (e.g. 'captionCache.v3'). Numeric array indices are supported."),
5982
+ depth: z16.number().optional().describe("Collapse anything deeper than N levels to a size marker \u2014 avoids huge outputs for large stores."),
5103
5983
  ...sessionIdShape6
5104
5984
  },
5105
5985
  outputSchema: {
5106
- stores: z15.record(z15.unknown()).optional(),
5107
- storeNames: z15.array(z15.string()).optional(),
5108
- found: z15.boolean().optional(),
5109
- value: z15.unknown().optional(),
5110
- component: z15.object({ ok: z15.boolean(), reason: z15.string().optional(), state: z15.unknown().optional() }).optional()
5986
+ stores: z16.record(z16.unknown()).optional(),
5987
+ storeNames: z16.array(z16.string()).optional(),
5988
+ found: z16.boolean().optional(),
5989
+ value: z16.unknown().optional(),
5990
+ component: z16.object({ ok: z16.boolean(), reason: z16.string().optional(), state: z16.unknown().optional() }).optional()
5111
5991
  },
5112
5992
  handler: async (deps, args) => {
5113
- const store = asString4(args["store"]);
5114
- const result = await commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.STATE_READ, {
5993
+ const store = asString(args["store"]);
5994
+ const result = await commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.STATE_READ, {
5115
5995
  ref: args["ref"],
5116
5996
  store
5117
5997
  });
5118
- const path = asString4(args["path"]);
5119
- const depth = asNumber2(args["depth"]);
5998
+ const path = asString(args["path"]);
5999
+ const depth = asNumber(args["depth"]);
5120
6000
  if (path === void 0 && depth === void 0)
5121
6001
  return result;
5122
6002
  const root = result;
@@ -5136,16 +6016,16 @@ var TOOLS = [
5136
6016
  name: IrisTool.EXPLORE,
5137
6017
  description: "Autonomous-exploration helper: list interactive elements (with refs) + current console-error count, so the agent can drive the app and report anomalies.",
5138
6018
  inputSchema: {
5139
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the interactive element list to a subtree."),
6019
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the interactive element list to a subtree."),
5140
6020
  ...sessionIdShape6
5141
6021
  },
5142
6022
  outputSchema: {
5143
- interactive: z15.array(z15.unknown()),
5144
- consoleErrors: z15.number(),
5145
- hint: z15.string()
6023
+ interactive: z16.array(z16.unknown()),
6024
+ consoleErrors: z16.number(),
6025
+ hint: z16.string()
5146
6026
  },
5147
6027
  handler: async (deps, args) => {
5148
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6028
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5149
6029
  const result = await session.command(IrisCommand.SNAPSHOT, {
5150
6030
  mode: SnapshotMode.INTERACTIVE,
5151
6031
  scope: args["scope"]
@@ -5163,6 +6043,7 @@ var TOOLS = [
5163
6043
  },
5164
6044
  // iris_capabilities (live | fromDisk) + iris_contract_save. See contract-tools.ts.
5165
6045
  ...CONTRACT_TOOLS,
6046
+ ...DOMAIN_TOOLS,
5166
6047
  // iris_flow_save / iris_flow_list / iris_flow_load. See flow-tools.ts.
5167
6048
  ...FLOW_TOOLS,
5168
6049
  // iris_project (read history + diff-vs-last) / iris_run_record. See project-tools.ts.
@@ -5316,7 +6197,7 @@ async function runTool(tool, deps, args) {
5316
6197
  return result;
5317
6198
  if (!isPlainObject(result) || "session" in result)
5318
6199
  return result;
5319
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6200
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5320
6201
  const envelope = { ...healthEnvelope(session) };
5321
6202
  const lease = session.takeSessionLease();
5322
6203
  if (lease !== void 0)
@@ -5486,7 +6367,17 @@ function isRunning(port) {
5486
6367
  // ../server/dist/index.js
5487
6368
  async function start(options = {}) {
5488
6369
  const port = options.port ?? IRIS_DEFAULT_PORT;
5489
- const bridge = new Bridge({ port });
6370
+ const envToken = process.env["IRIS_TOKEN"];
6371
+ const envOrigins = process.env["IRIS_ALLOWED_ORIGINS"];
6372
+ const host = options.host ?? process.env["IRIS_HOST"];
6373
+ const token = options.token ?? (envToken !== void 0 && envToken.length > 0 ? envToken : void 0);
6374
+ const allowedOrigins = options.allowedOrigins ?? envOrigins?.split(",").map((origin) => origin.trim()).filter((origin) => origin.length > 0);
6375
+ const bridge = new Bridge({
6376
+ port,
6377
+ ...host === void 0 ? {} : { host },
6378
+ ...token === void 0 ? {} : { token },
6379
+ ...allowedOrigins === void 0 ? {} : { allowedOrigins }
6380
+ });
5490
6381
  const reaper = new SessionReaper(bridge.sessions);
5491
6382
  reaper.start();
5492
6383
  const baselines = new BaselineStore();
@@ -5622,6 +6513,7 @@ export {
5622
6513
  CORE_TOOL_NAMES,
5623
6514
  CdpRealInputProvider,
5624
6515
  DriveError,
6516
+ FlowAssertionGrade,
5625
6517
  FlowStore,
5626
6518
  IrisTool,
5627
6519
  LaunchedRealInputProvider,
@@ -5640,15 +6532,19 @@ export {
5640
6532
  TOOL_PROFILE_ENV,
5641
6533
  UNKNOWN_TOOL_ERROR,
5642
6534
  VisualStore,
6535
+ assertSuccess,
5643
6536
  baselinePath,
5644
6537
  boxCenter,
6538
+ buildDomainModel,
5645
6539
  buildReactionReport,
5646
6540
  buildSessionRecommendation,
6541
+ classifyFlowAssertions,
5647
6542
  crawl,
5648
6543
  createNodeFileSystem,
5649
6544
  createToolInvoker,
5650
6545
  diffLines,
5651
6546
  diffPng,
6547
+ dynamicTestids,
5652
6548
  ensureIrisDir,
5653
6549
  evaluatePredicate,
5654
6550
  filterTools,
@@ -5671,6 +6567,8 @@ export {
5671
6567
  scrollToFind,
5672
6568
  start,
5673
6569
  startDaemon,
6570
+ successLabel,
6571
+ successToPredicate,
5674
6572
  waitForPredicate,
5675
6573
  writeContract,
5676
6574
  writePid