@syrin/iris 0.4.0 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,10 +1,34 @@
1
1
  // ../server/dist/index.js
2
- import { join as join2 } from "path";
2
+ import { join as join5 } from "path";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
 
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",
@@ -47,6 +71,7 @@ var CRAWL_DEFAULTS = {
47
71
  /** HTTP status at/above which a response counts as a failed request. */
48
72
  FAILED_STATUS: 400
49
73
  };
74
+ var UpdateCheckIntervalMs = 24 * 60 * 60 * 1e3;
50
75
  var CONTRACT_FILE_VERSION = 1;
51
76
  var FROM_DISK_ARG = "fromDisk";
52
77
  var ContractReadError = {
@@ -105,7 +130,7 @@ var ReplayStatus = {
105
130
  DRIFT: "drift",
106
131
  // an anchor missed (testid renamed / signal not observed) — legible drift returned
107
132
  ERROR: "error"
108
- // the flow file could not be loaded (missing/invalid) no steps ran
133
+ // the flow could not load or a resolved action failed
109
134
  };
110
135
  var DriftReason = {
111
136
  TESTID_NOT_FOUND: "testid_not_found",
@@ -139,8 +164,10 @@ var HealStatus = {
139
164
  // drift exists but no proposal cleared the confidence floor
140
165
  NOTHING_TO_HEAL: "nothing_to_heal",
141
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)
142
169
  ERROR: "error"
143
- // flow missing/malformed/invalid-name no steps ran
170
+ // flow missing/malformed/invalid-name, or a resolved action failed
144
171
  };
145
172
  var HEAL_CONFIDENCE_MIN = 0.5;
146
173
  var AnnotationTarget = {
@@ -156,7 +183,8 @@ var AnnotationErrorCode = {
156
183
  var COMPILED_PREDICATE_PREFIX = "will";
157
184
  var RING_BUFFER_DEFAULTS = {
158
185
  MAX_EVENTS: 2e3,
159
- MAX_AGE_MS: 6e4
186
+ MAX_AGE_MS: 6e4,
187
+ MAX_BYTES: TRANSPORT_LIMITS.MAX_BUFFER_BYTES
160
188
  };
161
189
  var EventType = {
162
190
  DOM_ADDED: "dom.added",
@@ -341,6 +369,8 @@ var MessageKind = {
341
369
 
342
370
  // ../protocol/dist/messages.js
343
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);
344
374
  var HumanControlDataSchema = z.object({
345
375
  kind: z.nativeEnum(HumanControlKind),
346
376
  text: z.string().optional()
@@ -348,35 +378,37 @@ var HumanControlDataSchema = z.object({
348
378
  var IrisEventSchema = z.object({
349
379
  t: z.number(),
350
380
  type: z.nativeEnum(EventType),
351
- sessionId: z.string(),
381
+ sessionId: sessionIdSchema,
352
382
  /** Stable element reference this event concerns, when applicable (e.g. "e7"). */
353
- ref: z.string().optional(),
383
+ ref: refSchema.optional(),
354
384
  /** Event-type-specific payload. Kept open here; refined per observer at the edges. */
355
385
  data: z.record(z.unknown()).default({})
356
386
  });
357
387
  var HelloMessageSchema = z.object({
358
388
  kind: z.literal(MessageKind.HELLO),
359
- protocolVersion: z.number(),
360
- sessionId: z.string(),
361
- url: z.string(),
362
- title: z.string(),
363
- 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(),
364
396
  /** Whether the app has advertised a capability registry (iris.describe). */
365
397
  hasCapabilities: z.boolean().optional()
366
398
  });
367
399
  var CommandMessageSchema = z.object({
368
400
  kind: z.literal(MessageKind.COMMAND),
369
- id: z.string(),
370
- sessionId: z.string().optional(),
371
- 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),
372
404
  args: z.record(z.unknown()).default({})
373
405
  });
374
406
  var CommandResultSchema = z.object({
375
407
  kind: z.literal(MessageKind.COMMAND_RESULT),
376
- id: z.string(),
408
+ id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH),
377
409
  ok: z.boolean(),
378
410
  result: z.unknown().optional(),
379
- error: z.string().optional()
411
+ error: z.string().max(TRANSPORT_LIMITS.MAX_ERROR_LENGTH).optional()
380
412
  });
381
413
  var EventMessageSchema = z.object({
382
414
  kind: z.literal(MessageKind.EVENT),
@@ -389,6 +421,25 @@ var IrisMessageSchema = z.discriminatedUnion("kind", [
389
421
  EventMessageSchema
390
422
  ]);
391
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
+
392
443
  // ../protocol/dist/toon.js
393
444
  var ROLE_MAP = {
394
445
  button: "btn",
@@ -649,21 +700,116 @@ var AnnotationSchema = z2.discriminatedUnion("kind", [
649
700
  })
650
701
  ]);
651
702
 
703
+ // ../server/dist/http-server.js
704
+ import * as http from "http";
705
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
706
+
707
+ // ../server/dist/log.js
708
+ function log(event, fields = {}) {
709
+ const line = JSON.stringify({ event, ...fields });
710
+ process.stderr.write(`${line}
711
+ `);
712
+ }
713
+
714
+ // ../server/dist/http-server.js
715
+ var MCP_SSE_PATH = "/mcp/sse";
716
+ var MCP_MESSAGE_PATH = "/mcp/message";
717
+ function createSharedServer() {
718
+ let mcpFactory;
719
+ const transports = /* @__PURE__ */ new Map();
720
+ const httpServer = http.createServer((req, res) => {
721
+ const url = req.url ?? "/";
722
+ if (req.method === "GET" && url === MCP_SSE_PATH) {
723
+ if (mcpFactory === void 0) {
724
+ res.writeHead(503, { "Content-Type": "text/plain" });
725
+ res.end("MCP server not ready");
726
+ return;
727
+ }
728
+ const mcpServer = mcpFactory();
729
+ const transport = new SSEServerTransport(MCP_MESSAGE_PATH, res);
730
+ const sid = transport.sessionId;
731
+ transports.set(sid, transport);
732
+ res.on("close", () => {
733
+ transports.delete(sid);
734
+ transport.close().catch(() => void 0);
735
+ mcpServer.close().catch(() => void 0);
736
+ log("mcp_client_disconnected", { sessionId: sid });
737
+ });
738
+ mcpServer.connect(transport).then(() => {
739
+ log("mcp_client_connected", { sessionId: sid });
740
+ }).catch((err) => {
741
+ const message = err instanceof Error ? err.message : String(err);
742
+ log("mcp_connect_error", { error: message });
743
+ });
744
+ return;
745
+ }
746
+ if (req.method === "POST" && url.startsWith(MCP_MESSAGE_PATH)) {
747
+ const parsed = new URL(url, "http://localhost");
748
+ const sessionId = parsed.searchParams.get("sessionId");
749
+ if (sessionId === null) {
750
+ res.writeHead(400, { "Content-Type": "text/plain" });
751
+ res.end("missing sessionId");
752
+ return;
753
+ }
754
+ const transport = transports.get(sessionId);
755
+ if (transport === void 0) {
756
+ res.writeHead(404, { "Content-Type": "text/plain" });
757
+ res.end("session not found");
758
+ return;
759
+ }
760
+ transport.handlePostMessage(req, res).catch((err) => {
761
+ const message = err instanceof Error ? err.message : String(err);
762
+ log("mcp_message_error", { error: message });
763
+ });
764
+ return;
765
+ }
766
+ res.writeHead(404, { "Content-Type": "text/plain" });
767
+ res.end("not found");
768
+ });
769
+ function attachMcp(factory) {
770
+ mcpFactory = factory;
771
+ }
772
+ async function close() {
773
+ for (const transport of transports.values()) {
774
+ await transport.close();
775
+ }
776
+ transports.clear();
777
+ await new Promise((resolve, reject) => {
778
+ httpServer.close((err) => {
779
+ if (err !== void 0 && err !== null)
780
+ reject(err);
781
+ else
782
+ resolve();
783
+ });
784
+ });
785
+ }
786
+ return { httpServer, attachMcp, close };
787
+ }
788
+
652
789
  // ../server/dist/bridge.js
790
+ import { timingSafeEqual } from "crypto";
791
+ import * as http2 from "http";
653
792
  import { WebSocketServer } from "ws";
654
793
 
655
794
  // ../server/dist/events/ring-buffer.js
656
795
  var RingBuffer = class {
657
796
  #maxEvents;
658
797
  #maxAgeMs;
798
+ #maxBytes;
659
799
  #events = [];
800
+ #eventBytes = [];
801
+ #totalBytes = 0;
660
802
  #droppedCount = 0;
661
803
  constructor(options = {}) {
662
804
  this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
663
805
  this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
806
+ this.#maxBytes = options.maxBytes ?? RING_BUFFER_DEFAULTS.MAX_BYTES;
664
807
  }
665
808
  push(event, now) {
666
809
  this.#events.push(event);
810
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8");
811
+ this.#eventBytes.push(bytes);
812
+ this.#totalBytes += bytes;
667
813
  this.#evict(now);
668
814
  }
669
815
  /** Events at or after a given timestamp cursor. */
@@ -689,10 +835,14 @@ var RingBuffer = class {
689
835
  #evict(now) {
690
836
  const before = this.#events.length;
691
837
  const cutoff = now - this.#maxAgeMs;
692
- if (this.#events.length > this.#maxEvents) {
693
- 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;
694
845
  }
695
- this.#events = this.#events.filter((e) => e.t >= cutoff);
696
846
  this.#droppedCount += before - this.#events.length;
697
847
  }
698
848
  /** Snapshot of buffer health for the agent — total events held and cumulative drops since connect. */
@@ -929,6 +1079,14 @@ var Session = class {
929
1079
  this.#pending.delete(id);
930
1080
  }
931
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
+ }
932
1090
  // ── Live-control: state machine + human→agent inbox (server-owned) ───────────────
933
1091
  getState() {
934
1092
  return this.#state;
@@ -1050,11 +1208,15 @@ var Session = class {
1050
1208
  var SessionManager = class {
1051
1209
  #sessions = /* @__PURE__ */ new Map();
1052
1210
  add(session) {
1211
+ const previous = this.#sessions.get(session.id);
1053
1212
  this.#sessions.set(session.id, session);
1213
+ return previous;
1054
1214
  }
1055
- remove(sessionId) {
1056
- this.#sessions.get(sessionId)?.rejectAll("session disconnected");
1057
- 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);
1058
1220
  }
1059
1221
  get(sessionId) {
1060
1222
  return this.#sessions.get(sessionId);
@@ -1071,7 +1233,16 @@ var SessionManager = class {
1071
1233
  }
1072
1234
  /**
1073
1235
  * Resolve the target session. With an explicit id, returns it. With none and exactly
1074
- * one connected, returns that. Otherwise throws a clear, agent-readable error.
1236
+ * one connected, returns that.
1237
+ *
1238
+ * With none and multiple connected, applies smart auto-selection:
1239
+ * 1. Prefer non-throttled sessions (not hidden + recently heard from).
1240
+ * 2. Within each tier, prefer lowest lastSeenMs (most recently active SDK heartbeat).
1241
+ * 3. If two or more non-throttled sessions are within 1 s of each other, throw —
1242
+ * genuinely ambiguous, agent must specify sessionId.
1243
+ * 4. If ALL sessions are throttled (e.g. user is working in their editor on another
1244
+ * desktop), skip the gap check and pick the freshest heartbeat. This lets the agent
1245
+ * keep working in the background without requiring sessionId every time.
1075
1246
  */
1076
1247
  resolve(sessionId) {
1077
1248
  if (sessionId !== void 0) {
@@ -1085,26 +1256,48 @@ var SessionManager = class {
1085
1256
  if (this.#sessions.size === 0) {
1086
1257
  throw new Error("no browser session connected \u2014 is your app running with @syrin/iris-browser enabled?");
1087
1258
  }
1088
- if (this.#sessions.size > 1) {
1089
- const ids = [...this.#sessions.keys()].join(", ");
1090
- throw new Error(`multiple sessions connected (${ids}); pass sessionId to target one`);
1259
+ const all = [...this.#sessions.values()];
1260
+ if (all.length === 1) {
1261
+ const [only] = all;
1262
+ if (only === void 0)
1263
+ throw new Error("session lookup failed");
1264
+ only.markAgentActivity();
1265
+ return only;
1091
1266
  }
1092
- const [only] = this.#sessions.values();
1093
- if (only === void 0)
1267
+ const scored = all.map((s) => ({ s, score: s.throttled() ? 1 : 0, ms: s.lastSeenMs() }));
1268
+ const bestScore = Math.min(...scored.map((x) => x.score));
1269
+ const candidates = scored.filter((x) => x.score === bestScore);
1270
+ candidates.sort((a, b) => a.ms - b.ms);
1271
+ const [best, runnerUp] = candidates;
1272
+ if (best === void 0)
1094
1273
  throw new Error("session lookup failed");
1095
- only.markAgentActivity();
1096
- return only;
1274
+ const allThrottled = bestScore === 1;
1275
+ const RECENCY_GAP_MS = allThrottled ? 0 : 1e3;
1276
+ const clearWinner = runnerUp === void 0 || best.ms + RECENCY_GAP_MS < runnerUp.ms;
1277
+ if (!clearWinner) {
1278
+ const detail = all.map((s) => `${s.id} (${s.throttled() ? "throttled" : "active"}, lastSeenMs=${s.lastSeenMs()})`).join(", ");
1279
+ throw new Error(`multiple sessions connected \u2014 pass sessionId to target one: ${detail}`);
1280
+ }
1281
+ best.s.markAgentActivity();
1282
+ return best.s;
1097
1283
  }
1098
1284
  };
1099
1285
 
1100
- // ../server/dist/log.js
1101
- function log(event, fields = {}) {
1102
- const line = JSON.stringify({ event, ...fields });
1103
- process.stderr.write(`${line}
1104
- `);
1105
- }
1106
-
1107
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
+ }
1108
1301
  function rawToString(raw) {
1109
1302
  if (typeof raw === "string")
1110
1303
  return raw;
@@ -1120,31 +1313,132 @@ var Bridge = class {
1120
1313
  ready;
1121
1314
  #wss;
1122
1315
  #clock;
1316
+ #token;
1317
+ #allowedOrigins;
1318
+ #maxMessagesPerSecond;
1319
+ #maxSessions;
1320
+ #maxPendingConnections;
1321
+ #helloTimeoutMs;
1322
+ #pendingConnections = 0;
1123
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
+ }
1124
1331
  this.#clock = options.clock ?? (() => Date.now());
1125
- this.#wss = new WebSocketServer({
1126
- port: options.port,
1127
- host: options.host ?? "127.0.0.1",
1128
- path: IRIS_WS_PATH
1129
- });
1130
- this.ready = new Promise((resolve) => {
1131
- this.#wss.on("listening", () => {
1132
- resolve(this.#wss.address().port);
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;
1338
+ if (options.server !== void 0) {
1339
+ const srv = options.server;
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
+ }
1133
1350
  });
1134
- });
1351
+ this.ready = new Promise((resolve) => {
1352
+ if (srv.listening) {
1353
+ resolve(srv.address().port);
1354
+ } else {
1355
+ srv.once("listening", () => {
1356
+ resolve(srv.address().port);
1357
+ });
1358
+ }
1359
+ });
1360
+ } else {
1361
+ this.#wss = new WebSocketServer({
1362
+ port: options.port,
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
+ }
1372
+ });
1373
+ this.ready = new Promise((resolve) => {
1374
+ this.#wss.on("listening", () => {
1375
+ resolve(this.#wss.address().port);
1376
+ });
1377
+ });
1378
+ }
1135
1379
  this.#wss.on("connection", (socket) => {
1136
1380
  this.#onConnection(socket);
1137
1381
  });
1138
1382
  }
1139
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;
1140
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);
1141
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
+ }
1142
1417
  const parsed = this.#parse(rawToString(raw));
1143
- if (parsed === null)
1418
+ if (parsed === null) {
1419
+ socket.close(1008, "invalid message");
1144
1420
  return;
1421
+ }
1145
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();
1146
1439
  session = new Session(parsed, socket, this.#clock);
1147
- this.sessions.add(session);
1440
+ const replaced = this.sessions.add(session);
1441
+ replaced?.disconnect("session replaced by a newer connection");
1148
1442
  log("session_connected", { sessionId: session.id, url: session.url });
1149
1443
  return;
1150
1444
  }
@@ -1158,15 +1452,28 @@ var Bridge = class {
1158
1452
  }
1159
1453
  });
1160
1454
  socket.on("close", () => {
1455
+ clearTimeout(helloTimer);
1456
+ releasePending();
1161
1457
  if (session !== void 0) {
1162
- this.sessions.remove(session.id);
1163
- log("session_disconnected", { sessionId: session.id });
1458
+ if (this.sessions.remove(session)) {
1459
+ log("session_disconnected", { sessionId: session.id });
1460
+ }
1164
1461
  }
1165
1462
  });
1166
1463
  socket.on("error", (err) => {
1167
1464
  log("socket_error", { error: err.message });
1168
1465
  });
1169
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
+ }
1170
1477
  #parse(text) {
1171
1478
  let json;
1172
1479
  try {
@@ -1183,6 +1490,8 @@ var Bridge = class {
1183
1490
  }
1184
1491
  close() {
1185
1492
  return new Promise((resolve) => {
1493
+ for (const client of this.#wss.clients)
1494
+ client.terminate();
1186
1495
  this.#wss.close(() => {
1187
1496
  resolve();
1188
1497
  });
@@ -1297,6 +1606,7 @@ var IrisTool = {
1297
1606
  STATE: "iris_state",
1298
1607
  CAPABILITIES: "iris_capabilities",
1299
1608
  CONTRACT_SAVE: "iris_contract_save",
1609
+ DOMAIN: "iris_domain",
1300
1610
  FLOW_SAVE: "iris_flow_save",
1301
1611
  FLOW_LIST: "iris_flow_list",
1302
1612
  FLOW_LOAD: "iris_flow_load",
@@ -1330,151 +1640,526 @@ var IrisTool = {
1330
1640
  /** Navigate the connected browser tab to a URL. */
1331
1641
  NAVIGATE: "iris_navigate",
1332
1642
  /** Reload the connected browser tab (soft or hard). */
1333
- REFRESH: "iris_refresh"
1643
+ REFRESH: "iris_refresh",
1644
+ /** Report running version, latest available, changelog, and breaking changes. */
1645
+ VERSION_INFO: "iris_version_info",
1646
+ /** Install the latest server version and restart (Claude Code reconnects automatically). */
1647
+ APPLY_UPDATE: "iris_apply_update",
1648
+ /** Restore the previous server version and restart. */
1649
+ ROLLBACK: "iris_rollback"
1334
1650
  };
1335
1651
 
1336
- // ../server/dist/project/iris-dir.js
1337
- import { join } from "path";
1338
- function irisDirPaths(root) {
1339
- return {
1340
- root,
1341
- contract: join(root, IrisDir.CONTRACT_FILE),
1342
- flows: join(root, IrisDir.FLOWS_SUBDIR),
1343
- baselines: join(root, IrisDir.BASELINES_SUBDIR),
1344
- project: join(root, IrisDir.PROJECT_FILE),
1345
- visual: join(root, IrisDir.VISUAL_SUBDIR)
1346
- };
1347
- }
1348
- function visualPath(root, name) {
1349
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.png`);
1350
- }
1351
- function visualDiffPath(root, name) {
1352
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.diff.png`);
1353
- }
1354
- function flowPath(root, name) {
1355
- return join(root, IrisDir.FLOWS_SUBDIR, `${name}.json`);
1356
- }
1357
- function isValidFlowName(name) {
1358
- return FLOW_NAME_PATTERN.test(name) && !name.includes("..");
1359
- }
1360
- function baselinePath(root, name) {
1361
- return join(root, IrisDir.BASELINES_SUBDIR, `${name}.json`);
1362
- }
1363
- async function ensureIrisDir(fs, root) {
1364
- const p = irisDirPaths(root);
1365
- await fs.mkdir(p.root);
1366
- await fs.mkdir(p.flows);
1367
- await fs.mkdir(p.baselines);
1368
- }
1369
- var JSON_INDENT = 2;
1370
- function stableSerialize(capabilities, generatedAt) {
1371
- const envelope = {
1372
- version: CONTRACT_FILE_VERSION,
1373
- generatedAt,
1374
- capabilities: {
1375
- testids: [...capabilities.testids].sort(),
1376
- signals: [...capabilities.signals].sort(),
1377
- stores: [...capabilities.stores].sort(),
1378
- 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() });
1379
1659
  }
1380
- };
1381
- return `${JSON.stringify(envelope, null, JSON_INDENT)}
1382
- `;
1383
- }
1384
- async function writeContract(fs, root, capabilities, now) {
1385
- await ensureIrisDir(fs, root);
1386
- await fs.writeFile(irisDirPaths(root).contract, stableSerialize(capabilities, now()));
1387
- }
1388
- async function readContract(fs, root) {
1389
- const path = irisDirPaths(root).contract;
1390
- if (!await fs.exists(path))
1391
- return { ok: false, reason: ContractReadError.MISSING };
1392
- let text;
1393
- try {
1394
- text = await fs.readFile(path);
1395
- } catch (error) {
1396
- return {
1397
- ok: false,
1398
- reason: fs.isNotFound(error) ? ContractReadError.MISSING : ContractReadError.MALFORMED
1399
- };
1400
- }
1401
- let parsed;
1402
- try {
1403
- parsed = JSON.parse(text);
1404
- } catch {
1405
- return { ok: false, reason: ContractReadError.MALFORMED };
1406
1660
  }
1407
- const result = ContractFileSchema.safeParse(parsed);
1408
- if (!result.success)
1409
- return { ok: false, reason: ContractReadError.MALFORMED };
1410
- return { ok: true, capabilities: result.data.capabilities, generatedAt: result.data.generatedAt };
1661
+ return items;
1411
1662
  }
1412
-
1413
- // ../server/dist/flows/flows.js
1414
1663
  function asString(value) {
1415
1664
  return typeof value === "string" ? value : void 0;
1416
1665
  }
1666
+ function asNumber(value) {
1667
+ return typeof value === "number" ? value : void 0;
1668
+ }
1417
1669
  function asRecord(value) {
1418
1670
  return typeof value === "object" && value !== null ? value : {};
1419
1671
  }
1420
- function degradedAnchor() {
1421
- 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;
1422
1676
  }
1423
- function subStepToFlowStep(raw) {
1424
- const sub = asRecord(raw);
1425
- const by = asString(sub["by"]);
1426
- const value = asString(sub["value"]);
1427
- const action = asString(sub["action"]);
1428
- const args = asRecord(sub["args"]);
1429
- if (by === QueryBy.TESTID && value !== void 0) {
1430
- return buildStep(IrisTool.ACT, { kind: AnchorKind.TESTID, value }, action, args, false);
1431
- }
1432
- return buildStep(IrisTool.ACT, degradedAnchor(), action, args, true);
1677
+ function asRecord2(value) {
1678
+ return typeof value === "object" && value !== null ? value : {};
1433
1679
  }
1434
- function buildStep(tool, anchor, action, args, degraded) {
1435
- const step = { tool, anchor, args };
1436
- if (action !== void 0)
1437
- step.action = action;
1438
- if (degraded)
1439
- step.degraded = true;
1440
- 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;
1441
1686
  }
1442
- function recordedStepToFlowStep(step) {
1443
- if (step.tool === IrisTool.ACT_SEQUENCE) {
1444
- const rawSubs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
1445
- const subs = rawSubs.map(subStepToFlowStep);
1446
- const degraded = subs.some((s) => s.degraded === true);
1447
- const anchor = subs[0]?.anchor ?? degradedAnchor();
1448
- const out2 = { tool: IrisTool.ACT_SEQUENCE, anchor, steps: subs };
1449
- if (degraded)
1450
- out2.degraded = true;
1451
- if (step.expect !== void 0)
1452
- out2.expect = step.expect;
1453
- 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
+ };
1454
1697
  }
1455
- const by = asString(step.args["by"]);
1456
- const value = asString(step.args["value"]);
1457
- const action = asString(step.args["action"]);
1458
- const args = asRecord(step.args["args"]);
1459
- 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);
1460
- if (step.expect !== void 0)
1461
- out.expect = step.expect;
1462
- return out;
1698
+ return {
1699
+ tool: IrisTool.ACT,
1700
+ stable: false,
1701
+ args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
1702
+ };
1463
1703
  }
1464
- function withAnnotations(flow, ann) {
1465
- if (ann === void 0)
1466
- return flow;
1467
- const steps = flow.steps.map((step, i) => {
1468
- const expect = ann.stepExpect.get(i);
1469
- 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 };
1470
1718
  });
1471
- const out = { ...flow, steps };
1472
- if (ann.dynamic.length > 0) {
1473
- out.dynamic = ann.dynamic.map((value) => ({ kind: AnchorKind.TESTID, value }));
1719
+ return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
1720
+ }
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 };
1474
1733
  }
1475
- if (ann.success !== void 0)
1476
- out.success = ann.success;
1477
- return out;
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;
1478
2163
  }
1479
2164
  var JSON_INDENT2 = 2;
1480
2165
  var FLOW_SUFFIX = ".json";
@@ -1482,8 +2167,8 @@ var FlowStore = class {
1482
2167
  #fs;
1483
2168
  #root;
1484
2169
  #clock;
1485
- constructor(fs, root, clock) {
1486
- this.#fs = fs;
2170
+ constructor(fs2, root, clock) {
2171
+ this.#fs = fs2;
1487
2172
  this.#root = root;
1488
2173
  this.#clock = clock;
1489
2174
  }
@@ -1569,19 +2254,7 @@ var FlowStore = class {
1569
2254
  if (!loaded.ok)
1570
2255
  return { ok: false, code: loaded.code };
1571
2256
  const flow = loaded.value;
1572
- const byStep = /* @__PURE__ */ new Map();
1573
- for (const change of changes)
1574
- byStep.set(change.step, change);
1575
- const applied = [];
1576
- const steps = flow.steps.map((step, index) => {
1577
- const change = byStep.get(index);
1578
- if (change === void 0 || step.anchor.kind !== AnchorKind.TESTID || step.anchor.value !== change.from) {
1579
- return step;
1580
- }
1581
- applied.push(change);
1582
- return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
1583
- });
1584
- const next = { ...flow, steps };
2257
+ const { flow: next, applied } = applyHealChanges(flow, changes);
1585
2258
  await this.#fs.writeFile(flowPath(this.#root, name), this.#serialize(next));
1586
2259
  return { ok: true, value: { name, changed: applied } };
1587
2260
  }
@@ -1629,8 +2302,8 @@ var ProjectStore = class {
1629
2302
  #fs;
1630
2303
  #root;
1631
2304
  #clock;
1632
- constructor(fs, root, clock) {
1633
- this.#fs = fs;
2305
+ constructor(fs2, root, clock) {
2306
+ this.#fs = fs2;
1634
2307
  this.#root = root;
1635
2308
  this.#clock = clock;
1636
2309
  }
@@ -1809,10 +2482,10 @@ function createNodeFileSystem() {
1809
2482
 
1810
2483
  // ../server/dist/mcp.js
1811
2484
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1812
- import { z as z15 } from "zod";
2485
+ import { z as z17 } from "zod";
1813
2486
 
1814
2487
  // ../server/dist/tools/tools.js
1815
- import { z as z14 } from "zod";
2488
+ import { z as z16 } from "zod";
1816
2489
 
1817
2490
  // ../server/dist/input/real-input.js
1818
2491
  var DriveError = class extends Error {
@@ -1873,8 +2546,10 @@ async function performGesture(page, action, box, args, sleep) {
1873
2546
  }
1874
2547
  return { performed: false, center };
1875
2548
  }
2549
+ var HIDE_IRIS_CHROME_CSS = "[data-iris-overlay]{display:none !important}";
2550
+ var SCREENSHOT_DETERMINISM = { style: HIDE_IRIS_CHROME_CSS, animations: "disabled" };
1876
2551
  async function capturePage(page, opts) {
1877
- 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 });
1878
2553
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
1879
2554
  }
1880
2555
  var nodeSleep = (ms) => new Promise((resolve) => {
@@ -2109,6 +2784,7 @@ var PredicateSchema = z3.lazy(() => z3.discriminatedUnion("kind", [
2109
2784
  name: z3.string().optional(),
2110
2785
  dataMatches: z3.record(z3.unknown()).optional()
2111
2786
  }),
2787
+ z3.object({ kind: z3.literal("settled"), quietMs: z3.number().positive().optional() }),
2112
2788
  z3.object({ kind: z3.literal("allOf"), predicates: z3.array(PredicateSchema) }),
2113
2789
  z3.object({ kind: z3.literal("anyOf"), predicates: z3.array(PredicateSchema) }),
2114
2790
  z3.object({ kind: z3.literal("not"), predicate: PredicateSchema })
@@ -2305,6 +2981,39 @@ function evalSignal(events, p) {
2305
2981
  evidence: sameName.length > 0 ? { nearMiss: sameName } : void 0
2306
2982
  };
2307
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
+ }
2308
3017
  async function evaluatePredicate(session, predicate, since = 0) {
2309
3018
  const events = session.eventsSince(since);
2310
3019
  switch (predicate.kind) {
@@ -2322,6 +3031,8 @@ async function evaluatePredicate(session, predicate, since = 0) {
2322
3031
  return evalAnimation(events, predicate);
2323
3032
  case "signal":
2324
3033
  return evalSignal(events, predicate);
3034
+ case "settled":
3035
+ return evalSettled(events, predicate, session.elapsed());
2325
3036
  case "allOf": {
2326
3037
  const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since)));
2327
3038
  const failed = results.find((r) => !r.pass);
@@ -2347,6 +3058,10 @@ async function evaluatePredicate(session, predicate, since = 0) {
2347
3058
  function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2348
3059
  return new Promise((resolve) => {
2349
3060
  let done = false;
3061
+ const failed = (error) => ({
3062
+ pass: false,
3063
+ failureReason: error instanceof Error ? error.message : String(error)
3064
+ });
2350
3065
  const finish = (result) => {
2351
3066
  if (done)
2352
3067
  return;
@@ -2360,6 +3075,8 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2360
3075
  void evaluatePredicate(session, predicate, since).then((r) => {
2361
3076
  if (r.pass)
2362
3077
  finish(r);
3078
+ }).catch((error) => {
3079
+ finish(failed(error));
2363
3080
  });
2364
3081
  };
2365
3082
  const unsub = session.onEvent(() => {
@@ -2373,141 +3090,30 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2373
3090
  evidence: r.evidence,
2374
3091
  failureReason: r.failureReason ?? "timed out waiting for predicate"
2375
3092
  });
3093
+ }).catch((error) => {
3094
+ finish(failed(error));
2376
3095
  });
2377
3096
  }, timeoutMs);
2378
3097
  check();
2379
3098
  });
2380
3099
  }
2381
3100
 
2382
- // ../server/dist/flows/replay.js
2383
- function asString2(value) {
2384
- return typeof value === "string" ? value : void 0;
2385
- }
2386
- function asRecord2(value) {
2387
- return typeof value === "object" && value !== null ? value : {};
2388
- }
2389
- function compileActStep(args, res) {
2390
- const testid = asString2(asRecord2(res)["testid"]);
2391
- const action = asString2(args["action"]) ?? "";
2392
- const actArgs = asRecord2(args["args"]);
2393
- if (testid !== void 0) {
2394
- return {
2395
- tool: IrisTool.ACT,
2396
- stable: true,
2397
- args: { by: QueryBy.TESTID, value: testid, action, args: actArgs }
2398
- };
2399
- }
2400
- return {
2401
- tool: IrisTool.ACT,
2402
- stable: false,
2403
- args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
2404
- };
2405
- }
2406
- function compileSequenceStep(args, res) {
2407
- const inputSteps = Array.isArray(args["steps"]) ? args["steps"] : [];
2408
- const resolved = Array.isArray(asRecord2(res)["steps"]) ? asRecord2(res)["steps"] : [];
2409
- let stable = inputSteps.length > 0;
2410
- const subSteps = inputSteps.map((raw, i) => {
2411
- const step = asRecord2(raw);
2412
- const action = asString2(step["action"]) ?? "";
2413
- const stepArgs = asRecord2(step["args"]);
2414
- const testid = asString2(asRecord2(resolved[i])["testid"]);
2415
- if (testid !== void 0) {
2416
- return { by: QueryBy.TESTID, value: testid, action, args: stepArgs };
2417
- }
2418
- stable = false;
2419
- return { ref: asString2(step["ref"]) ?? "", action, args: stepArgs };
2420
- });
2421
- return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
2422
- }
2423
- async function resolveRef(session, step) {
2424
- const by = asString2(step.by);
2425
- const value = asString2(step.value);
2426
- if (by === QueryBy.TESTID && value !== void 0) {
2427
- const result = await session.command(IrisCommand.QUERY, { by, value });
2428
- if (!result.ok)
2429
- throw new Error(result.error ?? "query failed");
2430
- const elements = Array.isArray(asRecord2(result.result)["elements"]) ? asRecord2(result.result)["elements"] : [];
2431
- const ref2 = asString2(asRecord2(elements[0])["ref"]);
2432
- if (ref2 === void 0)
2433
- throw new Error(`testid '${value}' did not resolve in current page`);
2434
- return elements.length > 1 ? { ref: ref2, note: `ambiguous testid '${value}', used first match` } : { ref: ref2 };
2435
- }
2436
- const ref = asString2(step.ref);
2437
- if (ref === void 0 || ref.length === 0)
2438
- throw new Error("step has no testid or ref to resolve");
2439
- return { ref, note: "replayed by stale ref (not portable across sessions)" };
2440
- }
2441
- async function replayProgram(session, program) {
2442
- const results = [];
2443
- for (const step of program.steps) {
2444
- try {
2445
- if (step.tool === IrisTool.ACT_SEQUENCE) {
2446
- const subs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
2447
- const notes = [];
2448
- const liveSteps = [];
2449
- for (const raw of subs) {
2450
- const sub = asRecord2(raw);
2451
- const { ref, note } = await resolveRef(session, sub);
2452
- if (note !== void 0)
2453
- notes.push(note);
2454
- liveSteps.push({
2455
- ref,
2456
- action: asString2(sub["action"]) ?? "",
2457
- args: asRecord2(sub["args"])
2458
- });
2459
- }
2460
- const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps });
2461
- results.push(buildResult(step.tool, r.ok, r.error, notes));
2462
- if (!r.ok)
2463
- break;
2464
- } else {
2465
- const { ref, note } = await resolveRef(session, step.args);
2466
- const r = await session.command(IrisCommand.ACT, {
2467
- ref,
2468
- action: asString2(step.args["action"]) ?? "",
2469
- args: asRecord2(step.args["args"])
2470
- });
2471
- results.push(buildResult(step.tool, r.ok, r.error, note !== void 0 ? [note] : []));
2472
- if (!r.ok)
2473
- break;
2474
- }
2475
- } catch (e) {
2476
- results.push({
2477
- tool: step.tool,
2478
- ok: false,
2479
- error: e instanceof Error ? e.message : String(e)
2480
- });
2481
- break;
2482
- }
2483
- }
2484
- return results;
2485
- }
2486
- function buildResult(tool, ok, error, notes) {
2487
- const base = { tool, ok };
2488
- if (!ok)
2489
- base.error = error ?? "command failed";
2490
- if (notes.length > 0)
2491
- base.note = notes.join("; ");
2492
- return base;
2493
- }
2494
-
2495
3101
  // ../server/dist/events/event-filters.js
2496
- function asString3(value) {
3102
+ function asString4(value) {
2497
3103
  return typeof value === "string" ? value : void 0;
2498
3104
  }
2499
- function asNumber(value) {
3105
+ function asNumber2(value) {
2500
3106
  return typeof value === "number" ? value : void 0;
2501
3107
  }
2502
3108
  function matchNet(e, method, urlContains, status) {
2503
3109
  const d = e.data;
2504
- if (method !== void 0 && asString3(d["method"])?.toUpperCase() !== method.toUpperCase()) {
3110
+ if (method !== void 0 && asString4(d["method"])?.toUpperCase() !== method.toUpperCase()) {
2505
3111
  return false;
2506
3112
  }
2507
- if (urlContains !== void 0 && !(asString3(d["url"]) ?? "").includes(urlContains)) {
3113
+ if (urlContains !== void 0 && !(asString4(d["url"]) ?? "").includes(urlContains)) {
2508
3114
  return false;
2509
3115
  }
2510
- if (status !== void 0 && asNumber(d["status"]) !== status)
3116
+ if (status !== void 0 && asNumber2(d["status"]) !== status)
2511
3117
  return false;
2512
3118
  return true;
2513
3119
  }
@@ -2524,8 +3130,8 @@ function matchConsole(e, level) {
2524
3130
  var HINT_SAMPLE_MAX = 5;
2525
3131
  function netEmptyHint(allNet) {
2526
3132
  const present = allNet.slice(-HINT_SAMPLE_MAX).reverse().map((e) => {
2527
- const status = asNumber(e.data["status"]);
2528
- 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"]) ?? "" };
2529
3135
  return status === void 0 ? base : { ...base, status };
2530
3136
  });
2531
3137
  return { totalInWindow: allNet.length, present };
@@ -2556,6 +3162,8 @@ function refuseIfThrottled(session, refuse) {
2556
3162
  }
2557
3163
 
2558
3164
  // ../server/dist/session/output-budget.js
3165
+ var LARGE_TIMELINE_EVENTS = 80;
3166
+ var LARGE_TIMELINE_BYTES = 8e3;
2559
3167
  function applyEventBudget(events, maxEvents) {
2560
3168
  if (maxEvents === void 0 || maxEvents < 0 || events.length <= maxEvents) {
2561
3169
  return { events, droppedOldest: 0 };
@@ -2565,9 +3173,93 @@ function applyEventBudget(events, maxEvents) {
2565
3173
  droppedOldest: events.length - maxEvents
2566
3174
  };
2567
3175
  }
2568
- function costHint(payload, events, droppedOldest = 0) {
2569
- const bytes = JSON.stringify(payload)?.length ?? 0;
2570
- return droppedOldest > 0 ? { events, bytes, droppedOldest } : { events, bytes };
3176
+ function costHint(payload, events, droppedOldest = 0) {
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"] };
2571
3263
  }
2572
3264
 
2573
3265
  // ../server/dist/session/state-select.js
@@ -2618,25 +3310,55 @@ function capDepth(value, maxDepth) {
2618
3310
  return value;
2619
3311
  }
2620
3312
 
2621
- // ../server/dist/tools/tools-helpers.js
2622
- function parseInteractive(tree) {
2623
- const items = [];
2624
- for (const line of tree.split("\n")) {
2625
- const match = /\(ref=(e\d+)\)/.exec(line);
2626
- if (match !== null) {
2627
- items.push({ ref: match[1] ?? "", desc: line.replace(/\s*\(ref=e\d+\)/, "").trim() });
2628
- }
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 };
2629
3325
  }
2630
- return items;
2631
- }
2632
- function asString4(value) {
2633
- 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;
2634
3330
  }
2635
- function asNumber2(value) {
2636
- 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
+ }
2637
3358
  }
2638
- function asRecord3(value) {
2639
- return typeof value === "object" && value !== null ? value : {};
3359
+ function isPresenceOnlyAssertion(predicate) {
3360
+ const kinds = walk(predicate);
3361
+ return kinds.presence && !kinds.consequence;
2640
3362
  }
2641
3363
 
2642
3364
  // ../server/dist/tools/contract-tools.js
@@ -2670,7 +3392,7 @@ var CONTRACT_TOOLS = [
2670
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");
2671
3393
  return { ...r.capabilities, source: "disk", generatedAt: r.generatedAt };
2672
3394
  }
2673
- const caps = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3395
+ const caps = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2674
3396
  return { ...caps, source: "live" };
2675
3397
  }
2676
3398
  },
@@ -2685,7 +3407,7 @@ var CONTRACT_TOOLS = [
2685
3407
  signalCount: z4.number()
2686
3408
  },
2687
3409
  handler: async (deps, args) => {
2688
- const res = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3410
+ const res = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2689
3411
  const caps = CapabilitiesSchema.parse(res);
2690
3412
  await writeContract(deps.fs, deps.irisRoot, caps, deps.now);
2691
3413
  return {
@@ -2698,253 +3420,399 @@ var CONTRACT_TOOLS = [
2698
3420
  }
2699
3421
  ];
2700
3422
 
2701
- // ../server/dist/tools/browser-tools.js
3423
+ // ../server/dist/domain/domain-tools.js
2702
3424
  import { z as z5 } from "zod";
2703
- var sessionIdShape2 = {
2704
- sessionId: z5.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
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"
2705
3434
  };
2706
- async function commandOrThrow2(deps, sessionId, name, args) {
2707
- const session = deps.sessions.resolve(sessionId);
2708
- const result = await session.command(name, args);
2709
- if (!result.ok)
2710
- throw new Error(result.error ?? `command '${name}' failed`);
2711
- return result.result;
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);
2712
3439
  }
2713
- var BROWSER_TOOLS = [
2714
- {
2715
- name: IrisTool.NAVIGATE,
2716
- 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.",
2717
- inputSchema: {
2718
- url: z5.string().describe("The URL to navigate to."),
2719
- ...sessionIdShape2
2720
- },
2721
- outputSchema: {
2722
- ok: z5.boolean(),
2723
- url: z5.string().optional(),
2724
- reason: z5.string().optional()
2725
- },
2726
- handler: async (deps, args) => {
2727
- const url = asString4(args["url"]);
2728
- if (url === void 0 || url.length === 0)
2729
- return { ok: false, reason: "url required" };
2730
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.NAVIGATE, { url });
2731
- return { ok: true, url };
2732
- }
2733
- },
2734
- {
2735
- name: IrisTool.REFRESH,
2736
- 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.",
2737
- inputSchema: {
2738
- hard: z5.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
2739
- ...sessionIdShape2
2740
- },
2741
- outputSchema: {
2742
- ok: z5.boolean()
2743
- },
2744
- handler: async (deps, args) => {
2745
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.REFRESH, {
2746
- hard: args["hard"] === true
2747
- });
2748
- return { ok: true };
2749
- }
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));
2750
3449
  }
2751
- ];
2752
-
2753
- // ../server/dist/flows/flow-tools.js
2754
- import { z as z6 } from "zod";
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
+ }
2755
3487
 
2756
- // ../server/dist/flows/flow-replay.js
2757
- function editDistance(a, b) {
2758
- const s = a.toLowerCase();
2759
- const t = b.toLowerCase();
2760
- const rows = s.length + 1;
2761
- const cols = t.length + 1;
2762
- const prev = new Array(cols);
2763
- const curr = new Array(cols);
2764
- for (let j = 0; j < cols; j++)
2765
- prev[j] = j;
2766
- for (let i = 1; i < rows; i++) {
2767
- curr[0] = i;
2768
- for (let j = 1; j < cols; j++) {
2769
- const cost = s[i - 1] === t[j - 1] ? 0 : 1;
2770
- curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
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 });
2771
3527
  }
2772
- for (let j = 0; j < cols; j++)
2773
- prev[j] = curr[j] ?? 0;
2774
3528
  }
2775
- return prev[cols - 1] ?? 0;
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);
2776
3543
  }
2777
- function nearestTestid(missing, present) {
2778
- let best = null;
2779
- let bestDistance = Number.POSITIVE_INFINITY;
2780
- for (const candidate of present) {
2781
- const distance = editDistance(missing, candidate);
2782
- if (distance < bestDistance || distance === bestDistance && best !== null && candidate.length < best.length || distance === bestDistance && best !== null && candidate.length === best.length && candidate < best) {
2783
- best = candidate;
2784
- bestDistance = distance;
2785
- }
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;
2786
3563
  }
2787
3564
  return best;
2788
3565
  }
2789
- function readQuery(result) {
2790
- if (!result.ok)
2791
- return { refs: [] };
2792
- const payload = asRecord3(result.result);
2793
- const elements = Array.isArray(payload["elements"]) ? payload["elements"] : [];
2794
- const refs = elements.map((e) => asString4(asRecord3(e)["ref"]) ?? "").filter((r) => r.length > 0);
2795
- const rawHint = payload["hint"];
2796
- if (typeof rawHint === "object" && rawHint !== null) {
2797
- const hint = asRecord3(rawHint);
2798
- const present = Array.isArray(hint["presentTestids"]) ? hint["presentTestids"].filter((t) => typeof t === "string") : [];
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) {
2799
3576
  return {
2800
- refs,
2801
- hint: {
2802
- route: asString4(hint["route"]) ?? "",
2803
- presentTestids: present,
2804
- presentRegions: [],
2805
- knownEmptyState: hint["knownEmptyState"] === true
2806
- }
3577
+ level: RiskLevel.MEDIUM,
3578
+ reason: `last run passed but logged ${String(errors)} error(s)`
2807
3579
  };
2808
3580
  }
2809
- return { refs };
2810
- }
2811
- function testidDrift(value, hint) {
2812
- return {
2813
- reasonKind: DriftReason.TESTID_NOT_FOUND,
2814
- reason: `testid "${value}" not found`,
2815
- anchor: value,
2816
- nearest: nearestTestid(value, hint?.presentTestids ?? [])
2817
- };
2818
- }
2819
- function anchorLabel(anchor) {
2820
- if (anchor.kind === AnchorKind.TESTID)
2821
- return anchor.value;
2822
- if (anchor.kind === AnchorKind.SIGNAL)
2823
- return anchor.name;
2824
- return anchor.name ?? anchor.role;
3581
+ return { level: RiskLevel.LOW, reason: "last run passed clean" };
2825
3582
  }
2826
- async function runTestidStep(session, step, index, value, dynamic) {
2827
- const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value });
2828
- const { refs, hint } = readQuery(queryResult);
2829
- if (refs.length === 0) {
3583
+ function gradeRisk(grade) {
3584
+ if (grade === FlowAssertionGrade.ASSERTION_FREE) {
2830
3585
  return {
2831
- step: index,
2832
- tool: step.tool,
2833
- anchor: value,
2834
- ok: false,
2835
- drift: testidDrift(value, hint)
3586
+ level: RiskLevel.MEDIUM,
3587
+ reason: "asserts no consequence \u2014 a green run proves little"
2836
3588
  };
2837
3589
  }
2838
- const ref = refs[0] ?? "";
2839
- const note = refs.length > 1 ? `ambiguous testid '${value}', used first match` : void 0;
2840
- const act = await session.command(IrisCommand.ACT, {
2841
- ref,
2842
- action: step.action ?? "",
2843
- args: step.args ?? {}
2844
- });
2845
- const result = { step: index, tool: step.tool, anchor: value, ok: act.ok };
2846
- if (!act.ok) {
2847
- result.error = act.error ?? "command failed";
2848
- if (note !== void 0)
2849
- result.note = note;
2850
- return result;
2851
- }
2852
- const expectTestid = step.expect?.element?.testid;
2853
- if (expectTestid !== void 0 && !dynamic.has(expectTestid)) {
2854
- const expectQuery = await session.command(IrisCommand.QUERY, {
2855
- by: QueryBy.TESTID,
2856
- value: expectTestid
2857
- });
2858
- const expectRefs = readQuery(expectQuery);
2859
- if (expectRefs.refs.length === 0) {
2860
- return {
2861
- step: index,
2862
- tool: step.tool,
2863
- anchor: expectTestid,
2864
- ok: false,
2865
- drift: testidDrift(expectTestid, expectRefs.hint)
2866
- };
2867
- }
3590
+ if (grade === FlowAssertionGrade.PRESENCE_ONLY) {
3591
+ return { level: RiskLevel.LOW, reason: "presence-only assertion" };
2868
3592
  }
2869
- if (note !== void 0)
2870
- result.note = note;
2871
- return result;
3593
+ return { level: RiskLevel.LOW, reason: "asserts a consequence" };
2872
3594
  }
2873
- async function runSignalStep(session, step, index, name, waitForSignal, signalTimeoutMs) {
2874
- const verdict = await waitForSignal(session, { kind: "signal", name }, signalTimeoutMs);
2875
- if (verdict.pass)
2876
- return { step: index, tool: step.tool, anchor: name, ok: true };
2877
- return {
2878
- step: index,
2879
- tool: step.tool,
2880
- anchor: name,
2881
- ok: false,
2882
- drift: {
2883
- reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
2884
- reason: `signal "${name}" not observed`,
2885
- anchor: name,
2886
- nearest: null
2887
- }
2888
- };
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 };
2889
3600
  }
2890
- async function replayFlow(session, flow, waitForSignal, signalTimeoutMs) {
2891
- const results = [];
2892
- const dynamic = new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
2893
- let index = 0;
2894
- for (const step of flow.steps) {
2895
- const label = anchorLabel(step.anchor);
2896
- let result;
2897
- if (step.anchor.kind === AnchorKind.SIGNAL) {
2898
- result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs);
2899
- } else {
2900
- result = await runTestidStep(session, step, index, label, dynamic);
2901
- }
2902
- results.push(result);
2903
- if (result.drift !== void 0 || !result.ok)
2904
- break;
2905
- index += 1;
2906
- }
2907
- return results;
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);
2908
3603
  }
2909
3604
 
2910
- // ../server/dist/flows/heal.js
2911
- function confidenceFor(from, to) {
2912
- if (from === to)
2913
- return 1;
2914
- const span = Math.max(from.length, to.length);
2915
- if (span === 0)
2916
- return 1;
2917
- const raw = 1 - editDistance(from, to) / span;
2918
- if (raw >= 1)
2919
- return 1;
2920
- if (raw <= 0)
2921
- return Number.EPSILON;
2922
- return raw;
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;
2923
3614
  }
2924
- function proposeRebindWith(drift, step, minConfidence) {
2925
- if (drift.reasonKind !== DriftReason.TESTID_NOT_FOUND)
2926
- return void 0;
2927
- const to = drift.nearest;
2928
- if (to === null)
2929
- return void 0;
2930
- const confidence = confidenceFor(drift.anchor, to);
2931
- if (confidence < minConfidence)
2932
- return void 0;
2933
- return { step, from: drift.anchor, to, confidence };
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
+ };
2934
3686
  }
2935
- function collectProposals(steps, minConfidence = HEAL_CONFIDENCE_MIN) {
2936
- const proposals = [];
2937
- for (const step of steps) {
2938
- if (step.drift === void 0)
2939
- continue;
2940
- const proposal = proposeRebindWith(step.drift, step.step, minConfidence);
2941
- if (proposal !== void 0)
2942
- proposals.push(proposal);
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.";
2943
3690
  }
2944
- return proposals;
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
+
3758
+ // ../server/dist/tools/browser-tools.js
3759
+ import { z as z6 } from "zod";
3760
+ var sessionIdShape2 = {
3761
+ sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3762
+ };
3763
+ async function commandOrThrow2(deps, sessionId, name, args) {
3764
+ const session = deps.sessions.resolve(sessionId);
3765
+ const result = await session.command(name, args);
3766
+ if (!result.ok)
3767
+ throw new Error(result.error ?? `command '${name}' failed`);
3768
+ return result.result;
2945
3769
  }
3770
+ var BROWSER_TOOLS = [
3771
+ {
3772
+ name: IrisTool.NAVIGATE,
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.",
3774
+ inputSchema: {
3775
+ url: z6.string().describe("The URL to navigate to."),
3776
+ ...sessionIdShape2
3777
+ },
3778
+ outputSchema: {
3779
+ ok: z6.boolean(),
3780
+ url: z6.string().optional(),
3781
+ reason: z6.string().optional()
3782
+ },
3783
+ handler: async (deps, args) => {
3784
+ const url = asString(args["url"]);
3785
+ if (url === void 0 || url.length === 0)
3786
+ return { ok: false, reason: "url required" };
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
+ };
3793
+ }
3794
+ },
3795
+ {
3796
+ name: IrisTool.REFRESH,
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.",
3798
+ inputSchema: {
3799
+ hard: z6.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
3800
+ ...sessionIdShape2
3801
+ },
3802
+ outputSchema: {
3803
+ ok: z6.boolean()
3804
+ },
3805
+ handler: async (deps, args) => {
3806
+ await commandOrThrow2(deps, asString(args["sessionId"]), IrisCommand.REFRESH, {
3807
+ hard: args["hard"] === true
3808
+ });
3809
+ return { ok: true };
3810
+ }
3811
+ }
3812
+ ];
2946
3813
 
2947
3814
  // ../server/dist/flows/flow-tools.js
3815
+ import { z as z7 } from "zod";
2948
3816
  function latestRecordedFlow(events) {
2949
3817
  for (let i = events.length - 1; i >= 0; i--) {
2950
3818
  const event = events[i];
@@ -2990,18 +3858,26 @@ async function recordReplayRun(deps, name, status, driftSteps, durationMs) {
2990
3858
  var FLOW_TOOLS = [
2991
3859
  {
2992
3860
  name: IrisTool.FLOW_SAVE,
2993
- 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).',
2994
3862
  inputSchema: {
2995
- 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.")
2996
3864
  },
2997
3865
  outputSchema: {
2998
- saved: z6.boolean(),
2999
- path: z6.string(),
3000
- stepCount: z6.number().optional(),
3001
- 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()
3002
3878
  },
3003
3879
  handler: (deps, args) => {
3004
- const name = asString4(args["flowName"]) ?? "";
3880
+ const name = asString(args["flowName"]) ?? "";
3005
3881
  const program = deps.recordings.getCompiled(name);
3006
3882
  if (program === void 0) {
3007
3883
  return Promise.resolve({
@@ -3015,10 +3891,12 @@ var FLOW_TOOLS = [
3015
3891
  dynamic: deps.annotations.dynamic(name),
3016
3892
  ...success !== void 0 ? { success } : {}
3017
3893
  };
3018
- return deps.flows.save(program, annotations).then((res) => {
3019
- if (res.ok)
3020
- deps.annotations.clear(name);
3021
- 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;
3022
3900
  });
3023
3901
  }
3024
3902
  },
@@ -3027,22 +3905,27 @@ var FLOW_TOOLS = [
3027
3905
  description: "List saved flow names under .iris/flows (a fresh agent learns the demonstrated journeys without a browser).",
3028
3906
  inputSchema: {},
3029
3907
  outputSchema: {
3030
- 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() }))
3031
3909
  },
3032
- 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
+ }))
3033
3916
  },
3034
3917
  {
3035
3918
  name: IrisTool.FLOW_LOAD,
3036
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 }.",
3037
3920
  inputSchema: {
3038
- 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.")
3039
3922
  },
3040
3923
  outputSchema: {
3041
- flowName: z6.string(),
3042
- steps: z6.array(z6.unknown()),
3043
- createdAt: z6.number().optional()
3924
+ flowName: z7.string(),
3925
+ steps: z7.array(z7.unknown()),
3926
+ createdAt: z7.number().optional()
3044
3927
  },
3045
- handler: (deps, args) => deps.flows.load(asString4(args["flowName"]) ?? "").then((res) => {
3928
+ handler: (deps, args) => deps.flows.load(asString(args["flowName"]) ?? "").then((res) => {
3046
3929
  if (!res.ok)
3047
3930
  return { error: flowErrorMessage(res.code), code: res.code };
3048
3931
  const { name, ...rest } = res.value;
@@ -3051,20 +3934,21 @@ var FLOW_TOOLS = [
3051
3934
  },
3052
3935
  {
3053
3936
  name: IrisTool.FLOW_REPLAY,
3054
- 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).`,
3055
3938
  inputSchema: {
3056
- flowName: z6.string().describe("Flow file name (without .json extension) from iris_flow_list."),
3057
- 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.")
3058
3942
  },
3059
3943
  outputSchema: {
3060
- status: z6.string().describe("ok | drift | error"),
3061
- steps: z6.array(z6.unknown()),
3062
- proposals: z6.array(z6.unknown()).optional(),
3063
- 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()
3064
3948
  },
3065
3949
  handler: async (deps, args) => {
3066
3950
  const startedAt = deps.now();
3067
- const name = asString4(args["flowName"]) ?? "";
3951
+ const name = asString(args["flowName"]) ?? "";
3068
3952
  const loaded = await deps.flows.load(name);
3069
3953
  if (!loaded.ok) {
3070
3954
  await recordReplayRun(deps, name, ReplayStatus.ERROR, 0, deps.now() - startedAt);
@@ -3075,12 +3959,35 @@ var FLOW_TOOLS = [
3075
3959
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3076
3960
  };
3077
3961
  }
3078
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3079
- 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
+ }
3080
3977
  const driftSteps = steps.filter((s) => s.drift !== void 0).length;
3081
3978
  const allOk = steps.every((s) => s.ok);
3082
- const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.DRIFT;
3979
+ const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.ERROR;
3083
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
+ }
3084
3991
  return { name, status, steps };
3085
3992
  }
3086
3993
  },
@@ -3088,20 +3995,20 @@ var FLOW_TOOLS = [
3088
3995
  name: IrisTool.FLOW_SAVE_RECORDED,
3089
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).",
3090
3997
  inputSchema: {
3091
- 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."),
3092
3999
  ...{
3093
- 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.")
3094
4001
  }
3095
4002
  },
3096
4003
  outputSchema: {
3097
- flowName: z6.string().optional(),
3098
- stepCount: z6.number().optional(),
3099
- degraded: z6.number().optional(),
3100
- error: z6.string().optional(),
3101
- 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()
3102
4009
  },
3103
4010
  handler: async (deps, args) => {
3104
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4011
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
3105
4012
  const recorded = latestRecordedFlow(session.eventsSince(0));
3106
4013
  if (recorded === void 0) {
3107
4014
  return {
@@ -3109,7 +4016,7 @@ var FLOW_TOOLS = [
3109
4016
  code: RecordedSaveError.NO_RECORDED_FLOW
3110
4017
  };
3111
4018
  }
3112
- const override = asString4(args["flowName"]);
4019
+ const override = asString(args["flowName"]);
3113
4020
  const flow = override !== void 0 ? { ...recorded.flow, name: override } : recorded.flow;
3114
4021
  const res = await deps.flows.saveFlow(flow);
3115
4022
  if (!res.ok)
@@ -3120,35 +4027,38 @@ var FLOW_TOOLS = [
3120
4027
  },
3121
4028
  {
3122
4029
  name: IrisTool.FLOW_HEAL,
3123
- 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 }.",
3124
4031
  inputSchema: {
3125
- flowName: z6.string().describe("Flow file name to heal (from iris_flow_list)."),
3126
- apply: z6.boolean().optional(),
3127
- 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.")
3128
4036
  },
3129
4037
  outputSchema: {
3130
- flowName: z6.string(),
3131
- status: z6.string(),
3132
- applied: z6.boolean(),
3133
- proposals: z6.array(z6.unknown()),
3134
- changed: z6.array(z6.unknown()),
3135
- message: z6.string(),
3136
- 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()
3137
4045
  },
3138
4046
  handler: (deps, args) => healFlow(deps, args).then(({ name, ...rest }) => ({ flowName: name, ...rest }))
3139
4047
  }
3140
4048
  ];
3141
4049
  var HEAL_MESSAGES = {
3142
4050
  NOTHING: "nothing to heal \u2014 every anchor resolved on replay",
3143
- 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",
3144
4052
  DRIFT_DRY: "confident rebind(s) proposed \u2014 re-run with apply:true to write them to disk",
3145
- 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"
3146
4056
  };
3147
4057
  function toChange(proposal) {
3148
4058
  return { step: proposal.step, from: proposal.from, to: proposal.to };
3149
4059
  }
3150
4060
  async function healFlow(deps, args) {
3151
- const name = asString4(args["flowName"]) ?? "";
4061
+ const name = asString(args["flowName"]) ?? "";
3152
4062
  const apply = args["apply"] === true;
3153
4063
  const loaded = await deps.flows.load(name);
3154
4064
  if (!loaded.ok) {
@@ -3162,9 +4072,22 @@ async function healFlow(deps, args) {
3162
4072
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3163
4073
  };
3164
4074
  }
3165
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3166
- 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);
3167
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
+ }
3168
4091
  if (!drifted) {
3169
4092
  return {
3170
4093
  name,
@@ -3196,6 +4119,23 @@ async function healFlow(deps, args) {
3196
4119
  message: HEAL_MESSAGES.DRIFT_DRY
3197
4120
  };
3198
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
+ }
3199
4139
  const written = await deps.flows.heal(name, proposals.map(toChange));
3200
4140
  if (!written.ok) {
3201
4141
  return {
@@ -3214,14 +4154,14 @@ async function healFlow(deps, args) {
3214
4154
  applied: written.value.changed.length > 0,
3215
4155
  proposals,
3216
4156
  changed: written.value.changed,
3217
- message: HEAL_MESSAGES.HEALED
4157
+ message: loaded.value.success !== void 0 ? HEAL_MESSAGES.HEALED : HEAL_MESSAGES.HEALED_UNVERIFIED
3218
4158
  };
3219
4159
  }
3220
4160
 
3221
4161
  // ../server/dist/project/project-tools.js
3222
- import { z as z7 } from "zod";
4162
+ import { z as z8 } from "zod";
3223
4163
  var sessionIdShape3 = {
3224
- 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.")
3225
4165
  };
3226
4166
  var REGRESSION_STATUSES = /* @__PURE__ */ new Set([
3227
4167
  RunStatus.FAIL,
@@ -3262,12 +4202,12 @@ var PROJECT_TOOLS = [
3262
4202
  name: IrisTool.PROJECT,
3263
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.',
3264
4204
  inputSchema: {
3265
- 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."),
3266
4206
  ...sessionIdShape3
3267
4207
  },
3268
4208
  outputSchema: {
3269
- runs: z7.array(z7.unknown()),
3270
- diff: z7.unknown().optional()
4209
+ runs: z8.array(z8.unknown()),
4210
+ diff: z8.unknown().optional()
3271
4211
  },
3272
4212
  handler: async (deps, args) => {
3273
4213
  const read = await deps.project.read();
@@ -3277,7 +4217,7 @@ var PROJECT_TOOLS = [
3277
4217
  reason: read.reason
3278
4218
  };
3279
4219
  }
3280
- const name = asString4(args["name"]);
4220
+ const name = asString(args["name"]);
3281
4221
  if (name === void 0) {
3282
4222
  return { runs: read.file.runs, learned: read.file.learned };
3283
4223
  }
@@ -3295,22 +4235,22 @@ var PROJECT_TOOLS = [
3295
4235
  name: IrisTool.RUN_RECORD,
3296
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 }.",
3297
4237
  inputSchema: {
3298
- name: z7.string().describe("Run name for grouping in iris_project history."),
3299
- status: z7.nativeEnum(RunStatus).describe("Outcome: pass | fail | drift | error"),
3300
- kind: z7.nativeEnum(RunKind).optional(),
3301
- 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."),
3302
4242
  ...sessionIdShape3
3303
4243
  },
3304
4244
  outputSchema: {
3305
- recorded: z7.boolean(),
3306
- runName: z7.string(),
3307
- status: z7.string()
4245
+ recorded: z8.boolean(),
4246
+ runName: z8.string(),
4247
+ status: z8.string()
3308
4248
  },
3309
4249
  handler: async (deps, args) => {
3310
- const name = asString4(args["name"]) ?? "";
4250
+ const name = asString(args["name"]) ?? "";
3311
4251
  const status = args["status"];
3312
4252
  const kindArg = args["kind"];
3313
- const summary = asString4(args["summary"]);
4253
+ const summary = asString(args["summary"]);
3314
4254
  await deps.project.recordRun({
3315
4255
  kind: typeof kindArg === "string" ? kindArg : RunKind.MANUAL,
3316
4256
  name,
@@ -3323,7 +4263,7 @@ var PROJECT_TOOLS = [
3323
4263
  ];
3324
4264
 
3325
4265
  // ../server/dist/visual/visual-tools.js
3326
- import { z as z8 } from "zod";
4266
+ import { z as z9 } from "zod";
3327
4267
 
3328
4268
  // ../server/dist/visual/visual-diff.js
3329
4269
  async function loadDeps() {
@@ -3421,8 +4361,8 @@ async function diffPng(baselineBytes, currentBytes, opts = {}) {
3421
4361
  var VisualStore = class {
3422
4362
  #fs;
3423
4363
  #root;
3424
- constructor(fs, root) {
3425
- this.#fs = fs;
4364
+ constructor(fs2, root) {
4365
+ this.#fs = fs2;
3426
4366
  this.#root = root;
3427
4367
  }
3428
4368
  /** The absolute baseline path for `name` (for echoing back to the agent). */
@@ -3474,13 +4414,13 @@ var VisualStore = class {
3474
4414
 
3475
4415
  // ../server/dist/visual/visual-tools.js
3476
4416
  var sessionIdShape4 = {
3477
- 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.")
3478
4418
  };
3479
- var rectShape = z8.object({
3480
- x: z8.number(),
3481
- y: z8.number(),
3482
- width: z8.number(),
3483
- height: z8.number()
4419
+ var rectShape = z9.object({
4420
+ x: z9.number(),
4421
+ y: z9.number(),
4422
+ width: z9.number(),
4423
+ height: z9.number()
3484
4424
  });
3485
4425
  function screenshotProvider(deps) {
3486
4426
  const p = deps.realInput;
@@ -3492,11 +4432,11 @@ var noProvider = {
3492
4432
  recommendation: VISUAL_NO_PROVIDER_RECOMMENDATION
3493
4433
  };
3494
4434
  function asBox(value) {
3495
- const b = asRecord3(asRecord3(value)["box"]);
3496
- const x = asNumber2(b["x"]);
3497
- const y = asNumber2(b["y"]);
3498
- const w = asNumber2(b["width"]);
3499
- 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"]);
3500
4440
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
3501
4441
  return void 0;
3502
4442
  if (w <= 0 || h <= 0)
@@ -3506,12 +4446,12 @@ function asBox(value) {
3506
4446
  async function buildOpts(deps, sessionId, args) {
3507
4447
  const clipArg = args["clip"];
3508
4448
  if (clipArg !== void 0) {
3509
- const c = asRecord3(clipArg);
4449
+ const c = asRecord(clipArg);
3510
4450
  const box = asBox({ box: c });
3511
4451
  if (box !== void 0)
3512
4452
  return { clip: box };
3513
4453
  }
3514
- const ref = asString4(args["ref"]);
4454
+ const ref = asString(args["ref"]);
3515
4455
  if (ref !== void 0) {
3516
4456
  const session = deps.sessions.resolve(sessionId);
3517
4457
  const res = await session.command(IrisCommand.INSPECT, { ref });
@@ -3537,31 +4477,31 @@ var VISUAL_TOOLS = [
3537
4477
  name: IrisTool.SCREENSHOT,
3538
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.",
3539
4479
  inputSchema: {
3540
- name: z8.string().describe("Baseline name \u2014 saved as .iris/visual/<name>.png. Use the same name in iris_visual_diff to compare."),
3541
- fullPage: z8.boolean().optional().describe("Capture the full scroll height. Default: viewport only."),
3542
- 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."),
3543
4483
  clip: rectShape.optional().describe("Explicit { x, y, width, height } clip rectangle in page coordinates."),
3544
4484
  ...sessionIdShape4
3545
4485
  },
3546
4486
  outputSchema: {
3547
- ok: z8.boolean(),
3548
- saved: z8.boolean().optional(),
3549
- name: z8.string().optional(),
3550
- path: z8.string().optional(),
3551
- bytes: z8.number().optional(),
3552
- reason: z8.string().optional(),
3553
- 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()
3554
4494
  },
3555
4495
  handler: async (deps, args) => {
3556
4496
  const provider = screenshotProvider(deps);
3557
4497
  if (provider === void 0)
3558
4498
  return noProvider;
3559
- const sessionId = asString4(args["sessionId"]);
4499
+ const sessionId = asString(args["sessionId"]);
3560
4500
  const session = deps.sessions.resolve(sessionId);
3561
4501
  const png = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3562
4502
  if (png === void 0)
3563
4503
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3564
- const name = asString4(args["name"]) ?? "default";
4504
+ const name = asString(args["name"]) ?? "default";
3565
4505
  const store = new VisualStore(deps.fs, deps.irisRoot);
3566
4506
  const path = await store.saveBaseline(name, png);
3567
4507
  return { ok: true, saved: true, name, path, bytes: png.length };
@@ -3571,39 +4511,39 @@ var VISUAL_TOOLS = [
3571
4511
  name: IrisTool.VISUAL_DIFF,
3572
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).",
3573
4513
  inputSchema: {
3574
- baseline: z8.string().describe("Baseline screenshot name (from iris_screenshot). Used to compare with the current screenshot."),
3575
- fullPage: z8.boolean().optional(),
3576
- 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(),
3577
4517
  clip: rectShape.optional(),
3578
- masks: z8.array(rectShape).optional(),
3579
- maxRatio: z8.number().optional(),
3580
- 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."),
3581
4521
  ...sessionIdShape4
3582
4522
  },
3583
4523
  outputSchema: {
3584
- ok: z8.boolean(),
3585
- match: z8.boolean().optional(),
3586
- diffPct: z8.number().optional(),
3587
- diffPath: z8.string().optional(),
3588
- 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()
3589
4529
  },
3590
4530
  handler: async (deps, args) => {
3591
4531
  const provider = screenshotProvider(deps);
3592
4532
  if (provider === void 0)
3593
4533
  return noProvider;
3594
- const baseline = asString4(args["baseline"]) ?? "";
4534
+ const baseline = asString(args["baseline"]) ?? "";
3595
4535
  const store = new VisualStore(deps.fs, deps.irisRoot);
3596
4536
  const baselineBytes = await store.readBaseline(baseline);
3597
4537
  if (baselineBytes === void 0)
3598
4538
  return { ok: false, reason: VisualReason.BASELINE_MISSING };
3599
- const sessionId = asString4(args["sessionId"]);
4539
+ const sessionId = asString(args["sessionId"]);
3600
4540
  const session = deps.sessions.resolve(sessionId);
3601
4541
  const current = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3602
4542
  if (current === void 0)
3603
4543
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3604
4544
  const masks = rectsFrom(args["masks"]);
3605
- const threshold = asNumber2(args["threshold"]);
3606
- const maxRatio = asNumber2(args["maxRatio"]);
4545
+ const threshold = asNumber(args["threshold"]);
4546
+ const maxRatio = asNumber(args["maxRatio"]);
3607
4547
  const result = await diffPng(baselineBytes, current, {
3608
4548
  ...threshold !== void 0 ? { threshold } : {},
3609
4549
  ...maxRatio !== void 0 ? { maxRatio } : {},
@@ -3620,7 +4560,7 @@ var VISUAL_TOOLS = [
3620
4560
  ];
3621
4561
 
3622
4562
  // ../server/dist/crawl/crawl-tools.js
3623
- import { z as z9 } from "zod";
4563
+ import { z as z10 } from "zod";
3624
4564
 
3625
4565
  // ../server/dist/crawl/crawl.js
3626
4566
  function isActivity(e) {
@@ -3633,7 +4573,7 @@ function failedRequests(events, floor) {
3633
4573
  return events.filter((e) => {
3634
4574
  if (e.type !== EventType.NET_REQUEST)
3635
4575
  return false;
3636
- const status = asNumber2(e.data["status"]);
4576
+ const status = asNumber(e.data["status"]);
3637
4577
  return status !== void 0 && status >= floor;
3638
4578
  });
3639
4579
  }
@@ -3663,7 +4603,7 @@ async function crawl(session, opts, sleep) {
3663
4603
  const act = await session.command(IrisCommand.ACT, {
3664
4604
  ref: item.ref,
3665
4605
  action: ActionType.CLICK,
3666
- args: {}
4606
+ args: opts.confirmDangerous === true ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {}
3667
4607
  });
3668
4608
  await sleep(settleMs);
3669
4609
  const events = session.eventsSince(since);
@@ -3674,14 +4614,14 @@ async function crawl(session, opts, sleep) {
3674
4614
  kind: CrawlAnomalyKind.CONSOLE_ERROR,
3675
4615
  ref: item.ref,
3676
4616
  desc: item.desc,
3677
- detail: asString4(e.data["message"]) ?? e.type
4617
+ detail: asString(e.data["message"]) ?? e.type
3678
4618
  });
3679
4619
  }
3680
4620
  for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
3681
4621
  counts.failedRequests += 1;
3682
- const method = asString4(e.data["method"]) ?? "";
3683
- const url = asString4(e.data["url"]) ?? "";
3684
- 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"]);
3685
4625
  anomalies.push({
3686
4626
  kind: CrawlAnomalyKind.FAILED_REQUEST,
3687
4627
  ref: item.ref,
@@ -3689,7 +4629,7 @@ async function crawl(session, opts, sleep) {
3689
4629
  detail: `${method} ${url} \u2192 ${status ?? ""}`.trim()
3690
4630
  });
3691
4631
  }
3692
- const dispatched = asRecord3(act.result)["dispatched"] !== false && act.ok;
4632
+ const dispatched = asRecord(act.result)["dispatched"] !== false && act.ok;
3693
4633
  if (dispatched && errs.length === 0 && !events.some(isActivity)) {
3694
4634
  counts.deadControls += 1;
3695
4635
  anomalies.push({
@@ -3717,32 +4657,34 @@ var CRAWL_TOOLS = [
3717
4657
  name: IrisTool.CRAWL,
3718
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 }.",
3719
4659
  inputSchema: {
3720
- maxSteps: z9.number().optional().describe("Maximum number of controls to click. Default: 25."),
3721
- settleMs: z9.number().optional().describe("Milliseconds to wait after each click for the app to react. Default: 500."),
3722
- scope: z9.string().optional().describe("CSS selector or element ref to restrict crawling to a subtree."),
3723
- 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.")
3724
4665
  },
3725
4666
  outputSchema: {
3726
- interactiveFound: z9.number(),
3727
- stepsRun: z9.number(),
3728
- anomalies: z9.array(z9.object({
3729
- kind: z9.string(),
3730
- ref: z9.string(),
3731
- desc: z9.string(),
3732
- 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()
3733
4674
  })),
3734
- counts: z9.record(z9.number()),
3735
- truncated: z9.boolean()
4675
+ counts: z10.record(z10.number()),
4676
+ truncated: z10.boolean()
3736
4677
  },
3737
4678
  handler: (deps, args) => {
3738
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3739
- const maxSteps = asNumber2(args["maxSteps"]);
3740
- const settleMs = asNumber2(args["settleMs"]);
3741
- 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"]);
3742
4683
  const opts = {
3743
4684
  ...maxSteps !== void 0 ? { maxSteps } : {},
3744
4685
  ...settleMs !== void 0 ? { settleMs } : {},
3745
- ...scope !== void 0 ? { scope } : {}
4686
+ ...scope !== void 0 ? { scope } : {},
4687
+ ...args["confirmDangerous"] === true ? { confirmDangerous: true } : {}
3746
4688
  };
3747
4689
  return crawl(session, opts, nodeSleep2);
3748
4690
  }
@@ -3750,7 +4692,7 @@ var CRAWL_TOOLS = [
3750
4692
  ];
3751
4693
 
3752
4694
  // ../server/dist/input/scroll-tools.js
3753
- import { z as z10 } from "zod";
4695
+ import { z as z11 } from "zod";
3754
4696
 
3755
4697
  // ../server/dist/input/scroll-find.js
3756
4698
  async function queryFirst(session, q) {
@@ -3759,9 +4701,9 @@ async function queryFirst(session, q) {
3759
4701
  value: q.value,
3760
4702
  ...q.name !== void 0 ? { name: q.name } : {}
3761
4703
  });
3762
- const elements = asRecord3(res.result)["elements"];
4704
+ const elements = asRecord(res.result)["elements"];
3763
4705
  if (Array.isArray(elements) && elements.length > 0)
3764
- return asRecord3(elements[0]);
4706
+ return asRecord(elements[0]);
3765
4707
  return void 0;
3766
4708
  }
3767
4709
  async function scrollToFind(session, q, opts = {}) {
@@ -3780,7 +4722,7 @@ async function scrollToFind(session, q, opts = {}) {
3780
4722
  const hit = await queryFirst(session, q);
3781
4723
  if (hit !== void 0)
3782
4724
  return { found: true, element: hit, scrolls, exhausted: false };
3783
- const data = asRecord3(sr.result);
4725
+ const data = asRecord(sr.result);
3784
4726
  if (data["atEnd"] === true || data["scrolled"] === false) {
3785
4727
  return { found: false, scrolls, exhausted: true };
3786
4728
  }
@@ -3788,7 +4730,7 @@ async function scrollToFind(session, q, opts = {}) {
3788
4730
  for (let i = 0; i < max; i += 1) {
3789
4731
  const sr = await session.command(IrisCommand.SCROLL, q.container !== void 0 ? { ref: q.container } : {});
3790
4732
  scrolls += 1;
3791
- const data = asRecord3(sr.result);
4733
+ const data = asRecord(sr.result);
3792
4734
  const hit = await queryFirst(session, q);
3793
4735
  if (hit !== void 0)
3794
4736
  return { found: true, element: hit, scrolls, exhausted: false };
@@ -3805,58 +4747,58 @@ var SCROLL_TOOLS = [
3805
4747
  name: IrisTool.SCROLL_TO,
3806
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 }.",
3807
4749
  inputSchema: {
3808
- by: z10.string().describe("Query strategy for finding the target: role | text | testid | label | placeholder | alt"),
3809
- value: z10.string().describe("Query value for the selected strategy (the element to scroll into view)."),
3810
- name: z10.string().optional().describe("Optional accessible name filter when using by=role."),
3811
- container: z10.string().optional().describe("Element ref for the scrollable container. Omit to scroll the document."),
3812
- maxScrolls: z10.number().optional().describe("Maximum number of scroll steps before giving up. Default: 20."),
3813
- 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."),
3814
- totalCount: z10.number().optional().describe("Total item count in the virtualized list. Required for bisection with targetIndex."),
3815
- 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.")
3816
4758
  },
3817
4759
  outputSchema: {
3818
- found: z10.boolean(),
3819
- element: z10.object({ ref: z10.string(), role: z10.string(), name: z10.string() }).optional(),
3820
- scrolls: z10.number(),
3821
- 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()
3822
4764
  },
3823
4765
  handler: (deps, args) => {
3824
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3825
- const name = asString4(args["name"]);
3826
- const container = asString4(args["container"]);
3827
- const targetIndex = asNumber2(args["targetIndex"]);
3828
- 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"]);
3829
4771
  const q = {
3830
- by: asString4(args["by"]) ?? "",
3831
- value: asString4(args["value"]) ?? "",
4772
+ by: asString(args["by"]) ?? "",
4773
+ value: asString(args["value"]) ?? "",
3832
4774
  ...name !== void 0 ? { name } : {},
3833
4775
  ...container !== void 0 ? { container } : {},
3834
4776
  ...targetIndex !== void 0 ? { targetIndex } : {},
3835
4777
  ...totalCount !== void 0 ? { totalCount } : {}
3836
4778
  };
3837
- const maxScrolls = asNumber2(args["maxScrolls"]);
4779
+ const maxScrolls = asNumber(args["maxScrolls"]);
3838
4780
  return scrollToFind(session, q, maxScrolls !== void 0 ? { maxScrolls } : {});
3839
4781
  }
3840
4782
  }
3841
4783
  ];
3842
4784
 
3843
4785
  // ../server/dist/session/session-tools.js
3844
- import { z as z11 } from "zod";
4786
+ import { z as z12 } from "zod";
3845
4787
  var SESSION_TOOLS = [
3846
4788
  {
3847
4789
  name: IrisTool.SESSION,
3848
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 }.",
3849
4791
  inputSchema: {
3850
- idleEndMs: z11.number().optional().describe("Idle window in milliseconds after which the presenter session auto-ends. Default: 300000 (5 min). Raise for slow apps."),
3851
- 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.")
3852
4794
  },
3853
4795
  outputSchema: {
3854
- applied: z11.boolean(),
3855
- idleEndMs: z11.number().optional()
4796
+ applied: z12.boolean(),
4797
+ idleEndMs: z12.number().optional()
3856
4798
  },
3857
4799
  handler: async (deps, args) => {
3858
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3859
- const idleEndMs = asNumber2(args["idleEndMs"]);
4800
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4801
+ const idleEndMs = asNumber(args["idleEndMs"]);
3860
4802
  if (idleEndMs !== void 0)
3861
4803
  session.setIdleEndMs(idleEndMs);
3862
4804
  const res = await session.command(IrisCommand.SESSION_CONFIG, idleEndMs !== void 0 ? { idleEndMs } : {});
@@ -3868,7 +4810,7 @@ var SESSION_TOOLS = [
3868
4810
  ];
3869
4811
 
3870
4812
  // ../server/dist/flows/annotate-tools.js
3871
- import { z as z12 } from "zod";
4813
+ import { z as z13 } from "zod";
3872
4814
 
3873
4815
  // ../server/dist/flows/annotate.js
3874
4816
  function compileAnnotation(a, stepCount) {
@@ -3945,23 +4887,23 @@ var ANNOTATE_TOOLS = [
3945
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.",
3946
4888
  inputSchema: {
3947
4889
  // `flow` selects the recording; `name`/`signal`/`testid`/`dataMatches` are the annotation fields.
3948
- flow: z12.string().optional().describe("Named recording to annotate. Defaults to 'default'."),
3949
- kind: z12.string().describe("Annotation kind: assert-signal | assert-visible | mark-dynamic | success-state."),
3950
- name: z12.string().optional().describe("Signal name for assert-signal annotations."),
3951
- testid: z12.string().optional().describe("data-testid value for assert-visible / mark-dynamic / success-state annotations."),
3952
- signal: z12.string().optional().describe("Signal name for success-state annotations."),
3953
- dataMatches: z12.record(z12.unknown()).optional().describe("Key/value pairs the signal payload must match (assert-signal only)."),
3954
- sessionId: z12.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open."),
3955
- 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.")
3956
4898
  },
3957
4899
  outputSchema: {
3958
- ok: z12.boolean(),
3959
- target: z12.string().optional(),
3960
- compiled: z12.string().optional(),
3961
- code: z12.string().optional()
4900
+ ok: z13.boolean(),
4901
+ target: z13.string().optional(),
4902
+ compiled: z13.string().optional(),
4903
+ code: z13.string().optional()
3962
4904
  },
3963
4905
  handler: (deps, args) => {
3964
- const name = asString4(args["flow"]) ?? DEFAULT_RECORDING;
4906
+ const name = asString(args["flow"]) ?? DEFAULT_RECORDING;
3965
4907
  const parsed = AnnotationSchema.safeParse(args);
3966
4908
  if (!parsed.success) {
3967
4909
  return Promise.resolve({ ok: false, code: AnnotationErrorCode.UNKNOWN_KIND });
@@ -3990,19 +4932,19 @@ var ANNOTATE_TOOLS = [
3990
4932
  ];
3991
4933
 
3992
4934
  // ../server/dist/session/live-control-tools.js
3993
- import { z as z13 } from "zod";
4935
+ import { z as z14 } from "zod";
3994
4936
  var sessionIdShape5 = {
3995
- 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.")
3996
4938
  };
3997
4939
  var LIVE_CONTROL_TOOLS = [
3998
4940
  {
3999
4941
  name: IrisTool.END_SESSION,
4000
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.',
4001
- inputSchema: { summary: z13.string().optional(), ...sessionIdShape5 },
4002
- outputSchema: { ended: z13.boolean(), sessionId: z13.string() },
4943
+ inputSchema: { summary: z14.string().optional(), ...sessionIdShape5 },
4944
+ outputSchema: { ended: z14.boolean(), sessionId: z14.string() },
4003
4945
  handler: (deps, args) => {
4004
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4005
- session.setState(SessionState.ENDED, asString4(args["summary"]));
4946
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4947
+ session.setState(SessionState.ENDED, asString(args["summary"]));
4006
4948
  return Promise.resolve({ ended: true, sessionId: session.id });
4007
4949
  }
4008
4950
  },
@@ -4010,9 +4952,9 @@ var LIVE_CONTROL_TOOLS = [
4010
4952
  name: IrisTool.RESUME,
4011
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.',
4012
4954
  inputSchema: { ...sessionIdShape5 },
4013
- outputSchema: { ok: z13.boolean() },
4955
+ outputSchema: { ok: z14.boolean() },
4014
4956
  handler: (deps, args) => {
4015
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4957
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4016
4958
  session.setState(SessionState.ACTIVE);
4017
4959
  return Promise.resolve({ ok: true });
4018
4960
  }
@@ -4021,9 +4963,9 @@ var LIVE_CONTROL_TOOLS = [
4021
4963
  name: IrisTool.MESSAGES,
4022
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.",
4023
4965
  inputSchema: { ...sessionIdShape5 },
4024
- outputSchema: { messages: z13.array(z13.unknown()) },
4966
+ outputSchema: { messages: z14.array(z14.unknown()) },
4025
4967
  handler: (deps, args) => {
4026
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4968
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4027
4969
  return Promise.resolve({ messages: session.drainInbox() });
4028
4970
  }
4029
4971
  }
@@ -4048,6 +4990,266 @@ function withControl(session, result) {
4048
4990
  return control === void 0 ? result : { ...result, control };
4049
4991
  }
4050
4992
 
4993
+ // ../server/dist/update/update-tools.js
4994
+ import { z as z15 } from "zod";
4995
+
4996
+ // ../server/dist/update/update-checker.js
4997
+ import * as fs from "fs";
4998
+ import * as https from "https";
4999
+ import { join as join2 } from "path";
5000
+ import { homedir } from "os";
5001
+ var IRIS_HOME = join2(homedir(), ".iris");
5002
+ var MANIFEST_PATH = join2(IRIS_HOME, "update-manifest.json");
5003
+ var NPM_REGISTRY = "https://registry.npmjs.org/@syrin/iris/latest";
5004
+ function loadManifest() {
5005
+ if (!fs.existsSync(MANIFEST_PATH))
5006
+ return null;
5007
+ try {
5008
+ const raw = fs.readFileSync(MANIFEST_PATH, "utf8");
5009
+ return JSON.parse(raw);
5010
+ } catch {
5011
+ return null;
5012
+ }
5013
+ }
5014
+ function saveManifest(manifest) {
5015
+ fs.mkdirSync(IRIS_HOME, { recursive: true });
5016
+ fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf8");
5017
+ }
5018
+ function isCacheFresh(manifest) {
5019
+ const checked = new Date(manifest.lastChecked).getTime();
5020
+ return Date.now() - checked < UpdateCheckIntervalMs;
5021
+ }
5022
+ function fetchNpmInfo() {
5023
+ return new Promise((resolve, reject) => {
5024
+ const req = https.get(NPM_REGISTRY, (res) => {
5025
+ let body = "";
5026
+ res.setEncoding("utf8");
5027
+ res.on("data", (chunk) => {
5028
+ body += chunk;
5029
+ });
5030
+ res.on("end", () => {
5031
+ try {
5032
+ resolve(JSON.parse(body));
5033
+ } catch (err) {
5034
+ reject(err instanceof Error ? err : new Error(String(err)));
5035
+ }
5036
+ });
5037
+ res.on("error", reject);
5038
+ });
5039
+ req.setTimeout(5e3, () => {
5040
+ req.destroy();
5041
+ reject(new Error("npm registry request timed out"));
5042
+ });
5043
+ req.on("error", reject);
5044
+ });
5045
+ }
5046
+ async function checkForUpdate(currentVersion) {
5047
+ const cached = loadManifest();
5048
+ if (cached !== null && cached.currentVersion === currentVersion && isCacheFresh(cached)) {
5049
+ return cached;
5050
+ }
5051
+ try {
5052
+ const info = await fetchNpmInfo();
5053
+ const updateAvailable = info.version !== currentVersion;
5054
+ const manifest = {
5055
+ currentVersion,
5056
+ latestVersion: info.version,
5057
+ updateAvailable,
5058
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString(),
5059
+ ...info.iris?.changelog !== void 0 ? { changelog: info.iris.changelog } : {},
5060
+ ...info.iris?.breakingChanges !== void 0 ? { breakingChanges: info.iris.breakingChanges } : {},
5061
+ ...cached?.previousVersion !== void 0 ? { previousVersion: cached.previousVersion } : {}
5062
+ };
5063
+ saveManifest(manifest);
5064
+ return manifest;
5065
+ } catch (err) {
5066
+ log("iris_update_check_failed", {
5067
+ error: err instanceof Error ? err.message : String(err)
5068
+ });
5069
+ if (cached !== null)
5070
+ return { ...cached, currentVersion };
5071
+ return {
5072
+ currentVersion,
5073
+ updateAvailable: false,
5074
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString()
5075
+ };
5076
+ }
5077
+ }
5078
+
5079
+ // ../server/dist/update/updater.js
5080
+ import { execFile } from "child_process";
5081
+ import { existsSync as existsSync2 } from "fs";
5082
+ import { platform } from "os";
5083
+ import { dirname, join as join3 } from "path";
5084
+ var NPM_BIN = platform() === "win32" ? "npm.cmd" : "npm";
5085
+ var NPM_TIMEOUT_MS = 12e4;
5086
+ var ExecutionKind = {
5087
+ /** Launched via `npx @syrin/iris` — npm re-resolves the package on restart. */
5088
+ NPX: "npx",
5089
+ /** Installed globally via `npm install -g`. */
5090
+ GLOBAL: "global",
5091
+ /** Installed as a local project dependency. */
5092
+ LOCAL: "local"
5093
+ };
5094
+ function detectExecutionKind() {
5095
+ const script = process.argv[1] ?? "";
5096
+ if (script.includes("/_npx/") || script.includes("\\_npx\\"))
5097
+ return ExecutionKind.NPX;
5098
+ if (script.includes("/node_modules/") || script.includes("\\node_modules\\")) {
5099
+ return ExecutionKind.LOCAL;
5100
+ }
5101
+ return ExecutionKind.GLOBAL;
5102
+ }
5103
+ function findLocalProjectRoot() {
5104
+ let dir = process.cwd();
5105
+ for (; ; ) {
5106
+ if (existsSync2(join3(dir, "package.json")))
5107
+ return dir;
5108
+ const parent = dirname(dir);
5109
+ if (parent === dir)
5110
+ return null;
5111
+ dir = parent;
5112
+ }
5113
+ }
5114
+ function runNpm(args, opts = {}) {
5115
+ return new Promise((resolve, reject) => {
5116
+ execFile(NPM_BIN, args, { timeout: NPM_TIMEOUT_MS, ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {} }, (err, _stdout, stderr) => {
5117
+ if (err !== null) {
5118
+ reject(new Error(`npm ${args.join(" ")} failed: ${stderr !== "" ? stderr : err.message}`));
5119
+ } else {
5120
+ resolve();
5121
+ }
5122
+ });
5123
+ });
5124
+ }
5125
+ async function installVersion(version, kind) {
5126
+ const pkg = `@syrin/iris@${version}`;
5127
+ if (kind === ExecutionKind.NPX) {
5128
+ log("iris_update_npx_strategy", {
5129
+ note: "Running via npx \u2014 exiting so Claude Code restarts and npx fetches the new version"
5130
+ });
5131
+ return;
5132
+ }
5133
+ if (kind === ExecutionKind.LOCAL) {
5134
+ const root = findLocalProjectRoot();
5135
+ if (root !== null) {
5136
+ await runNpm(["install", pkg], { cwd: root });
5137
+ return;
5138
+ }
5139
+ log("iris_update_local_no_root", { fallback: "global" });
5140
+ }
5141
+ await runNpm(["install", "-g", pkg]);
5142
+ }
5143
+ async function installVersionRollback(version, kind) {
5144
+ if (kind === ExecutionKind.NPX) {
5145
+ log("iris_rollback_npx_strategy", {
5146
+ note: "Running via npx \u2014 update your .mcp.json args to pin the version you want to restore"
5147
+ });
5148
+ return;
5149
+ }
5150
+ await installVersion(version, kind);
5151
+ }
5152
+ async function applyUpdate(targetVersion) {
5153
+ const manifest = loadManifest();
5154
+ if (manifest !== null) {
5155
+ saveManifest({ ...manifest, previousVersion: manifest.currentVersion });
5156
+ }
5157
+ const kind = detectExecutionKind();
5158
+ log("iris_update_applying", { version: targetVersion, executionKind: kind });
5159
+ await installVersion(targetVersion, kind);
5160
+ log("iris_update_applied", { version: targetVersion, executionKind: kind });
5161
+ process.exit(0);
5162
+ }
5163
+ async function rollback() {
5164
+ const manifest = loadManifest();
5165
+ if (manifest === null || manifest.previousVersion === void 0) {
5166
+ throw new Error("No previous version available for rollback");
5167
+ }
5168
+ const prev = manifest.previousVersion;
5169
+ const kind = detectExecutionKind();
5170
+ log("iris_rollback_applying", { version: prev, executionKind: kind });
5171
+ await installVersionRollback(prev, kind);
5172
+ log("iris_rollback_applied", { version: prev, executionKind: kind });
5173
+ process.exit(0);
5174
+ }
5175
+
5176
+ // ../server/dist/server-version.js
5177
+ import { createRequire } from "module";
5178
+ var _pkg = createRequire(import.meta.url)("../package.json");
5179
+ var SERVER_VERSION = _pkg.version;
5180
+
5181
+ // ../server/dist/update/update-tools.js
5182
+ var UPDATE_TOOLS = [
5183
+ {
5184
+ name: IrisTool.VERSION_INFO,
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.",
5186
+ inputSchema: {},
5187
+ outputSchema: {
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.")
5196
+ },
5197
+ handler: async (_deps) => {
5198
+ const manifest = await checkForUpdate(SERVER_VERSION);
5199
+ return {
5200
+ currentVersion: manifest.currentVersion,
5201
+ ...manifest.latestVersion !== void 0 ? { latestVersion: manifest.latestVersion } : {},
5202
+ updateAvailable: manifest.updateAvailable,
5203
+ executionKind: detectExecutionKind(),
5204
+ ...manifest.changelog !== void 0 ? { changelog: manifest.changelog } : {},
5205
+ ...manifest.breakingChanges !== void 0 ? { breakingChanges: manifest.breakingChanges } : {},
5206
+ rollbackAvailable: manifest.previousVersion !== void 0,
5207
+ ...manifest.previousVersion !== void 0 ? { previousVersion: manifest.previousVersion } : {}
5208
+ };
5209
+ }
5210
+ },
5211
+ {
5212
+ name: IrisTool.APPLY_UPDATE,
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.',
5214
+ inputSchema: {
5215
+ confirm: z15.boolean().describe("Set to true to confirm the update should be applied. Required to prevent accidental upgrades.")
5216
+ },
5217
+ outputSchema: {
5218
+ ok: z15.boolean(),
5219
+ message: z15.string().optional()
5220
+ },
5221
+ handler: async (_deps, args) => {
5222
+ if (args["confirm"] !== true) {
5223
+ return { ok: false, message: "Set confirm:true to apply the update" };
5224
+ }
5225
+ const manifest = await checkForUpdate(SERVER_VERSION);
5226
+ if (!manifest.updateAvailable || manifest.latestVersion === void 0) {
5227
+ return { ok: false, message: "No update available \u2014 already on the latest version" };
5228
+ }
5229
+ await applyUpdate(manifest.latestVersion);
5230
+ return { ok: true };
5231
+ }
5232
+ },
5233
+ {
5234
+ name: IrisTool.ROLLBACK,
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.",
5236
+ inputSchema: {
5237
+ confirm: z15.boolean().describe("Set to true to confirm the rollback. Required to prevent accidental downgrades.")
5238
+ },
5239
+ outputSchema: {
5240
+ ok: z15.boolean(),
5241
+ message: z15.string().optional()
5242
+ },
5243
+ handler: async (_deps, args) => {
5244
+ if (args["confirm"] !== true) {
5245
+ return { ok: false, message: "Set confirm:true to apply the rollback" };
5246
+ }
5247
+ await rollback();
5248
+ return { ok: true };
5249
+ }
5250
+ }
5251
+ ];
5252
+
4051
5253
  // ../server/dist/tools/tools.js
4052
5254
  async function snapshotTree(deps, sessionId) {
4053
5255
  const session = deps.sessions.resolve(sessionId);
@@ -4058,7 +5260,7 @@ async function snapshotTree(deps, sessionId) {
4058
5260
  return { lines: normalizeLines(snap.tree ?? ""), route: snap.status?.route ?? "" };
4059
5261
  }
4060
5262
  var sessionIdShape6 = {
4061
- sessionId: z14.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.")
4062
5264
  };
4063
5265
  async function commandOrThrow3(deps, sessionId, name, args) {
4064
5266
  const session = deps.sessions.resolve(sessionId);
@@ -4068,11 +5270,11 @@ async function commandOrThrow3(deps, sessionId, name, args) {
4068
5270
  return result.result;
4069
5271
  }
4070
5272
  function asBox2(value) {
4071
- const b = asRecord3(asRecord3(value)["box"]);
4072
- const x = asNumber2(b["x"]);
4073
- const y = asNumber2(b["y"]);
4074
- const w = asNumber2(b["width"]);
4075
- 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"]);
4076
5278
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
4077
5279
  return void 0;
4078
5280
  if (w <= 0 || h <= 0)
@@ -4088,29 +5290,51 @@ async function tryRealInput(deps, session, ref, action, args) {
4088
5290
  return synthetic();
4089
5291
  if (!isPointerAction(action))
4090
5292
  return synthetic(InputModeReason.NOT_POINTER);
4091
- const inner = asRecord3(args["args"]);
5293
+ const inner = asRecord(args["args"]);
4092
5294
  if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && inner["native"] !== true) {
4093
5295
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
4094
5296
  }
4095
5297
  if (!await provider.isAvailableFor(session.url))
4096
5298
  return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
4097
- 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);
4098
5316
  if (box === void 0)
4099
5317
  return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
4100
5318
  let toBox;
4101
5319
  if (action === ActionType.DRAG) {
4102
- const toRef = asString4(inner["toRef"]);
5320
+ const toRef = asString(inner["toRef"]);
4103
5321
  if (toRef === void 0)
4104
5322
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4105
- 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);
4106
5330
  if (toBox === void 0)
4107
5331
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4108
5332
  }
4109
5333
  const performArgs = {};
4110
- const value = asString4(inner["value"]);
5334
+ const value = asString(inner["value"]);
4111
5335
  if (value !== void 0)
4112
5336
  performArgs.value = value;
4113
- const text = asString4(inner["text"]);
5337
+ const text = asString(inner["text"]);
4114
5338
  if (text !== void 0)
4115
5339
  performArgs.text = text;
4116
5340
  if (toBox !== void 0)
@@ -4129,23 +5353,24 @@ async function tryRealInput(deps, session, ref, action, args) {
4129
5353
  };
4130
5354
  }
4131
5355
  }
5356
+ var SNAPSHOT_CACHE = new SnapshotCache();
4132
5357
  var TOOLS = [
4133
5358
  {
4134
5359
  name: IrisTool.SESSIONS,
4135
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.",
4136
5361
  inputSchema: {},
4137
5362
  outputSchema: {
4138
- sessions: z14.array(z14.object({
4139
- sessionId: z14.string(),
4140
- url: z14.string(),
4141
- title: z14.string().optional(),
4142
- lastSeenMs: z14.number(),
4143
- throttled: z14.boolean(),
4144
- focused: z14.boolean(),
4145
- hidden: z14.boolean(),
4146
- realInputAvailable: z14.boolean().optional(),
4147
- stale: z14.boolean().optional(),
4148
- recommendation: z14.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()
4149
5374
  })).describe("Connected browser sessions with health state.")
4150
5375
  },
4151
5376
  handler: async (deps) => {
@@ -4159,71 +5384,95 @@ var TOOLS = [
4159
5384
  },
4160
5385
  {
4161
5386
  name: IrisTool.SNAPSHOT,
4162
- 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.",
4163
5388
  inputSchema: {
4164
- scope: z14.string().optional().describe("CSS selector or element ref to restrict the snapshot to a subtree. Omit to snapshot the whole page."),
4165
- mode: z14.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."),
4166
5392
  ...sessionIdShape6
4167
5393
  },
4168
5394
  outputSchema: {
4169
- tree: z14.string().optional().describe("Indented ARIA tree of every element on the page (or the scoped subtree)."),
4170
- status: z14.object({ route: z14.string(), title: z14.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.")
4171
5405
  },
4172
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.SNAPSHOT, {
4173
- scope: args["scope"],
4174
- mode: args["mode"] ?? SnapshotMode.FULL
4175
- })
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
+ }
4176
5419
  },
4177
5420
  {
4178
5421
  name: IrisTool.QUERY,
4179
- 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.",
4180
5423
  inputSchema: {
4181
- by: z14.string().describe("Query strategy: role | text | label | placeholder | testid | alt"),
4182
- value: z14.string().describe("Query value for the selected strategy (e.g. by=role value=button, or by=testid value=submit-btn)."),
4183
- name: z14.string().optional().describe("Accessible name filter \u2014 narrows results when `by` is role and the page has many elements of that role."),
4184
- scope: z14.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.'),
4185
5430
  ...sessionIdShape6
4186
5431
  },
4187
5432
  outputSchema: {
4188
- elements: z14.array(z14.object({
4189
- ref: z14.string(),
4190
- role: z14.string(),
4191
- name: z14.string(),
4192
- value: z14.string().optional(),
4193
- states: z14.array(z14.string()),
4194
- visible: z14.boolean()
4195
- })),
4196
- hint: z14.object({
4197
- route: z14.string(),
4198
- presentTestids: z14.array(z14.string()),
4199
- knownEmptyState: z14.boolean()
4200
- }).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.")
4201
5450
  },
4202
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.QUERY, {
5451
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.QUERY, {
4203
5452
  by: args["by"],
4204
5453
  value: args["value"],
4205
5454
  name: args["name"],
4206
5455
  scope: args["scope"]
4207
- })
5456
+ }).then((result) => withSizeCost(paginateQueryResult(result, asNumber(args["limit"]), args["count_only"] === true)))
4208
5457
  },
4209
5458
  {
4210
5459
  name: IrisTool.INSPECT,
4211
5460
  description: "Deep info on one element by ref: full a11y props, visibility, box, and (with @syrin/iris-react) component stack + source file.",
4212
5461
  inputSchema: {
4213
- ref: z14.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')."),
4214
5463
  ...sessionIdShape6
4215
5464
  },
4216
5465
  outputSchema: {
4217
- ref: z14.string(),
4218
- role: z14.string(),
4219
- name: z14.string(),
4220
- value: z14.string().optional(),
4221
- states: z14.array(z14.string()),
4222
- visible: z14.boolean(),
4223
- box: z14.object({ x: z14.number(), y: z14.number(), width: z14.number(), height: z14.number() }).optional(),
4224
- component: z14.object({ name: z14.string(), sourceFile: z14.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()
4225
5474
  },
4226
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.INSPECT, {
5475
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.INSPECT, {
4227
5476
  ref: args["ref"]
4228
5477
  })
4229
5478
  },
@@ -4231,30 +5480,30 @@ var TOOLS = [
4231
5480
  name: IrisTool.ACT,
4232
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.',
4233
5482
  inputSchema: {
4234
- ref: z14.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
4235
- action: z14.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4236
- args: z14.record(z14.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click."),
4237
- refuseWhenThrottled: z14.boolean().optional().describe("Throw instead of silently sending synthetic events when the tab is throttled/backgrounded. Default: false (synthetic events are still sent)."),
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)."),
4238
5487
  ...sessionIdShape6
4239
5488
  },
4240
5489
  outputSchema: {
4241
- since: z14.number().describe("Cursor \u2014 pass to iris_observe/iris_wait_for/iris_assert to scope reaction queries to this act."),
4242
- dispatched: z14.boolean(),
4243
- settled: z14.boolean().nullable(),
4244
- inputMode: z14.string(),
4245
- result: z14.unknown().optional(),
4246
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4247
5496
  },
4248
5497
  handler: async (deps, args) => {
4249
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5498
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4250
5499
  const paused = pausedShortCircuit(session);
4251
5500
  if (paused !== void 0)
4252
5501
  return paused;
4253
5502
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4254
5503
  const since = session.elapsed();
4255
5504
  session.markActCursor(since);
4256
- const ref = asString4(args["ref"]) ?? "";
4257
- const action = asString4(args["action"]) ?? "";
5505
+ const ref = asString(args["ref"]) ?? "";
5506
+ const action = asString(args["action"]) ?? "";
4258
5507
  const real = await tryRealInput(deps, session, ref, action, args);
4259
5508
  if (real.result !== void 0) {
4260
5509
  if (deps.recordings.active().length > 0) {
@@ -4280,7 +5529,7 @@ var TOOLS = [
4280
5529
  if (deps.recordings.active().length > 0) {
4281
5530
  deps.recordings.capture(compileActStep(args, result.result));
4282
5531
  }
4283
- const r = asRecord3(result.result);
5532
+ const r = asRecord(result.result);
4284
5533
  return withControl(session, {
4285
5534
  since,
4286
5535
  inputMode: InputMode.SYNTHETIC,
@@ -4299,17 +5548,17 @@ var TOOLS = [
4299
5548
  name: IrisTool.ACT_SEQUENCE,
4300
5549
  description: "Run multiple actions in order (fill -> fill -> submit) in one round-trip. Returns per-step effects[] (see iris_act).",
4301
5550
  inputSchema: {
4302
- steps: z14.array(z14.record(z14.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."),
4303
5552
  ...sessionIdShape6
4304
5553
  },
4305
5554
  outputSchema: {
4306
- since: z14.number(),
4307
- dispatched: z14.boolean(),
4308
- result: z14.unknown().optional(),
4309
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4310
5559
  },
4311
5560
  handler: async (deps, args) => {
4312
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5561
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4313
5562
  const paused = pausedShortCircuit(session);
4314
5563
  if (paused !== void 0)
4315
5564
  return paused;
@@ -4321,7 +5570,7 @@ var TOOLS = [
4321
5570
  if (deps.recordings.active().length > 0) {
4322
5571
  deps.recordings.capture(compileSequenceStep(args, result.result));
4323
5572
  }
4324
- const r = asRecord3(result.result);
5573
+ const r = asRecord(result.result);
4325
5574
  return withControl(session, {
4326
5575
  since,
4327
5576
  dispatched: r["count"] !== void 0,
@@ -4332,34 +5581,34 @@ var TOOLS = [
4332
5581
  },
4333
5582
  {
4334
5583
  name: IrisTool.ACT_AND_WAIT,
4335
- 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.",
4336
5585
  inputSchema: {
4337
- ref: z14.string().describe("Element ref from iris_snapshot or iris_query."),
4338
- action: z14.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4339
- args: z14.record(z14.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press."),
4340
- until: PredicateSchema.describe("Predicate to wait for after the action completes. Same shape accepted by iris_assert."),
4341
- timeout_ms: z14.number().optional().describe("Maximum wait time in milliseconds. 0 = evaluate once without waiting. Default: 4000."),
4342
- refuseWhenThrottled: z14.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."),
4343
5592
  ...sessionIdShape6
4344
5593
  },
4345
5594
  outputSchema: {
4346
- effect: z14.unknown().describe("The iris_act result (dispatched, settled, inputMode, etc.)."),
4347
- verdict: z14.object({
4348
- pass: z14.boolean(),
4349
- evidence: z14.unknown().optional(),
4350
- failureReason: z14.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()
4351
5600
  }),
4352
- trace: z14.unknown().describe("Reaction report (same shape as iris_observe summary)."),
4353
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4354
5603
  },
4355
5604
  handler: async (deps, args) => {
4356
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5605
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4357
5606
  const paused = pausedShortCircuit(session);
4358
5607
  if (paused !== void 0)
4359
5608
  return paused;
4360
5609
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4361
- const until = PredicateSchema.parse(args["until"]);
4362
- 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;
4363
5612
  const since = session.elapsed();
4364
5613
  session.markActCursor(since);
4365
5614
  const actResult = await session.command(IrisCommand.ACT, {
@@ -4383,40 +5632,41 @@ var TOOLS = [
4383
5632
  name: IrisTool.OBSERVE,
4384
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.",
4385
5634
  inputSchema: {
4386
- window_ms: z14.number().optional().describe("Time window to look back. Default: 2000ms. Ignored when `since` is provided."),
4387
- since: z14.number().optional().describe("Cursor from a prior iris_act or iris_observe call. Scopes the event window to exactly that span."),
4388
- filters: z14.array(z14.string()).optional().describe("Event type allowlist: dom | net | route | console | animation | signal. Omit to return all types."),
4389
- max_events: z14.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."),
4390
5639
  ...sessionIdShape6
4391
5640
  },
4392
5641
  outputSchema: {
4393
- events: z14.array(z14.unknown()),
4394
- summary: z14.object({
4395
- total: z14.number(),
4396
- network: z14.number(),
4397
- domAdded: z14.number(),
4398
- domRemoved: z14.number(),
4399
- domChanged: z14.number(),
4400
- routeChanges: z14.number(),
4401
- consoleErrors: z14.number(),
4402
- animations: z14.number(),
4403
- signals: z14.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()
4404
5653
  }),
4405
- cost: z14.object({
4406
- events: z14.number(),
4407
- bytes: z14.number(),
4408
- droppedOldest: z14.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).")
4409
5659
  }),
4410
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.boolean() }).optional()
5660
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4411
5661
  },
4412
5662
  handler: (deps, args) => {
4413
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4414
- const since = asNumber2(args["since"]);
4415
- 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;
4416
5666
  const events = since !== void 0 ? session.eventsSince(since) : session.eventsInWindow(windowMs);
4417
5667
  const filters = Array.isArray(args["filters"]) ? args["filters"] : void 0;
4418
5668
  const filtered = filters === void 0 ? events : events.filter((e) => filters.includes(e.type));
4419
- const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber2(args["max_events"]));
5669
+ const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber(args["max_events"]));
4420
5670
  const report = buildReactionReport(budgeted, windowMs);
4421
5671
  return Promise.resolve(withControl(session, {
4422
5672
  ...report,
@@ -4429,99 +5679,113 @@ var TOOLS = [
4429
5679
  name: IrisTool.WAIT_FOR,
4430
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.",
4431
5681
  inputSchema: {
4432
- predicate: PredicateSchema.describe("Predicate to wait for: { signal }, { net }, { element } or a combination."),
4433
- timeout_ms: z14.number().optional().describe("Maximum wait in milliseconds. Default: 4000."),
4434
- since: z14.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."),
4435
5685
  ...sessionIdShape6
4436
5686
  },
4437
5687
  outputSchema: {
4438
- pass: z14.boolean(),
4439
- evidence: z14.unknown().optional(),
4440
- failureReason: z14.string().optional(),
4441
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4442
5692
  },
4443
5693
  handler: async (deps, args) => {
4444
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5694
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4445
5695
  const predicate = PredicateSchema.parse(args["predicate"]);
4446
- const since = asNumber2(args["since"]) ?? session.lastActCursor() ?? 0;
4447
- 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);
4448
5698
  return withControl(session, { ...verdict, ...healthEnvelope(session) });
4449
5699
  }
4450
5700
  },
4451
5701
  {
4452
5702
  name: IrisTool.ASSERT,
4453
- 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.",
4454
5704
  inputSchema: {
4455
5705
  predicate: PredicateSchema.describe("Predicate to evaluate: { signal }, { net }, { element } or a combination."),
4456
- timeout_ms: z14.number().optional().describe("If > 0, wait up to this many milliseconds before failing. Default: 0 (evaluate once)."),
4457
- since: z14.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."),
4458
5708
  ...sessionIdShape6
4459
5709
  },
4460
5710
  outputSchema: {
4461
- pass: z14.boolean(),
4462
- evidence: z14.unknown().optional(),
4463
- failureReason: z14.string().optional(),
4464
- session: z14.object({ lastSeenMs: z14.number(), throttled: z14.boolean(), focused: z14.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()
4465
5716
  },
4466
5717
  handler: async (deps, args) => {
4467
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5718
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4468
5719
  const predicate = PredicateSchema.parse(args["predicate"]);
4469
- const timeout = asNumber2(args["timeout_ms"]) ?? 0;
4470
- 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;
4471
5722
  const verdict = timeout > 0 ? await waitForPredicate(session, predicate, timeout, since) : await evaluatePredicate(session, predicate, since);
4472
- 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) });
4473
5725
  }
4474
5726
  },
4475
5727
  {
4476
5728
  name: IrisTool.NETWORK,
4477
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.',
4478
5730
  inputSchema: {
4479
- since: z14.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to requests fired after that act."),
4480
- method: z14.string().optional().describe("HTTP method filter: GET | POST | PUT | DELETE | PATCH etc."),
4481
- urlContains: z14.string().optional().describe("Substring that the request URL must contain."),
4482
- status: z14.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."),
4483
5736
  ...sessionIdShape6
4484
5737
  },
4485
5738
  outputSchema: {
4486
- calls: z14.array(z14.unknown()),
4487
- hint: z14.object({ totalInWindow: z14.number(), present: z14.array(z14.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()
4488
5744
  },
4489
5745
  handler: (deps, args) => {
4490
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4491
- const since = asNumber2(args["since"]) ?? 0;
4492
- const method = asString4(args["method"]);
4493
- const urlContains = asString4(args["urlContains"]);
4494
- 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"]);
4495
5752
  const allNet = session.eventsSince(since).filter((e) => e.type === EventType.NET_REQUEST);
4496
- const calls = allNet.filter((e) => matchNet(e, method, urlContains, status));
4497
- if (calls.length === 0 && allNet.length > 0) {
4498
- 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) }));
4499
5756
  }
4500
- 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 }));
4501
5759
  }
4502
5760
  },
4503
5761
  {
4504
5762
  name: IrisTool.CONSOLE,
4505
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.',
4506
5764
  inputSchema: {
4507
- level: z14.string().optional().describe("Log level filter: error | warn | info | log. Omit to return all levels."),
4508
- since: z14.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."),
4509
5768
  ...sessionIdShape6
4510
5769
  },
4511
5770
  outputSchema: {
4512
- logs: z14.array(z14.unknown()),
4513
- hint: z14.object({ totalInWindow: z14.number(), byLevel: z14.record(z14.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()
4514
5776
  },
4515
5777
  handler: (deps, args) => {
4516
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4517
- const since = asNumber2(args["since"]) ?? 0;
4518
- 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"]);
4519
5782
  const allConsole = session.eventsSince(since).filter(isConsoleEvent);
4520
- const logs = allConsole.filter((e) => matchConsole(e, level));
4521
- if (logs.length === 0 && allConsole.length > 0) {
4522
- 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) }));
4523
5786
  }
4524
- 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 }));
4525
5789
  }
4526
5790
  },
4527
5791
  {
@@ -4529,24 +5793,24 @@ var TOOLS = [
4529
5793
  description: "Currently running + recently completed animations with targets/timing.",
4530
5794
  inputSchema: { ...sessionIdShape6 },
4531
5795
  outputSchema: {
4532
- animations: z14.array(z14.unknown())
5796
+ animations: z16.array(z16.unknown())
4533
5797
  },
4534
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.ANIMATIONS, {})
5798
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.ANIMATIONS, {})
4535
5799
  },
4536
5800
  {
4537
5801
  name: IrisTool.BASELINE_SAVE,
4538
5802
  description: "Snapshot the current semantic state under a name, to diff against later (regression detection).",
4539
5803
  inputSchema: {
4540
- name: z14.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.'),
4541
5805
  ...sessionIdShape6
4542
5806
  },
4543
5807
  outputSchema: {
4544
- baseline: z14.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
4545
- lineCount: z14.number()
5808
+ baseline: z16.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
5809
+ lineCount: z16.number()
4546
5810
  },
4547
5811
  handler: async (deps, args) => {
4548
- const name = asString4(args["name"]) ?? "default";
4549
- 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"]));
4550
5814
  deps.baselines.save({ name, lines, route });
4551
5815
  return { baseline: name, lineCount: lines.length };
4552
5816
  }
@@ -4556,7 +5820,7 @@ var TOOLS = [
4556
5820
  description: "List saved baseline names.",
4557
5821
  inputSchema: {},
4558
5822
  outputSchema: {
4559
- baselines: z14.array(z14.string())
5823
+ baselines: z16.array(z16.string())
4560
5824
  },
4561
5825
  handler: (deps) => Promise.resolve({ baselines: deps.baselines.list() })
4562
5826
  },
@@ -4564,23 +5828,23 @@ var TOOLS = [
4564
5828
  name: IrisTool.DIFF,
4565
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?".',
4566
5830
  inputSchema: {
4567
- baseline: z14.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."),
4568
5832
  ...sessionIdShape6
4569
5833
  },
4570
5834
  outputSchema: {
4571
- baseline: z14.string(),
4572
- removed: z14.array(z14.string()),
4573
- added: z14.array(z14.string()),
4574
- consoleErrors: z14.number(),
4575
- routeChanged: z14.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()
4576
5840
  },
4577
5841
  handler: async (deps, args) => {
4578
- const name = asString4(args["baseline"]) ?? "default";
5842
+ const name = asString(args["baseline"]) ?? "default";
4579
5843
  const base = deps.baselines.get(name);
4580
5844
  if (base === void 0)
4581
5845
  throw new Error(`no baseline named '${name}'`);
4582
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4583
- 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"]));
4584
5848
  const { removed, added } = diffLines(base.lines, lines);
4585
5849
  const consoleErrors = session.eventsSince(0).filter((e) => e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT).length;
4586
5850
  return { baseline: name, removed, added, consoleErrors, routeChanged: base.route !== route };
@@ -4590,16 +5854,16 @@ var TOOLS = [
4590
5854
  name: IrisTool.RECORD_START,
4591
5855
  description: "Start recording the event timeline under a name (for replay / a flow report).",
4592
5856
  inputSchema: {
4593
- recordingName: z14.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."),
4594
5858
  ...sessionIdShape6
4595
5859
  },
4596
5860
  outputSchema: {
4597
- recordingName: z14.string(),
4598
- since: z14.number()
5861
+ recordingName: z16.string(),
5862
+ since: z16.number()
4599
5863
  },
4600
5864
  handler: (deps, args) => {
4601
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4602
- const name = asString4(args["recordingName"]) ?? "default";
5865
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5866
+ const name = asString(args["recordingName"]) ?? "default";
4603
5867
  const cursor = session.elapsed();
4604
5868
  deps.recordings.start(name, cursor);
4605
5869
  return Promise.resolve({ recordingName: name, since: cursor });
@@ -4609,17 +5873,17 @@ var TOOLS = [
4609
5873
  name: IrisTool.RECORD_STOP,
4610
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.",
4611
5875
  inputSchema: {
4612
- recordingName: z14.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."),
4613
5877
  ...sessionIdShape6
4614
5878
  },
4615
5879
  outputSchema: {
4616
- recordingName: z14.string(),
4617
- program: z14.unknown(),
4618
- warning: z14.string().optional()
5880
+ recordingName: z16.string(),
5881
+ program: z16.unknown(),
5882
+ warning: z16.string().optional()
4619
5883
  },
4620
5884
  handler: (deps, args) => {
4621
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4622
- const name = asString4(args["recordingName"]) ?? "default";
5885
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5886
+ const name = asString(args["recordingName"]) ?? "default";
4623
5887
  const rec = deps.recordings.stop(name);
4624
5888
  if (rec === void 0)
4625
5889
  throw new Error(`no active recording named '${name}'`);
@@ -4645,29 +5909,30 @@ var TOOLS = [
4645
5909
  },
4646
5910
  {
4647
5911
  name: IrisTool.REPLAY,
4648
- 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?}] }.",
4649
5913
  inputSchema: {
4650
- recordingName: z14.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."),
4651
5916
  ...sessionIdShape6
4652
5917
  },
4653
5918
  outputSchema: {
4654
- recordingName: z14.string(),
4655
- ok: z14.boolean(),
4656
- steps: z14.array(z14.object({
4657
- tool: z14.string(),
4658
- ok: z14.boolean(),
4659
- error: z14.string().optional(),
4660
- note: z14.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()
4661
5926
  }))
4662
5927
  },
4663
5928
  handler: async (deps, args) => {
4664
- const name = asString4(args["recordingName"]) ?? "default";
5929
+ const name = asString(args["recordingName"]) ?? "default";
4665
5930
  const program = deps.recordings.getCompiled(name);
4666
5931
  if (program === void 0)
4667
5932
  throw new Error(`no compiled recording named '${name}'`);
4668
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5933
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4669
5934
  const since = session.elapsed();
4670
- const steps = await replayProgram(session, program);
5935
+ const steps = await replayProgram(session, program, args["confirmDangerous"] === true);
4671
5936
  return { recordingName: name, since, steps, ok: steps.every((s) => s.ok) };
4672
5937
  }
4673
5938
  },
@@ -4675,32 +5940,33 @@ var TOOLS = [
4675
5940
  name: IrisTool.NARRATE,
4676
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.",
4677
5942
  inputSchema: {
4678
- text: z14.string().describe("Short sentence describing your next action, shown on the presenter HUD for the developer watching."),
4679
- level: z14.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."),
4680
5945
  ...sessionIdShape6
4681
5946
  },
4682
- outputSchema: {
4683
- ok: z14.boolean()
4684
- },
4685
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.NARRATE, {
4686
- text: args["text"],
4687
- level: args["level"]
4688
- })
5947
+ outputSchema: { ok: z16.boolean() },
5948
+ handler: async (deps, args) => {
5949
+ const result = await commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.NARRATE, {
5950
+ text: args["text"],
5951
+ level: args["level"]
5952
+ });
5953
+ return { ok: true, ...result };
5954
+ }
4689
5955
  },
4690
5956
  {
4691
5957
  name: IrisTool.CLOCK,
4692
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.",
4693
5959
  inputSchema: {
4694
- freeze: z14.boolean().optional().describe("Freeze the fake clock. Time stops advancing until advanceMs or reset."),
4695
- advanceMs: z14.number().optional().describe("Fast-forward time by this many milliseconds \u2014 triggers debounces, toasts, auto-dismiss timers."),
4696
- reset: z14.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."),
4697
5963
  ...sessionIdShape6
4698
5964
  },
4699
5965
  outputSchema: {
4700
- ok: z14.boolean(),
4701
- elapsed: z14.number().optional()
5966
+ ok: z16.boolean().optional(),
5967
+ elapsed: z16.number().optional()
4702
5968
  },
4703
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.CLOCK, {
5969
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.CLOCK, {
4704
5970
  freeze: args["freeze"],
4705
5971
  advanceMs: args["advanceMs"],
4706
5972
  reset: args["reset"]
@@ -4710,27 +5976,27 @@ var TOOLS = [
4710
5976
  name: IrisTool.STATE,
4711
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? }.",
4712
5978
  inputSchema: {
4713
- ref: z14.string().optional().describe("Element ref \u2014 attempts a best-effort read of the nearest React component's hook state."),
4714
- store: z14.string().optional().describe("Registered store name (e.g. 'workspace'). Omit to read all stores."),
4715
- path: z14.string().optional().describe("Dot-path into the store (e.g. 'captionCache.v3'). Numeric array indices are supported."),
4716
- depth: z14.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."),
4717
5983
  ...sessionIdShape6
4718
5984
  },
4719
5985
  outputSchema: {
4720
- stores: z14.record(z14.unknown()).optional(),
4721
- storeNames: z14.array(z14.string()).optional(),
4722
- found: z14.boolean().optional(),
4723
- value: z14.unknown().optional(),
4724
- component: z14.object({ ok: z14.boolean(), reason: z14.string().optional(), state: z14.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()
4725
5991
  },
4726
5992
  handler: async (deps, args) => {
4727
- const store = asString4(args["store"]);
4728
- 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, {
4729
5995
  ref: args["ref"],
4730
5996
  store
4731
5997
  });
4732
- const path = asString4(args["path"]);
4733
- const depth = asNumber2(args["depth"]);
5998
+ const path = asString(args["path"]);
5999
+ const depth = asNumber(args["depth"]);
4734
6000
  if (path === void 0 && depth === void 0)
4735
6001
  return result;
4736
6002
  const root = result;
@@ -4750,16 +6016,16 @@ var TOOLS = [
4750
6016
  name: IrisTool.EXPLORE,
4751
6017
  description: "Autonomous-exploration helper: list interactive elements (with refs) + current console-error count, so the agent can drive the app and report anomalies.",
4752
6018
  inputSchema: {
4753
- scope: z14.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."),
4754
6020
  ...sessionIdShape6
4755
6021
  },
4756
6022
  outputSchema: {
4757
- interactive: z14.array(z14.unknown()),
4758
- consoleErrors: z14.number(),
4759
- hint: z14.string()
6023
+ interactive: z16.array(z16.unknown()),
6024
+ consoleErrors: z16.number(),
6025
+ hint: z16.string()
4760
6026
  },
4761
6027
  handler: async (deps, args) => {
4762
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6028
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4763
6029
  const result = await session.command(IrisCommand.SNAPSHOT, {
4764
6030
  mode: SnapshotMode.INTERACTIVE,
4765
6031
  scope: args["scope"]
@@ -4777,6 +6043,7 @@ var TOOLS = [
4777
6043
  },
4778
6044
  // iris_capabilities (live | fromDisk) + iris_contract_save. See contract-tools.ts.
4779
6045
  ...CONTRACT_TOOLS,
6046
+ ...DOMAIN_TOOLS,
4780
6047
  // iris_flow_save / iris_flow_list / iris_flow_load. See flow-tools.ts.
4781
6048
  ...FLOW_TOOLS,
4782
6049
  // iris_project (read history + diff-vs-last) / iris_run_record. See project-tools.ts.
@@ -4794,7 +6061,9 @@ var TOOLS = [
4794
6061
  // Live-control: iris_end_session / iris_resume / iris_messages. See live-control-tools.ts.
4795
6062
  ...LIVE_CONTROL_TOOLS,
4796
6063
  // iris_navigate / iris_refresh — browser navigation tools. See browser-tools.ts.
4797
- ...BROWSER_TOOLS
6064
+ ...BROWSER_TOOLS,
6065
+ // iris_version_info / iris_apply_update / iris_rollback — update lifecycle tools.
6066
+ ...UPDATE_TOOLS
4798
6067
  ];
4799
6068
 
4800
6069
  // ../server/dist/tools/profiles.js
@@ -4928,7 +6197,7 @@ async function runTool(tool, deps, args) {
4928
6197
  return result;
4929
6198
  if (!isPlainObject(result) || "session" in result)
4930
6199
  return result;
4931
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6200
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4932
6201
  const envelope = { ...healthEnvelope(session) };
4933
6202
  const lease = session.takeSessionLease();
4934
6203
  if (lease !== void 0)
@@ -4940,7 +6209,7 @@ async function runTool(tool, deps, args) {
4940
6209
  }
4941
6210
 
4942
6211
  // ../server/dist/mcp.js
4943
- var SERVER_INFO = { name: "iris", version: "0.3.10" };
6212
+ var SERVER_INFO = { name: "iris", version: SERVER_VERSION };
4944
6213
  var ENCODING_ENV = "IRIS_ENCODING";
4945
6214
  var TOON_VALUE = "toon";
4946
6215
  function encodeResult(result, useToon) {
@@ -5054,10 +6323,61 @@ function createToolInvoker(deps) {
5054
6323
  };
5055
6324
  }
5056
6325
 
6326
+ // ../server/dist/daemon.js
6327
+ import { join as join4 } from "path";
6328
+ import { homedir as homedir2 } from "os";
6329
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3, unlinkSync, openSync } from "fs";
6330
+ import { spawn } from "child_process";
6331
+ var IRIS_HOME2 = join4(homedir2(), ".iris");
6332
+ function pidPath(port) {
6333
+ return join4(IRIS_HOME2, `daemon-${port}.pid`);
6334
+ }
6335
+ function logPath(port) {
6336
+ return join4(IRIS_HOME2, `daemon-${port}.log`);
6337
+ }
6338
+ function readPid(port) {
6339
+ const path = pidPath(port);
6340
+ if (!existsSync3(path))
6341
+ return null;
6342
+ const n = parseInt(readFileSync2(path, "utf8").trim(), 10);
6343
+ return isNaN(n) ? null : n;
6344
+ }
6345
+ function isAlive(pid) {
6346
+ try {
6347
+ process.kill(pid, 0);
6348
+ return true;
6349
+ } catch {
6350
+ return false;
6351
+ }
6352
+ }
6353
+ function writePid(port) {
6354
+ mkdirSync2(IRIS_HOME2, { recursive: true });
6355
+ writeFileSync2(pidPath(port), String(process.pid), "utf8");
6356
+ }
6357
+ function removePid(port) {
6358
+ const path = pidPath(port);
6359
+ if (existsSync3(path))
6360
+ unlinkSync(path);
6361
+ }
6362
+ function isRunning(port) {
6363
+ const pid = readPid(port);
6364
+ return pid !== null && isAlive(pid);
6365
+ }
6366
+
5057
6367
  // ../server/dist/index.js
5058
6368
  async function start(options = {}) {
5059
6369
  const port = options.port ?? IRIS_DEFAULT_PORT;
5060
- 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
+ });
5061
6381
  const reaper = new SessionReaper(bridge.sessions);
5062
6382
  reaper.start();
5063
6383
  const baselines = new BaselineStore();
@@ -5086,11 +6406,11 @@ async function start(options = {}) {
5086
6406
  }
5087
6407
  }
5088
6408
  if (options.mcp !== false) {
5089
- const fs = createNodeFileSystem();
5090
- const irisRoot = options.irisRoot ?? join2(process.cwd(), IrisDir.ROOT);
6409
+ const fs2 = createNodeFileSystem();
6410
+ const irisRoot = options.irisRoot ?? join5(process.cwd(), IrisDir.ROOT);
5091
6411
  const now = options.now ?? (() => Date.now());
5092
- const flows = new FlowStore(fs, irisRoot, { now });
5093
- const project = new ProjectStore(fs, irisRoot, { now });
6412
+ const flows = new FlowStore(fs2, irisRoot, { now });
6413
+ const project = new ProjectStore(fs2, irisRoot, { now });
5094
6414
  const annotations = new AnnotationStore();
5095
6415
  const deps = {
5096
6416
  sessions: bridge.sessions,
@@ -5099,7 +6419,7 @@ async function start(options = {}) {
5099
6419
  annotations,
5100
6420
  flows,
5101
6421
  project,
5102
- fs,
6422
+ fs: fs2,
5103
6423
  irisRoot,
5104
6424
  now
5105
6425
  };
@@ -5121,6 +6441,71 @@ async function start(options = {}) {
5121
6441
  }
5122
6442
  };
5123
6443
  }
6444
+ async function startDaemon(options = {}) {
6445
+ const port = options.port ?? IRIS_DEFAULT_PORT;
6446
+ const shared = createSharedServer();
6447
+ const bridge = new Bridge({ port, server: shared.httpServer });
6448
+ const reaper = new SessionReaper(bridge.sessions);
6449
+ reaper.start();
6450
+ let owned;
6451
+ let realInput;
6452
+ const driveUrl = options.driveUrl;
6453
+ if (driveUrl !== void 0 && driveUrl.length > 0) {
6454
+ const headless = options.headless ?? true;
6455
+ const factory = options.realInputFactory ?? ((opts) => new LaunchedRealInputProvider({ driveUrl: opts.driveUrl, headless: opts.headless }));
6456
+ const launched = factory({ driveUrl, headless });
6457
+ try {
6458
+ await launched.navigate();
6459
+ } catch (error) {
6460
+ await shared.close();
6461
+ throw error;
6462
+ }
6463
+ owned = launched;
6464
+ realInput = launched;
6465
+ } else {
6466
+ const cdpUrl = options.cdpUrl ?? process.env["IRIS_CDP_URL"];
6467
+ if (cdpUrl !== void 0 && cdpUrl.length > 0) {
6468
+ const cdp = new CdpRealInputProvider({ cdpUrl });
6469
+ owned = cdp;
6470
+ realInput = cdp;
6471
+ }
6472
+ }
6473
+ const fs2 = createNodeFileSystem();
6474
+ const irisRoot = options.irisRoot ?? join5(process.cwd(), IrisDir.ROOT);
6475
+ const now = options.now ?? (() => Date.now());
6476
+ const flows = new FlowStore(fs2, irisRoot, { now });
6477
+ const project = new ProjectStore(fs2, irisRoot, { now });
6478
+ const annotations = new AnnotationStore();
6479
+ const deps = {
6480
+ sessions: bridge.sessions,
6481
+ baselines: new BaselineStore(),
6482
+ recordings: new RecordingStore(),
6483
+ annotations,
6484
+ flows,
6485
+ project,
6486
+ fs: fs2,
6487
+ irisRoot,
6488
+ now
6489
+ };
6490
+ const profile = resolveToolProfile(options.toolProfile);
6491
+ const effectiveDeps = realInput !== void 0 ? { ...deps, realInput } : deps;
6492
+ shared.attachMcp(() => createMcpServer(effectiveDeps, profile));
6493
+ await new Promise((resolve) => {
6494
+ shared.httpServer.once("listening", resolve);
6495
+ shared.httpServer.listen(port, "127.0.0.1");
6496
+ });
6497
+ log("mcp_daemon_started", { port });
6498
+ return {
6499
+ bridge,
6500
+ ...realInput !== void 0 ? { realInput } : {},
6501
+ close: async () => {
6502
+ reaper.stop();
6503
+ await owned?.dispose();
6504
+ await bridge.close();
6505
+ await shared.close();
6506
+ }
6507
+ };
6508
+ }
5124
6509
  export {
5125
6510
  AnnotationStore,
5126
6511
  BaselineStore,
@@ -5128,9 +6513,12 @@ export {
5128
6513
  CORE_TOOL_NAMES,
5129
6514
  CdpRealInputProvider,
5130
6515
  DriveError,
6516
+ FlowAssertionGrade,
5131
6517
  FlowStore,
5132
6518
  IrisTool,
5133
6519
  LaunchedRealInputProvider,
6520
+ MCP_MESSAGE_PATH,
6521
+ MCP_SSE_PATH,
5134
6522
  PredicateSchema,
5135
6523
  ProjectStore,
5136
6524
  RecordingStore,
@@ -5144,31 +6532,44 @@ export {
5144
6532
  TOOL_PROFILE_ENV,
5145
6533
  UNKNOWN_TOOL_ERROR,
5146
6534
  VisualStore,
6535
+ assertSuccess,
5147
6536
  baselinePath,
5148
6537
  boxCenter,
6538
+ buildDomainModel,
5149
6539
  buildReactionReport,
5150
6540
  buildSessionRecommendation,
6541
+ classifyFlowAssertions,
5151
6542
  crawl,
5152
6543
  createNodeFileSystem,
5153
6544
  createToolInvoker,
5154
6545
  diffLines,
5155
6546
  diffPng,
6547
+ dynamicTestids,
5156
6548
  ensureIrisDir,
5157
6549
  evaluatePredicate,
5158
6550
  filterTools,
5159
6551
  flowPath,
5160
6552
  irisDirPaths,
6553
+ isAlive,
5161
6554
  isPointerAction,
6555
+ isRunning,
6556
+ logPath,
5162
6557
  nearestTestid,
5163
6558
  normalizeLines,
5164
6559
  performGesture,
5165
6560
  readContract,
6561
+ readPid,
5166
6562
  recordedStepToFlowStep,
6563
+ removePid,
5167
6564
  replayFlow,
5168
6565
  resolveToolProfile,
5169
6566
  runTool,
5170
6567
  scrollToFind,
5171
6568
  start,
6569
+ startDaemon,
6570
+ successLabel,
6571
+ successToPredicate,
5172
6572
  waitForPredicate,
5173
- writeContract
6573
+ writeContract,
6574
+ writePid
5174
6575
  };