@syrin/iris 0.5.0 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,10 +2,35 @@
2
2
 
3
3
  // ../server/dist/cli.js
4
4
  import { pathToFileURL } from "url";
5
+ import { realpathSync } from "fs";
5
6
 
6
7
  // ../protocol/dist/constants.js
7
8
  var IRIS_DEFAULT_PORT = 4400;
8
9
  var IRIS_WS_PATH = "/iris";
10
+ var IRIS_PROTOCOL_VERSION = 1;
11
+ var TRANSPORT_LIMITS = {
12
+ MAX_MESSAGE_BYTES: 1024 * 1024,
13
+ MAX_MESSAGES_PER_SECOND: 1e3,
14
+ MAX_SESSIONS: 32,
15
+ MAX_PENDING_CONNECTIONS: 16,
16
+ HELLO_TIMEOUT_MS: 5e3,
17
+ MAX_BUFFER_BYTES: 8 * 1024 * 1024,
18
+ MAX_SESSION_ID_LENGTH: 128,
19
+ MAX_URL_LENGTH: 4096,
20
+ MAX_TITLE_LENGTH: 512,
21
+ MAX_ADAPTERS: 32,
22
+ MAX_ADAPTER_NAME_LENGTH: 128,
23
+ MAX_TOKEN_LENGTH: 512,
24
+ MAX_COMMAND_ID_LENGTH: 128,
25
+ MAX_COMMAND_NAME_LENGTH: 128,
26
+ MAX_REF_LENGTH: 128,
27
+ MAX_ERROR_LENGTH: 4096,
28
+ MAX_SERIALIZE_DEPTH: 8,
29
+ MAX_COLLECTION_ITEMS: 200,
30
+ MAX_OBJECT_KEYS: 200,
31
+ MAX_STRING_LENGTH: 64 * 1024
32
+ };
33
+ var DANGEROUS_ACTION_CONFIRM_ARG = "confirmDangerous";
9
34
  var REPLAY_PROGRAM_VERSION = 1;
10
35
  var IrisDir = {
11
36
  ROOT: ".iris",
@@ -107,7 +132,7 @@ var ReplayStatus = {
107
132
  DRIFT: "drift",
108
133
  // an anchor missed (testid renamed / signal not observed) — legible drift returned
109
134
  ERROR: "error"
110
- // the flow file could not be loaded (missing/invalid) no steps ran
135
+ // the flow could not load or a resolved action failed
111
136
  };
112
137
  var DriftReason = {
113
138
  TESTID_NOT_FOUND: "testid_not_found",
@@ -141,8 +166,10 @@ var HealStatus = {
141
166
  // drift exists but no proposal cleared the confidence floor
142
167
  NOTHING_TO_HEAL: "nothing_to_heal",
143
168
  // replay was green
169
+ CONSEQUENCE_BROKEN: "consequence_broken",
170
+ // rebind resolves a locator but the flow's success consequence no longer fires — REFUSED (file untouched)
144
171
  ERROR: "error"
145
- // flow missing/malformed/invalid-name no steps ran
172
+ // flow missing/malformed/invalid-name, or a resolved action failed
146
173
  };
147
174
  var HEAL_CONFIDENCE_MIN = 0.5;
148
175
  var AnnotationTarget = {
@@ -158,7 +185,8 @@ var AnnotationErrorCode = {
158
185
  var COMPILED_PREDICATE_PREFIX = "will";
159
186
  var RING_BUFFER_DEFAULTS = {
160
187
  MAX_EVENTS: 2e3,
161
- MAX_AGE_MS: 6e4
188
+ MAX_AGE_MS: 6e4,
189
+ MAX_BYTES: TRANSPORT_LIMITS.MAX_BUFFER_BYTES
162
190
  };
163
191
  var EventType = {
164
192
  DOM_ADDED: "dom.added",
@@ -343,6 +371,8 @@ var MessageKind = {
343
371
 
344
372
  // ../protocol/dist/messages.js
345
373
  import { z } from "zod";
374
+ var sessionIdSchema = z.string().min(1).max(TRANSPORT_LIMITS.MAX_SESSION_ID_LENGTH);
375
+ var refSchema = z.string().max(TRANSPORT_LIMITS.MAX_REF_LENGTH);
346
376
  var HumanControlDataSchema = z.object({
347
377
  kind: z.nativeEnum(HumanControlKind),
348
378
  text: z.string().optional()
@@ -350,35 +380,37 @@ var HumanControlDataSchema = z.object({
350
380
  var IrisEventSchema = z.object({
351
381
  t: z.number(),
352
382
  type: z.nativeEnum(EventType),
353
- sessionId: z.string(),
383
+ sessionId: sessionIdSchema,
354
384
  /** Stable element reference this event concerns, when applicable (e.g. "e7"). */
355
- ref: z.string().optional(),
385
+ ref: refSchema.optional(),
356
386
  /** Event-type-specific payload. Kept open here; refined per observer at the edges. */
357
387
  data: z.record(z.unknown()).default({})
358
388
  });
359
389
  var HelloMessageSchema = z.object({
360
390
  kind: z.literal(MessageKind.HELLO),
361
- protocolVersion: z.number(),
362
- sessionId: z.string(),
363
- url: z.string(),
364
- title: z.string(),
365
- adapters: z.array(z.string()),
391
+ protocolVersion: z.literal(IRIS_PROTOCOL_VERSION),
392
+ sessionId: sessionIdSchema,
393
+ url: z.string().max(TRANSPORT_LIMITS.MAX_URL_LENGTH),
394
+ title: z.string().max(TRANSPORT_LIMITS.MAX_TITLE_LENGTH),
395
+ adapters: z.array(z.string().max(TRANSPORT_LIMITS.MAX_ADAPTER_NAME_LENGTH)).max(TRANSPORT_LIMITS.MAX_ADAPTERS),
396
+ /** Optional browser/bridge pairing token. Required when the bridge configures one. */
397
+ token: z.string().max(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH).optional(),
366
398
  /** Whether the app has advertised a capability registry (iris.describe). */
367
399
  hasCapabilities: z.boolean().optional()
368
400
  });
369
401
  var CommandMessageSchema = z.object({
370
402
  kind: z.literal(MessageKind.COMMAND),
371
- id: z.string(),
372
- sessionId: z.string().optional(),
373
- name: z.string(),
403
+ id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH),
404
+ sessionId: sessionIdSchema.optional(),
405
+ name: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_NAME_LENGTH),
374
406
  args: z.record(z.unknown()).default({})
375
407
  });
376
408
  var CommandResultSchema = z.object({
377
409
  kind: z.literal(MessageKind.COMMAND_RESULT),
378
- id: z.string(),
410
+ id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH),
379
411
  ok: z.boolean(),
380
412
  result: z.unknown().optional(),
381
- error: z.string().optional()
413
+ error: z.string().max(TRANSPORT_LIMITS.MAX_ERROR_LENGTH).optional()
382
414
  });
383
415
  var EventMessageSchema = z.object({
384
416
  kind: z.literal(MessageKind.EVENT),
@@ -391,6 +423,25 @@ var IrisMessageSchema = z.discriminatedUnion("kind", [
391
423
  EventMessageSchema
392
424
  ]);
393
425
 
426
+ // ../protocol/dist/security.js
427
+ 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;
428
+ function isLoopbackHostname(hostname) {
429
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
430
+ if (normalized === "localhost" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") {
431
+ return true;
432
+ }
433
+ const octets = normalized.split(".");
434
+ return octets.length === 4 && octets[0] === "127" && octets.every((octet) => {
435
+ if (!/^\d{1,3}$/.test(octet))
436
+ return false;
437
+ const value = Number(octet);
438
+ return value >= 0 && value <= 255;
439
+ });
440
+ }
441
+ function isDangerousActionText(text) {
442
+ return DANGEROUS_ACTION.test(text.replace(/[_-]+/g, " "));
443
+ }
444
+
394
445
  // ../protocol/dist/toon.js
395
446
  var ROLE_MAP = {
396
447
  button: "btn",
@@ -742,6 +793,7 @@ function createSharedServer() {
742
793
  }
743
794
 
744
795
  // ../server/dist/bridge.js
796
+ import { timingSafeEqual } from "crypto";
745
797
  import * as http2 from "http";
746
798
  import { WebSocketServer } from "ws";
747
799
 
@@ -749,14 +801,21 @@ import { WebSocketServer } from "ws";
749
801
  var RingBuffer = class {
750
802
  #maxEvents;
751
803
  #maxAgeMs;
804
+ #maxBytes;
752
805
  #events = [];
806
+ #eventBytes = [];
807
+ #totalBytes = 0;
753
808
  #droppedCount = 0;
754
809
  constructor(options = {}) {
755
810
  this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
756
811
  this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
812
+ this.#maxBytes = options.maxBytes ?? RING_BUFFER_DEFAULTS.MAX_BYTES;
757
813
  }
758
814
  push(event, now) {
759
815
  this.#events.push(event);
816
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8");
817
+ this.#eventBytes.push(bytes);
818
+ this.#totalBytes += bytes;
760
819
  this.#evict(now);
761
820
  }
762
821
  /** Events at or after a given timestamp cursor. */
@@ -782,10 +841,14 @@ var RingBuffer = class {
782
841
  #evict(now) {
783
842
  const before = this.#events.length;
784
843
  const cutoff = now - this.#maxAgeMs;
785
- if (this.#events.length > this.#maxEvents) {
786
- this.#events = this.#events.slice(this.#events.length - this.#maxEvents);
844
+ while (this.#events.length > this.#maxEvents || this.#totalBytes > this.#maxBytes && this.#events.length > 0) {
845
+ this.#events.shift();
846
+ this.#totalBytes -= this.#eventBytes.shift() ?? 0;
847
+ }
848
+ while ((this.#events[0]?.t ?? cutoff) < cutoff) {
849
+ this.#events.shift();
850
+ this.#totalBytes -= this.#eventBytes.shift() ?? 0;
787
851
  }
788
- this.#events = this.#events.filter((e) => e.t >= cutoff);
789
852
  this.#droppedCount += before - this.#events.length;
790
853
  }
791
854
  /** Snapshot of buffer health for the agent — total events held and cumulative drops since connect. */
@@ -1022,6 +1085,14 @@ var Session = class {
1022
1085
  this.#pending.delete(id);
1023
1086
  }
1024
1087
  }
1088
+ /** End this transport without letting a stale socket remove its replacement session. */
1089
+ disconnect(reason) {
1090
+ this.rejectAll(reason);
1091
+ try {
1092
+ this.#socket.close(1008, reason);
1093
+ } catch {
1094
+ }
1095
+ }
1025
1096
  // ── Live-control: state machine + human→agent inbox (server-owned) ───────────────
1026
1097
  getState() {
1027
1098
  return this.#state;
@@ -1143,11 +1214,15 @@ var Session = class {
1143
1214
  var SessionManager = class {
1144
1215
  #sessions = /* @__PURE__ */ new Map();
1145
1216
  add(session) {
1217
+ const previous = this.#sessions.get(session.id);
1146
1218
  this.#sessions.set(session.id, session);
1219
+ return previous;
1147
1220
  }
1148
- remove(sessionId) {
1149
- this.#sessions.get(sessionId)?.rejectAll("session disconnected");
1150
- this.#sessions.delete(sessionId);
1221
+ remove(session) {
1222
+ if (this.#sessions.get(session.id) !== session)
1223
+ return false;
1224
+ session.rejectAll("session disconnected");
1225
+ return this.#sessions.delete(session.id);
1151
1226
  }
1152
1227
  get(sessionId) {
1153
1228
  return this.#sessions.get(sessionId);
@@ -1215,6 +1290,20 @@ var SessionManager = class {
1215
1290
  };
1216
1291
 
1217
1292
  // ../server/dist/bridge.js
1293
+ function normalizeOrigin(origin) {
1294
+ try {
1295
+ return new URL(origin).origin;
1296
+ } catch {
1297
+ return null;
1298
+ }
1299
+ }
1300
+ function tokensMatch(expected, received) {
1301
+ if (received === void 0)
1302
+ return false;
1303
+ const expectedBytes = Buffer.from(expected);
1304
+ const receivedBytes = Buffer.from(received);
1305
+ return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
1306
+ }
1218
1307
  function rawToString(raw) {
1219
1308
  if (typeof raw === "string")
1220
1309
  return raw;
@@ -1230,11 +1319,41 @@ var Bridge = class {
1230
1319
  ready;
1231
1320
  #wss;
1232
1321
  #clock;
1322
+ #token;
1323
+ #allowedOrigins;
1324
+ #maxMessagesPerSecond;
1325
+ #maxSessions;
1326
+ #maxPendingConnections;
1327
+ #helloTimeoutMs;
1328
+ #pendingConnections = 0;
1233
1329
  constructor(options) {
1330
+ const host = options.host ?? "127.0.0.1";
1331
+ if ((options.token?.length ?? 0) > TRANSPORT_LIMITS.MAX_TOKEN_LENGTH) {
1332
+ throw new Error(`Iris pairing token exceeds ${String(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH)} characters`);
1333
+ }
1334
+ if (!isLoopbackHostname(host) && (options.token === void 0 || options.token.length === 0)) {
1335
+ throw new Error("a pairing token is required when the Iris bridge binds beyond localhost");
1336
+ }
1234
1337
  this.#clock = options.clock ?? (() => Date.now());
1338
+ this.#token = options.token !== void 0 && options.token.length > 0 ? options.token : void 0;
1339
+ this.#allowedOrigins = new Set((options.allowedOrigins ?? []).map(normalizeOrigin).filter((origin) => origin !== null));
1340
+ this.#maxMessagesPerSecond = options.maxMessagesPerSecond ?? TRANSPORT_LIMITS.MAX_MESSAGES_PER_SECOND;
1341
+ this.#maxSessions = options.maxSessions ?? TRANSPORT_LIMITS.MAX_SESSIONS;
1342
+ this.#maxPendingConnections = options.maxPendingConnections ?? TRANSPORT_LIMITS.MAX_PENDING_CONNECTIONS;
1343
+ this.#helloTimeoutMs = options.helloTimeoutMs ?? TRANSPORT_LIMITS.HELLO_TIMEOUT_MS;
1235
1344
  if (options.server !== void 0) {
1236
1345
  const srv = options.server;
1237
- this.#wss = new WebSocketServer({ server: srv, path: IRIS_WS_PATH });
1346
+ this.#wss = new WebSocketServer({
1347
+ server: srv,
1348
+ path: IRIS_WS_PATH,
1349
+ maxPayload: TRANSPORT_LIMITS.MAX_MESSAGE_BYTES,
1350
+ verifyClient: ({ origin }, done) => {
1351
+ const allowed = this.#originAllowed(origin);
1352
+ if (!allowed)
1353
+ log("origin_rejected", { origin: origin ?? "missing" });
1354
+ done(allowed, 403, "Forbidden");
1355
+ }
1356
+ });
1238
1357
  this.ready = new Promise((resolve) => {
1239
1358
  if (srv.listening) {
1240
1359
  resolve(srv.address().port);
@@ -1247,8 +1366,15 @@ var Bridge = class {
1247
1366
  } else {
1248
1367
  this.#wss = new WebSocketServer({
1249
1368
  port: options.port,
1250
- host: options.host ?? "127.0.0.1",
1251
- path: IRIS_WS_PATH
1369
+ host,
1370
+ path: IRIS_WS_PATH,
1371
+ maxPayload: TRANSPORT_LIMITS.MAX_MESSAGE_BYTES,
1372
+ verifyClient: ({ origin }, done) => {
1373
+ const allowed = this.#originAllowed(origin);
1374
+ if (!allowed)
1375
+ log("origin_rejected", { origin: origin ?? "missing" });
1376
+ done(allowed, 403, "Forbidden");
1377
+ }
1252
1378
  });
1253
1379
  this.ready = new Promise((resolve) => {
1254
1380
  this.#wss.on("listening", () => {
@@ -1261,14 +1387,64 @@ var Bridge = class {
1261
1387
  });
1262
1388
  }
1263
1389
  #onConnection(socket) {
1390
+ if (this.#pendingConnections >= this.#maxPendingConnections) {
1391
+ socket.close(1013, "too many pending handshakes");
1392
+ return;
1393
+ }
1394
+ this.#pendingConnections += 1;
1395
+ let awaitingHello = true;
1264
1396
  let session;
1397
+ let messageWindowStartedAt = this.#clock();
1398
+ let messagesInWindow = 0;
1399
+ const releasePending = () => {
1400
+ if (!awaitingHello)
1401
+ return;
1402
+ awaitingHello = false;
1403
+ this.#pendingConnections -= 1;
1404
+ };
1405
+ const helloTimer = setTimeout(() => {
1406
+ if (!awaitingHello)
1407
+ return;
1408
+ releasePending();
1409
+ socket.close(1008, "hello timeout");
1410
+ }, this.#helloTimeoutMs);
1265
1411
  socket.on("message", (raw) => {
1412
+ const now = this.#clock();
1413
+ if (now - messageWindowStartedAt >= 1e3) {
1414
+ messageWindowStartedAt = now;
1415
+ messagesInWindow = 0;
1416
+ }
1417
+ messagesInWindow += 1;
1418
+ if (messagesInWindow > this.#maxMessagesPerSecond) {
1419
+ log("message_rate_exceeded", {});
1420
+ socket.close(1008, "message rate exceeded");
1421
+ return;
1422
+ }
1266
1423
  const parsed = this.#parse(rawToString(raw));
1267
- if (parsed === null)
1424
+ if (parsed === null) {
1425
+ socket.close(1008, "invalid message");
1268
1426
  return;
1427
+ }
1269
1428
  if (parsed.kind === MessageKind.HELLO) {
1429
+ if (session !== void 0) {
1430
+ socket.close(1008, "hello already received");
1431
+ return;
1432
+ }
1433
+ if (this.#token !== void 0 && !tokensMatch(this.#token, parsed.token)) {
1434
+ log("authentication_failed", {});
1435
+ socket.close(1008, "authentication failed");
1436
+ return;
1437
+ }
1438
+ const existing = this.sessions.get(parsed.sessionId);
1439
+ if (existing === void 0 && this.sessions.count() >= this.#maxSessions) {
1440
+ socket.close(1013, "session limit reached");
1441
+ return;
1442
+ }
1443
+ clearTimeout(helloTimer);
1444
+ releasePending();
1270
1445
  session = new Session(parsed, socket, this.#clock);
1271
- this.sessions.add(session);
1446
+ const replaced = this.sessions.add(session);
1447
+ replaced?.disconnect("session replaced by a newer connection");
1272
1448
  log("session_connected", { sessionId: session.id, url: session.url });
1273
1449
  return;
1274
1450
  }
@@ -1282,15 +1458,28 @@ var Bridge = class {
1282
1458
  }
1283
1459
  });
1284
1460
  socket.on("close", () => {
1461
+ clearTimeout(helloTimer);
1462
+ releasePending();
1285
1463
  if (session !== void 0) {
1286
- this.sessions.remove(session.id);
1287
- log("session_disconnected", { sessionId: session.id });
1464
+ if (this.sessions.remove(session)) {
1465
+ log("session_disconnected", { sessionId: session.id });
1466
+ }
1288
1467
  }
1289
1468
  });
1290
1469
  socket.on("error", (err) => {
1291
1470
  log("socket_error", { error: err.message });
1292
1471
  });
1293
1472
  }
1473
+ #originAllowed(origin) {
1474
+ if (origin === void 0)
1475
+ return true;
1476
+ const normalized = normalizeOrigin(origin);
1477
+ if (normalized === null)
1478
+ return false;
1479
+ if (this.#allowedOrigins.has(normalized))
1480
+ return true;
1481
+ return isLoopbackHostname(new URL(normalized).hostname);
1482
+ }
1294
1483
  #parse(text) {
1295
1484
  let json;
1296
1485
  try {
@@ -1307,6 +1496,8 @@ var Bridge = class {
1307
1496
  }
1308
1497
  close() {
1309
1498
  return new Promise((resolve) => {
1499
+ for (const client of this.#wss.clients)
1500
+ client.terminate();
1310
1501
  this.#wss.close(() => {
1311
1502
  resolve();
1312
1503
  });
@@ -1421,6 +1612,7 @@ var IrisTool = {
1421
1612
  STATE: "iris_state",
1422
1613
  CAPABILITIES: "iris_capabilities",
1423
1614
  CONTRACT_SAVE: "iris_contract_save",
1615
+ DOMAIN: "iris_domain",
1424
1616
  FLOW_SAVE: "iris_flow_save",
1425
1617
  FLOW_LIST: "iris_flow_list",
1426
1618
  FLOW_LOAD: "iris_flow_load",
@@ -1463,189 +1655,558 @@ var IrisTool = {
1463
1655
  ROLLBACK: "iris_rollback"
1464
1656
  };
1465
1657
 
1466
- // ../server/dist/project/iris-dir.js
1467
- import { join } from "path";
1468
- function irisDirPaths(root) {
1469
- return {
1470
- root,
1471
- contract: join(root, IrisDir.CONTRACT_FILE),
1472
- flows: join(root, IrisDir.FLOWS_SUBDIR),
1473
- baselines: join(root, IrisDir.BASELINES_SUBDIR),
1474
- project: join(root, IrisDir.PROJECT_FILE),
1475
- visual: join(root, IrisDir.VISUAL_SUBDIR)
1476
- };
1477
- }
1478
- function visualPath(root, name) {
1479
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.png`);
1480
- }
1481
- function visualDiffPath(root, name) {
1482
- return join(root, IrisDir.VISUAL_SUBDIR, `${name}.diff.png`);
1483
- }
1484
- function flowPath(root, name) {
1485
- return join(root, IrisDir.FLOWS_SUBDIR, `${name}.json`);
1486
- }
1487
- function isValidFlowName(name) {
1488
- return FLOW_NAME_PATTERN.test(name) && !name.includes("..");
1489
- }
1490
- async function ensureIrisDir(fs2, root) {
1491
- const p = irisDirPaths(root);
1492
- await fs2.mkdir(p.root);
1493
- await fs2.mkdir(p.flows);
1494
- await fs2.mkdir(p.baselines);
1495
- }
1496
- var JSON_INDENT = 2;
1497
- function stableSerialize(capabilities, generatedAt) {
1498
- const envelope = {
1499
- version: CONTRACT_FILE_VERSION,
1500
- generatedAt,
1501
- capabilities: {
1502
- testids: [...capabilities.testids].sort(),
1503
- signals: [...capabilities.signals].sort(),
1504
- stores: [...capabilities.stores].sort(),
1505
- 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)
1658
+ // ../server/dist/tools/tools-helpers.js
1659
+ function parseInteractive(tree) {
1660
+ const items = [];
1661
+ for (const line of tree.split("\n")) {
1662
+ const match = /\(ref=(e\d+)\)/.exec(line);
1663
+ if (match !== null) {
1664
+ items.push({ ref: match[1] ?? "", desc: line.replace(/\s*\(ref=e\d+\)/, "").trim() });
1506
1665
  }
1507
- };
1508
- return `${JSON.stringify(envelope, null, JSON_INDENT)}
1509
- `;
1510
- }
1511
- async function writeContract(fs2, root, capabilities, now) {
1512
- await ensureIrisDir(fs2, root);
1513
- await fs2.writeFile(irisDirPaths(root).contract, stableSerialize(capabilities, now()));
1514
- }
1515
- async function readContract(fs2, root) {
1516
- const path = irisDirPaths(root).contract;
1517
- if (!await fs2.exists(path))
1518
- return { ok: false, reason: ContractReadError.MISSING };
1519
- let text;
1520
- try {
1521
- text = await fs2.readFile(path);
1522
- } catch (error) {
1523
- return {
1524
- ok: false,
1525
- reason: fs2.isNotFound(error) ? ContractReadError.MISSING : ContractReadError.MALFORMED
1526
- };
1527
- }
1528
- let parsed;
1529
- try {
1530
- parsed = JSON.parse(text);
1531
- } catch {
1532
- return { ok: false, reason: ContractReadError.MALFORMED };
1533
1666
  }
1534
- const result = ContractFileSchema.safeParse(parsed);
1535
- if (!result.success)
1536
- return { ok: false, reason: ContractReadError.MALFORMED };
1537
- return { ok: true, capabilities: result.data.capabilities, generatedAt: result.data.generatedAt };
1667
+ return items;
1538
1668
  }
1539
-
1540
- // ../server/dist/flows/flows.js
1541
1669
  function asString(value) {
1542
1670
  return typeof value === "string" ? value : void 0;
1543
1671
  }
1672
+ function asNumber(value) {
1673
+ return typeof value === "number" ? value : void 0;
1674
+ }
1544
1675
  function asRecord(value) {
1545
1676
  return typeof value === "object" && value !== null ? value : {};
1546
1677
  }
1547
- function degradedAnchor() {
1548
- return { kind: AnchorKind.ROLE, role: DEGRADED_ANCHOR_ROLE };
1678
+
1679
+ // ../server/dist/flows/replay.js
1680
+ function asString2(value) {
1681
+ return typeof value === "string" ? value : void 0;
1549
1682
  }
1550
- function subStepToFlowStep(raw) {
1551
- const sub = asRecord(raw);
1552
- const by = asString(sub["by"]);
1553
- const value = asString(sub["value"]);
1554
- const action = asString(sub["action"]);
1555
- const args = asRecord(sub["args"]);
1556
- if (by === QueryBy.TESTID && value !== void 0) {
1557
- return buildStep(IrisTool.ACT, { kind: AnchorKind.TESTID, value }, action, args, false);
1558
- }
1559
- return buildStep(IrisTool.ACT, degradedAnchor(), action, args, true);
1683
+ function asRecord2(value) {
1684
+ return typeof value === "object" && value !== null ? value : {};
1560
1685
  }
1561
- function buildStep(tool, anchor, action, args, degraded) {
1562
- const step = { tool, anchor, args };
1563
- if (action !== void 0)
1564
- step.action = action;
1565
- if (degraded)
1566
- step.degraded = true;
1567
- return step;
1686
+ function replayActionArgs(value, confirmDangerous = false) {
1687
+ const args = { ...asRecord2(value) };
1688
+ delete args[DANGEROUS_ACTION_CONFIRM_ARG];
1689
+ if (confirmDangerous)
1690
+ args[DANGEROUS_ACTION_CONFIRM_ARG] = true;
1691
+ return args;
1568
1692
  }
1569
- function recordedStepToFlowStep(step) {
1570
- if (step.tool === IrisTool.ACT_SEQUENCE) {
1571
- const rawSubs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
1572
- const subs = rawSubs.map(subStepToFlowStep);
1573
- const degraded = subs.some((s) => s.degraded === true);
1574
- const anchor = subs[0]?.anchor ?? degradedAnchor();
1575
- const out2 = { tool: IrisTool.ACT_SEQUENCE, anchor, steps: subs };
1576
- if (degraded)
1577
- out2.degraded = true;
1578
- if (step.expect !== void 0)
1579
- out2.expect = step.expect;
1580
- return out2;
1693
+ function compileActStep(args, res) {
1694
+ const testid = asString2(asRecord2(res)["testid"]);
1695
+ const action = asString2(args["action"]) ?? "";
1696
+ const actArgs = replayActionArgs(args["args"]);
1697
+ if (testid !== void 0) {
1698
+ return {
1699
+ tool: IrisTool.ACT,
1700
+ stable: true,
1701
+ args: { by: QueryBy.TESTID, value: testid, action, args: actArgs }
1702
+ };
1581
1703
  }
1582
- const by = asString(step.args["by"]);
1583
- const value = asString(step.args["value"]);
1584
- const action = asString(step.args["action"]);
1585
- const args = asRecord(step.args["args"]);
1586
- 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);
1587
- if (step.expect !== void 0)
1588
- out.expect = step.expect;
1589
- return out;
1704
+ return {
1705
+ tool: IrisTool.ACT,
1706
+ stable: false,
1707
+ args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
1708
+ };
1590
1709
  }
1591
- function withAnnotations(flow, ann) {
1592
- if (ann === void 0)
1593
- return flow;
1594
- const steps = flow.steps.map((step, i) => {
1595
- const expect = ann.stepExpect.get(i);
1596
- return expect === void 0 ? step : { ...step, expect };
1710
+ function compileSequenceStep(args, res) {
1711
+ const inputSteps = Array.isArray(args["steps"]) ? args["steps"] : [];
1712
+ const resolved = Array.isArray(asRecord2(res)["steps"]) ? asRecord2(res)["steps"] : [];
1713
+ let stable = inputSteps.length > 0;
1714
+ const subSteps = inputSteps.map((raw, i) => {
1715
+ const step = asRecord2(raw);
1716
+ const action = asString2(step["action"]) ?? "";
1717
+ const stepArgs = replayActionArgs(step["args"]);
1718
+ const testid = asString2(asRecord2(resolved[i])["testid"]);
1719
+ if (testid !== void 0) {
1720
+ return { by: QueryBy.TESTID, value: testid, action, args: stepArgs };
1721
+ }
1722
+ stable = false;
1723
+ return { ref: asString2(step["ref"]) ?? "", action, args: stepArgs };
1597
1724
  });
1598
- const out = { ...flow, steps };
1599
- if (ann.dynamic.length > 0) {
1600
- out.dynamic = ann.dynamic.map((value) => ({ kind: AnchorKind.TESTID, value }));
1601
- }
1602
- if (ann.success !== void 0)
1603
- out.success = ann.success;
1604
- return out;
1725
+ return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
1605
1726
  }
1606
- var JSON_INDENT2 = 2;
1607
- var FLOW_SUFFIX = ".json";
1608
- var FlowStore = class {
1609
- #fs;
1610
- #root;
1611
- #clock;
1612
- constructor(fs2, root, clock) {
1613
- this.#fs = fs2;
1614
- this.#root = root;
1615
- this.#clock = clock;
1616
- }
1617
- /**
1618
- * The single byte-stable flow serializer: 2-space indent + one trailing newline. save(),
1619
- * saveFlow() and heal() all route through it so an unchanged flow that round-trips through any
1620
- * of them produces byte-identical on-disk content (locked by the byte-stability tests).
1621
- */
1622
- #serialize(flow) {
1623
- return `${JSON.stringify(flow, null, JSON_INDENT2)}
1624
- `;
1727
+ async function resolveRef(session, step) {
1728
+ const by = asString2(step.by);
1729
+ const value = asString2(step.value);
1730
+ if (by === QueryBy.TESTID && value !== void 0) {
1731
+ const result = await session.command(IrisCommand.QUERY, { by, value });
1732
+ if (!result.ok)
1733
+ throw new Error(result.error ?? "query failed");
1734
+ const elements = Array.isArray(asRecord2(result.result)["elements"]) ? asRecord2(result.result)["elements"] : [];
1735
+ const ref2 = asString2(asRecord2(elements[0])["ref"]);
1736
+ if (ref2 === void 0)
1737
+ throw new Error(`testid '${value}' did not resolve in current page`);
1738
+ return elements.length > 1 ? { ref: ref2, note: `ambiguous testid '${value}', used first match` } : { ref: ref2 };
1625
1739
  }
1626
- /**
1627
- * Convert a CompiledProgram (testid-normalized) into an anchored, on-disk flow + write it.
1628
- * Optionally fold structured annotations (per-step expect, dynamic[], success) onto
1629
- * the flow before writing. Omitting `annotations` reproduces the same bytes.
1630
- */
1631
- async save(program, annotations) {
1632
- if (!isValidFlowName(program.name)) {
1633
- return { ok: false, code: FlowErrorCode.INVALID_NAME };
1634
- }
1635
- const steps = program.steps.map(recordedStepToFlowStep);
1636
- const base = {
1637
- version: FLOW_FILE_VERSION,
1638
- name: program.name,
1639
- createdAt: this.#clock.now(),
1640
- steps
1641
- };
1642
- const flow = withAnnotations(base, annotations);
1643
- await this.#fs.mkdir(irisDirPaths(this.#root).flows);
1644
- await this.#fs.writeFile(flowPath(this.#root, program.name), this.#serialize(flow));
1645
- const degraded = flow.steps.filter((s) => s.degraded === true).length;
1646
- return {
1647
- ok: true,
1648
- value: {
1740
+ const ref = asString2(step.ref);
1741
+ if (ref === void 0 || ref.length === 0)
1742
+ throw new Error("step has no testid or ref to resolve");
1743
+ return { ref, note: "replayed by stale ref (not portable across sessions)" };
1744
+ }
1745
+ async function replayProgram(session, program, confirmDangerous = false) {
1746
+ const results = [];
1747
+ for (const step of program.steps) {
1748
+ try {
1749
+ if (step.tool === IrisTool.ACT_SEQUENCE) {
1750
+ const subs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
1751
+ const notes = [];
1752
+ const liveSteps = [];
1753
+ for (const raw of subs) {
1754
+ const sub = asRecord2(raw);
1755
+ const { ref, note } = await resolveRef(session, sub);
1756
+ if (note !== void 0)
1757
+ notes.push(note);
1758
+ liveSteps.push({
1759
+ ref,
1760
+ action: asString2(sub["action"]) ?? "",
1761
+ args: replayActionArgs(sub["args"], confirmDangerous)
1762
+ });
1763
+ }
1764
+ const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps });
1765
+ results.push(buildResult(step.tool, r.ok, r.error, notes));
1766
+ if (!r.ok)
1767
+ break;
1768
+ } else {
1769
+ const { ref, note } = await resolveRef(session, step.args);
1770
+ const r = await session.command(IrisCommand.ACT, {
1771
+ ref,
1772
+ action: asString2(step.args["action"]) ?? "",
1773
+ args: replayActionArgs(step.args["args"], confirmDangerous)
1774
+ });
1775
+ results.push(buildResult(step.tool, r.ok, r.error, note !== void 0 ? [note] : []));
1776
+ if (!r.ok)
1777
+ break;
1778
+ }
1779
+ } catch (e) {
1780
+ results.push({
1781
+ tool: step.tool,
1782
+ ok: false,
1783
+ error: e instanceof Error ? e.message : String(e)
1784
+ });
1785
+ break;
1786
+ }
1787
+ }
1788
+ return results;
1789
+ }
1790
+ function buildResult(tool, ok, error, notes) {
1791
+ const base = { tool, ok };
1792
+ if (!ok)
1793
+ base.error = error ?? "command failed";
1794
+ if (notes.length > 0)
1795
+ base.note = notes.join("; ");
1796
+ return base;
1797
+ }
1798
+
1799
+ // ../server/dist/flows/flow-replay.js
1800
+ function editDistance(a, b) {
1801
+ const s = a.toLowerCase();
1802
+ const t = b.toLowerCase();
1803
+ const rows = s.length + 1;
1804
+ const cols = t.length + 1;
1805
+ const prev = new Array(cols);
1806
+ const curr = new Array(cols);
1807
+ for (let j = 0; j < cols; j++)
1808
+ prev[j] = j;
1809
+ for (let i = 1; i < rows; i++) {
1810
+ curr[0] = i;
1811
+ for (let j = 1; j < cols; j++) {
1812
+ const cost = s[i - 1] === t[j - 1] ? 0 : 1;
1813
+ curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
1814
+ }
1815
+ for (let j = 0; j < cols; j++)
1816
+ prev[j] = curr[j] ?? 0;
1817
+ }
1818
+ return prev[cols - 1] ?? 0;
1819
+ }
1820
+ function nearestTestid(missing, present) {
1821
+ let best = null;
1822
+ let bestDistance = Number.POSITIVE_INFINITY;
1823
+ for (const candidate of present) {
1824
+ const distance = editDistance(missing, candidate);
1825
+ if (distance < bestDistance || distance === bestDistance && best !== null && candidate.length < best.length || distance === bestDistance && best !== null && candidate.length === best.length && candidate < best) {
1826
+ best = candidate;
1827
+ bestDistance = distance;
1828
+ }
1829
+ }
1830
+ return best;
1831
+ }
1832
+ function readQuery(result) {
1833
+ if (!result.ok)
1834
+ return { refs: [] };
1835
+ const payload = asRecord(result.result);
1836
+ const elements = Array.isArray(payload["elements"]) ? payload["elements"] : [];
1837
+ const refs = elements.map((e) => asString(asRecord(e)["ref"]) ?? "").filter((r) => r.length > 0);
1838
+ const rawHint = payload["hint"];
1839
+ if (typeof rawHint === "object" && rawHint !== null) {
1840
+ const hint = asRecord(rawHint);
1841
+ const present = Array.isArray(hint["presentTestids"]) ? hint["presentTestids"].filter((t) => typeof t === "string") : [];
1842
+ return {
1843
+ refs,
1844
+ hint: {
1845
+ route: asString(hint["route"]) ?? "",
1846
+ presentTestids: present,
1847
+ presentRegions: [],
1848
+ knownEmptyState: hint["knownEmptyState"] === true
1849
+ }
1850
+ };
1851
+ }
1852
+ return { refs };
1853
+ }
1854
+ function nearestIsAmbiguous(missing, present) {
1855
+ if (present.length < 2)
1856
+ return false;
1857
+ let min = Number.POSITIVE_INFINITY;
1858
+ let count = 0;
1859
+ for (const candidate of present) {
1860
+ const distance = editDistance(missing, candidate);
1861
+ if (distance < min) {
1862
+ min = distance;
1863
+ count = 1;
1864
+ } else if (distance === min) {
1865
+ count += 1;
1866
+ }
1867
+ }
1868
+ return count >= 2;
1869
+ }
1870
+ function testidDrift(value, hint) {
1871
+ const present = hint?.presentTestids ?? [];
1872
+ const drift = {
1873
+ reasonKind: DriftReason.TESTID_NOT_FOUND,
1874
+ reason: `testid "${value}" not found`,
1875
+ anchor: value,
1876
+ nearest: nearestTestid(value, present)
1877
+ };
1878
+ if (nearestIsAmbiguous(value, present))
1879
+ drift.ambiguous = true;
1880
+ return drift;
1881
+ }
1882
+ function anchorLabel(anchor) {
1883
+ if (anchor.kind === AnchorKind.TESTID)
1884
+ return anchor.value;
1885
+ if (anchor.kind === AnchorKind.SIGNAL)
1886
+ return anchor.name;
1887
+ return anchor.name ?? anchor.role;
1888
+ }
1889
+ async function runTestidStep(session, step, index, value, dynamic, confirmDangerous) {
1890
+ const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value });
1891
+ const { refs, hint } = readQuery(queryResult);
1892
+ if (refs.length === 0) {
1893
+ return {
1894
+ step: index,
1895
+ tool: step.tool,
1896
+ anchor: value,
1897
+ ok: false,
1898
+ drift: testidDrift(value, hint)
1899
+ };
1900
+ }
1901
+ const ref = refs[0] ?? "";
1902
+ const note = refs.length > 1 ? `ambiguous testid '${value}', used first match` : void 0;
1903
+ const act = await session.command(IrisCommand.ACT, {
1904
+ ref,
1905
+ action: step.action ?? "",
1906
+ args: replayActionArgs(step.args, confirmDangerous)
1907
+ });
1908
+ const result = { step: index, tool: step.tool, anchor: value, ok: act.ok };
1909
+ if (!act.ok) {
1910
+ result.error = act.error ?? "command failed";
1911
+ if (note !== void 0)
1912
+ result.note = note;
1913
+ return result;
1914
+ }
1915
+ const expectTestid = step.expect?.element?.testid;
1916
+ if (expectTestid !== void 0 && !dynamic.has(expectTestid)) {
1917
+ const expectQuery = await session.command(IrisCommand.QUERY, {
1918
+ by: QueryBy.TESTID,
1919
+ value: expectTestid
1920
+ });
1921
+ const expectRefs = readQuery(expectQuery);
1922
+ if (expectRefs.refs.length === 0) {
1923
+ return {
1924
+ step: index,
1925
+ tool: step.tool,
1926
+ anchor: expectTestid,
1927
+ ok: false,
1928
+ drift: testidDrift(expectTestid, expectRefs.hint)
1929
+ };
1930
+ }
1931
+ }
1932
+ if (note !== void 0)
1933
+ result.note = note;
1934
+ return result;
1935
+ }
1936
+ async function runSignalStep(session, step, index, name, waitForSignal, signalTimeoutMs) {
1937
+ const verdict = await waitForSignal(session, { kind: "signal", name }, signalTimeoutMs);
1938
+ if (verdict.pass)
1939
+ return { step: index, tool: step.tool, anchor: name, ok: true };
1940
+ return {
1941
+ step: index,
1942
+ tool: step.tool,
1943
+ anchor: name,
1944
+ ok: false,
1945
+ drift: {
1946
+ reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
1947
+ reason: `signal "${name}" not observed`,
1948
+ anchor: name,
1949
+ nearest: null
1950
+ }
1951
+ };
1952
+ }
1953
+ async function replayFlow(session, flow, waitForSignal, signalTimeoutMs, confirmDangerous = false) {
1954
+ const results = [];
1955
+ const dynamic = new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
1956
+ let index = 0;
1957
+ for (const step of flow.steps) {
1958
+ const label = anchorLabel(step.anchor);
1959
+ let result;
1960
+ if (step.anchor.kind === AnchorKind.SIGNAL) {
1961
+ result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs);
1962
+ } else {
1963
+ result = await runTestidStep(session, step, index, label, dynamic, confirmDangerous);
1964
+ }
1965
+ results.push(result);
1966
+ if (result.drift !== void 0 || !result.ok)
1967
+ break;
1968
+ index += 1;
1969
+ }
1970
+ return results;
1971
+ }
1972
+
1973
+ // ../server/dist/flows/heal.js
1974
+ function confidenceFor(from, to) {
1975
+ if (from === to)
1976
+ return 1;
1977
+ const span = Math.max(from.length, to.length);
1978
+ if (span === 0)
1979
+ return 1;
1980
+ const raw = 1 - editDistance(from, to) / span;
1981
+ if (raw >= 1)
1982
+ return 1;
1983
+ if (raw <= 0)
1984
+ return Number.EPSILON;
1985
+ return raw;
1986
+ }
1987
+ function applyHealChanges(flow, changes) {
1988
+ const byStep = /* @__PURE__ */ new Map();
1989
+ for (const change of changes)
1990
+ byStep.set(change.step, change);
1991
+ const applied = [];
1992
+ const steps = flow.steps.map((step, index) => {
1993
+ const change = byStep.get(index);
1994
+ if (change === void 0 || step.anchor.kind !== AnchorKind.TESTID || step.anchor.value !== change.from) {
1995
+ return step;
1996
+ }
1997
+ applied.push(change);
1998
+ return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
1999
+ });
2000
+ return { flow: { ...flow, steps }, applied };
2001
+ }
2002
+ function proposeRebindWith(drift, step, minConfidence) {
2003
+ if (drift.reasonKind !== DriftReason.TESTID_NOT_FOUND)
2004
+ return void 0;
2005
+ if (drift.ambiguous === true)
2006
+ return void 0;
2007
+ const to = drift.nearest;
2008
+ if (to === null)
2009
+ return void 0;
2010
+ const confidence = confidenceFor(drift.anchor, to);
2011
+ if (confidence < minConfidence)
2012
+ return void 0;
2013
+ return { step, from: drift.anchor, to, confidence };
2014
+ }
2015
+ function collectProposals(steps, minConfidence = HEAL_CONFIDENCE_MIN) {
2016
+ const proposals = [];
2017
+ for (const step of steps) {
2018
+ if (step.drift === void 0)
2019
+ continue;
2020
+ const proposal = proposeRebindWith(step.drift, step.step, minConfidence);
2021
+ if (proposal !== void 0)
2022
+ proposals.push(proposal);
2023
+ }
2024
+ return proposals;
2025
+ }
2026
+
2027
+ // ../server/dist/project/iris-dir.js
2028
+ import { join } from "path";
2029
+ function irisDirPaths(root) {
2030
+ return {
2031
+ root,
2032
+ contract: join(root, IrisDir.CONTRACT_FILE),
2033
+ flows: join(root, IrisDir.FLOWS_SUBDIR),
2034
+ baselines: join(root, IrisDir.BASELINES_SUBDIR),
2035
+ project: join(root, IrisDir.PROJECT_FILE),
2036
+ visual: join(root, IrisDir.VISUAL_SUBDIR)
2037
+ };
2038
+ }
2039
+ function visualPath(root, name) {
2040
+ return join(root, IrisDir.VISUAL_SUBDIR, `${name}.png`);
2041
+ }
2042
+ function visualDiffPath(root, name) {
2043
+ return join(root, IrisDir.VISUAL_SUBDIR, `${name}.diff.png`);
2044
+ }
2045
+ function flowPath(root, name) {
2046
+ return join(root, IrisDir.FLOWS_SUBDIR, `${name}.json`);
2047
+ }
2048
+ function isValidFlowName(name) {
2049
+ return FLOW_NAME_PATTERN.test(name) && !name.includes("..");
2050
+ }
2051
+ async function ensureIrisDir(fs2, root) {
2052
+ const p = irisDirPaths(root);
2053
+ await fs2.mkdir(p.root);
2054
+ await fs2.mkdir(p.flows);
2055
+ await fs2.mkdir(p.baselines);
2056
+ }
2057
+ var JSON_INDENT = 2;
2058
+ function stableSerialize(capabilities, generatedAt) {
2059
+ const envelope = {
2060
+ version: CONTRACT_FILE_VERSION,
2061
+ generatedAt,
2062
+ capabilities: {
2063
+ testids: [...capabilities.testids].sort(),
2064
+ signals: [...capabilities.signals].sort(),
2065
+ stores: [...capabilities.stores].sort(),
2066
+ 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)
2067
+ }
2068
+ };
2069
+ return `${JSON.stringify(envelope, null, JSON_INDENT)}
2070
+ `;
2071
+ }
2072
+ async function writeContract(fs2, root, capabilities, now) {
2073
+ await ensureIrisDir(fs2, root);
2074
+ await fs2.writeFile(irisDirPaths(root).contract, stableSerialize(capabilities, now()));
2075
+ }
2076
+ async function readContract(fs2, root) {
2077
+ const path = irisDirPaths(root).contract;
2078
+ if (!await fs2.exists(path))
2079
+ return { ok: false, reason: ContractReadError.MISSING };
2080
+ let text;
2081
+ try {
2082
+ text = await fs2.readFile(path);
2083
+ } catch (error) {
2084
+ return {
2085
+ ok: false,
2086
+ reason: fs2.isNotFound(error) ? ContractReadError.MISSING : ContractReadError.MALFORMED
2087
+ };
2088
+ }
2089
+ let parsed;
2090
+ try {
2091
+ parsed = JSON.parse(text);
2092
+ } catch {
2093
+ return { ok: false, reason: ContractReadError.MALFORMED };
2094
+ }
2095
+ const result = ContractFileSchema.safeParse(parsed);
2096
+ if (!result.success)
2097
+ return { ok: false, reason: ContractReadError.MALFORMED };
2098
+ return { ok: true, capabilities: result.data.capabilities, generatedAt: result.data.generatedAt };
2099
+ }
2100
+
2101
+ // ../server/dist/flows/flows.js
2102
+ function asString3(value) {
2103
+ return typeof value === "string" ? value : void 0;
2104
+ }
2105
+ function asRecord3(value) {
2106
+ return typeof value === "object" && value !== null ? value : {};
2107
+ }
2108
+ function degradedAnchor() {
2109
+ return { kind: AnchorKind.ROLE, role: DEGRADED_ANCHOR_ROLE };
2110
+ }
2111
+ function subStepToFlowStep(raw) {
2112
+ const sub = asRecord3(raw);
2113
+ const by = asString3(sub["by"]);
2114
+ const value = asString3(sub["value"]);
2115
+ const action = asString3(sub["action"]);
2116
+ const args = asRecord3(sub["args"]);
2117
+ if (by === QueryBy.TESTID && value !== void 0) {
2118
+ return buildStep(IrisTool.ACT, { kind: AnchorKind.TESTID, value }, action, args, false);
2119
+ }
2120
+ return buildStep(IrisTool.ACT, degradedAnchor(), action, args, true);
2121
+ }
2122
+ function buildStep(tool, anchor, action, args, degraded) {
2123
+ const step = { tool, anchor, args };
2124
+ if (action !== void 0)
2125
+ step.action = action;
2126
+ if (degraded)
2127
+ step.degraded = true;
2128
+ return step;
2129
+ }
2130
+ function recordedStepToFlowStep(step) {
2131
+ if (step.tool === IrisTool.ACT_SEQUENCE) {
2132
+ const rawSubs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
2133
+ const subs = rawSubs.map(subStepToFlowStep);
2134
+ const degraded = subs.some((s) => s.degraded === true);
2135
+ const anchor = subs[0]?.anchor ?? degradedAnchor();
2136
+ const out2 = { tool: IrisTool.ACT_SEQUENCE, anchor, steps: subs };
2137
+ if (degraded)
2138
+ out2.degraded = true;
2139
+ if (step.expect !== void 0)
2140
+ out2.expect = step.expect;
2141
+ return out2;
2142
+ }
2143
+ const by = asString3(step.args["by"]);
2144
+ const value = asString3(step.args["value"]);
2145
+ const action = asString3(step.args["action"]);
2146
+ const args = asRecord3(step.args["args"]);
2147
+ 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);
2148
+ if (step.expect !== void 0)
2149
+ out.expect = step.expect;
2150
+ return out;
2151
+ }
2152
+ function withAnnotations(flow, ann) {
2153
+ if (ann === void 0)
2154
+ return flow;
2155
+ const steps = flow.steps.map((step, i) => {
2156
+ const expect = ann.stepExpect.get(i);
2157
+ return expect === void 0 ? step : { ...step, expect };
2158
+ });
2159
+ const out = { ...flow, steps };
2160
+ if (ann.dynamic.length > 0) {
2161
+ out.dynamic = ann.dynamic.map((value) => ({ kind: AnchorKind.TESTID, value }));
2162
+ }
2163
+ if (ann.success !== void 0)
2164
+ out.success = ann.success;
2165
+ return out;
2166
+ }
2167
+ var JSON_INDENT2 = 2;
2168
+ var FLOW_SUFFIX = ".json";
2169
+ var FlowStore = class {
2170
+ #fs;
2171
+ #root;
2172
+ #clock;
2173
+ constructor(fs2, root, clock) {
2174
+ this.#fs = fs2;
2175
+ this.#root = root;
2176
+ this.#clock = clock;
2177
+ }
2178
+ /**
2179
+ * The single byte-stable flow serializer: 2-space indent + one trailing newline. save(),
2180
+ * saveFlow() and heal() all route through it so an unchanged flow that round-trips through any
2181
+ * of them produces byte-identical on-disk content (locked by the byte-stability tests).
2182
+ */
2183
+ #serialize(flow) {
2184
+ return `${JSON.stringify(flow, null, JSON_INDENT2)}
2185
+ `;
2186
+ }
2187
+ /**
2188
+ * Convert a CompiledProgram (testid-normalized) into an anchored, on-disk flow + write it.
2189
+ * Optionally fold structured annotations (per-step expect, dynamic[], success) onto
2190
+ * the flow before writing. Omitting `annotations` reproduces the same bytes.
2191
+ */
2192
+ async save(program, annotations) {
2193
+ if (!isValidFlowName(program.name)) {
2194
+ return { ok: false, code: FlowErrorCode.INVALID_NAME };
2195
+ }
2196
+ const steps = program.steps.map(recordedStepToFlowStep);
2197
+ const base = {
2198
+ version: FLOW_FILE_VERSION,
2199
+ name: program.name,
2200
+ createdAt: this.#clock.now(),
2201
+ steps
2202
+ };
2203
+ const flow = withAnnotations(base, annotations);
2204
+ await this.#fs.mkdir(irisDirPaths(this.#root).flows);
2205
+ await this.#fs.writeFile(flowPath(this.#root, program.name), this.#serialize(flow));
2206
+ const degraded = flow.steps.filter((s) => s.degraded === true).length;
2207
+ return {
2208
+ ok: true,
2209
+ value: {
1649
2210
  name: program.name,
1650
2211
  stepCount: flow.steps.length,
1651
2212
  degraded,
@@ -1696,19 +2257,7 @@ var FlowStore = class {
1696
2257
  if (!loaded.ok)
1697
2258
  return { ok: false, code: loaded.code };
1698
2259
  const flow = loaded.value;
1699
- const byStep = /* @__PURE__ */ new Map();
1700
- for (const change of changes)
1701
- byStep.set(change.step, change);
1702
- const applied = [];
1703
- const steps = flow.steps.map((step, index) => {
1704
- const change = byStep.get(index);
1705
- if (change === void 0 || step.anchor.kind !== AnchorKind.TESTID || step.anchor.value !== change.from) {
1706
- return step;
1707
- }
1708
- applied.push(change);
1709
- return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
1710
- });
1711
- const next = { ...flow, steps };
2260
+ const { flow: next, applied } = applyHealChanges(flow, changes);
1712
2261
  await this.#fs.writeFile(flowPath(this.#root, name), this.#serialize(next));
1713
2262
  return { ok: true, value: { name, changed: applied } };
1714
2263
  }
@@ -1936,10 +2485,10 @@ function createNodeFileSystem() {
1936
2485
 
1937
2486
  // ../server/dist/mcp.js
1938
2487
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1939
- import { z as z16 } from "zod";
2488
+ import { z as z17 } from "zod";
1940
2489
 
1941
2490
  // ../server/dist/tools/tools.js
1942
- import { z as z15 } from "zod";
2491
+ import { z as z16 } from "zod";
1943
2492
 
1944
2493
  // ../server/dist/input/real-input.js
1945
2494
  var DriveError = class extends Error {
@@ -2000,8 +2549,10 @@ async function performGesture(page, action, box, args, sleep) {
2000
2549
  }
2001
2550
  return { performed: false, center };
2002
2551
  }
2552
+ var HIDE_IRIS_CHROME_CSS = "[data-iris-overlay]{display:none !important}";
2553
+ var SCREENSHOT_DETERMINISM = { style: HIDE_IRIS_CHROME_CSS, animations: "disabled" };
2003
2554
  async function capturePage(page, opts) {
2004
- const buf = await page.screenshot(opts.clip !== void 0 ? { clip: opts.clip } : opts.fullPage === true ? { fullPage: true } : {});
2555
+ const buf = await page.screenshot(opts.clip !== void 0 ? { ...SCREENSHOT_DETERMINISM, clip: opts.clip } : opts.fullPage === true ? { ...SCREENSHOT_DETERMINISM, fullPage: true } : { ...SCREENSHOT_DETERMINISM });
2005
2556
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
2006
2557
  }
2007
2558
  var nodeSleep = (ms) => new Promise((resolve) => {
@@ -2236,6 +2787,7 @@ var PredicateSchema = z3.lazy(() => z3.discriminatedUnion("kind", [
2236
2787
  name: z3.string().optional(),
2237
2788
  dataMatches: z3.record(z3.unknown()).optional()
2238
2789
  }),
2790
+ z3.object({ kind: z3.literal("settled"), quietMs: z3.number().positive().optional() }),
2239
2791
  z3.object({ kind: z3.literal("allOf"), predicates: z3.array(PredicateSchema) }),
2240
2792
  z3.object({ kind: z3.literal("anyOf"), predicates: z3.array(PredicateSchema) }),
2241
2793
  z3.object({ kind: z3.literal("not"), predicate: PredicateSchema })
@@ -2432,6 +2984,39 @@ function evalSignal(events, p) {
2432
2984
  evidence: sameName.length > 0 ? { nearMiss: sameName } : void 0
2433
2985
  };
2434
2986
  }
2987
+ var SETTLE_ACTIVITY = /* @__PURE__ */ new Set([
2988
+ EventType.NET_REQUEST,
2989
+ EventType.DOM_ADDED,
2990
+ EventType.DOM_REMOVED,
2991
+ EventType.DOM_ATTR
2992
+ ]);
2993
+ var DEFAULT_QUIET_MS = 500;
2994
+ function evalSettled(events, p, now) {
2995
+ const quietMs = p.quietMs ?? DEFAULT_QUIET_MS;
2996
+ let lastT = -1;
2997
+ let lastType;
2998
+ for (const e of events) {
2999
+ if (SETTLE_ACTIVITY.has(e.type) && e.t > lastT) {
3000
+ lastT = e.t;
3001
+ lastType = e.type;
3002
+ }
3003
+ }
3004
+ if (lastT < 0) {
3005
+ return {
3006
+ pass: true,
3007
+ evidence: { settled: true, quietForMs: null, note: "no activity to settle" }
3008
+ };
3009
+ }
3010
+ const quietForMs = now - lastT;
3011
+ if (quietForMs >= quietMs) {
3012
+ return { pass: true, evidence: { settled: true, quietForMs, lastActivity: lastType } };
3013
+ }
3014
+ return {
3015
+ pass: false,
3016
+ failureReason: `not settled: last activity (${String(lastType)}) ${String(quietForMs)}ms ago, need ${String(quietMs)}ms quiet`,
3017
+ evidence: { quietForMs, lastActivity: lastType }
3018
+ };
3019
+ }
2435
3020
  async function evaluatePredicate(session, predicate, since = 0) {
2436
3021
  const events = session.eventsSince(since);
2437
3022
  switch (predicate.kind) {
@@ -2449,6 +3034,8 @@ async function evaluatePredicate(session, predicate, since = 0) {
2449
3034
  return evalAnimation(events, predicate);
2450
3035
  case "signal":
2451
3036
  return evalSignal(events, predicate);
3037
+ case "settled":
3038
+ return evalSettled(events, predicate, session.elapsed());
2452
3039
  case "allOf": {
2453
3040
  const results = await Promise.all(predicate.predicates.map((p) => evaluatePredicate(session, p, since)));
2454
3041
  const failed = results.find((r) => !r.pass);
@@ -2474,6 +3061,10 @@ async function evaluatePredicate(session, predicate, since = 0) {
2474
3061
  function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2475
3062
  return new Promise((resolve) => {
2476
3063
  let done = false;
3064
+ const failed = (error) => ({
3065
+ pass: false,
3066
+ failureReason: error instanceof Error ? error.message : String(error)
3067
+ });
2477
3068
  const finish = (result) => {
2478
3069
  if (done)
2479
3070
  return;
@@ -2487,6 +3078,8 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2487
3078
  void evaluatePredicate(session, predicate, since).then((r) => {
2488
3079
  if (r.pass)
2489
3080
  finish(r);
3081
+ }).catch((error) => {
3082
+ finish(failed(error));
2490
3083
  });
2491
3084
  };
2492
3085
  const unsub = session.onEvent(() => {
@@ -2500,141 +3093,30 @@ function waitForPredicate(session, predicate, timeoutMs, since = 0) {
2500
3093
  evidence: r.evidence,
2501
3094
  failureReason: r.failureReason ?? "timed out waiting for predicate"
2502
3095
  });
3096
+ }).catch((error) => {
3097
+ finish(failed(error));
2503
3098
  });
2504
3099
  }, timeoutMs);
2505
3100
  check();
2506
3101
  });
2507
3102
  }
2508
3103
 
2509
- // ../server/dist/flows/replay.js
2510
- function asString2(value) {
2511
- return typeof value === "string" ? value : void 0;
2512
- }
2513
- function asRecord2(value) {
2514
- return typeof value === "object" && value !== null ? value : {};
2515
- }
2516
- function compileActStep(args, res) {
2517
- const testid = asString2(asRecord2(res)["testid"]);
2518
- const action = asString2(args["action"]) ?? "";
2519
- const actArgs = asRecord2(args["args"]);
2520
- if (testid !== void 0) {
2521
- return {
2522
- tool: IrisTool.ACT,
2523
- stable: true,
2524
- args: { by: QueryBy.TESTID, value: testid, action, args: actArgs }
2525
- };
2526
- }
2527
- return {
2528
- tool: IrisTool.ACT,
2529
- stable: false,
2530
- args: { ref: asString2(args["ref"]) ?? "", action, args: actArgs }
2531
- };
2532
- }
2533
- function compileSequenceStep(args, res) {
2534
- const inputSteps = Array.isArray(args["steps"]) ? args["steps"] : [];
2535
- const resolved = Array.isArray(asRecord2(res)["steps"]) ? asRecord2(res)["steps"] : [];
2536
- let stable = inputSteps.length > 0;
2537
- const subSteps = inputSteps.map((raw, i) => {
2538
- const step = asRecord2(raw);
2539
- const action = asString2(step["action"]) ?? "";
2540
- const stepArgs = asRecord2(step["args"]);
2541
- const testid = asString2(asRecord2(resolved[i])["testid"]);
2542
- if (testid !== void 0) {
2543
- return { by: QueryBy.TESTID, value: testid, action, args: stepArgs };
2544
- }
2545
- stable = false;
2546
- return { ref: asString2(step["ref"]) ?? "", action, args: stepArgs };
2547
- });
2548
- return { tool: IrisTool.ACT_SEQUENCE, stable, args: { steps: subSteps } };
2549
- }
2550
- async function resolveRef(session, step) {
2551
- const by = asString2(step.by);
2552
- const value = asString2(step.value);
2553
- if (by === QueryBy.TESTID && value !== void 0) {
2554
- const result = await session.command(IrisCommand.QUERY, { by, value });
2555
- if (!result.ok)
2556
- throw new Error(result.error ?? "query failed");
2557
- const elements = Array.isArray(asRecord2(result.result)["elements"]) ? asRecord2(result.result)["elements"] : [];
2558
- const ref2 = asString2(asRecord2(elements[0])["ref"]);
2559
- if (ref2 === void 0)
2560
- throw new Error(`testid '${value}' did not resolve in current page`);
2561
- return elements.length > 1 ? { ref: ref2, note: `ambiguous testid '${value}', used first match` } : { ref: ref2 };
2562
- }
2563
- const ref = asString2(step.ref);
2564
- if (ref === void 0 || ref.length === 0)
2565
- throw new Error("step has no testid or ref to resolve");
2566
- return { ref, note: "replayed by stale ref (not portable across sessions)" };
2567
- }
2568
- async function replayProgram(session, program) {
2569
- const results = [];
2570
- for (const step of program.steps) {
2571
- try {
2572
- if (step.tool === IrisTool.ACT_SEQUENCE) {
2573
- const subs = Array.isArray(step.args["steps"]) ? step.args["steps"] : [];
2574
- const notes = [];
2575
- const liveSteps = [];
2576
- for (const raw of subs) {
2577
- const sub = asRecord2(raw);
2578
- const { ref, note } = await resolveRef(session, sub);
2579
- if (note !== void 0)
2580
- notes.push(note);
2581
- liveSteps.push({
2582
- ref,
2583
- action: asString2(sub["action"]) ?? "",
2584
- args: asRecord2(sub["args"])
2585
- });
2586
- }
2587
- const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps });
2588
- results.push(buildResult(step.tool, r.ok, r.error, notes));
2589
- if (!r.ok)
2590
- break;
2591
- } else {
2592
- const { ref, note } = await resolveRef(session, step.args);
2593
- const r = await session.command(IrisCommand.ACT, {
2594
- ref,
2595
- action: asString2(step.args["action"]) ?? "",
2596
- args: asRecord2(step.args["args"])
2597
- });
2598
- results.push(buildResult(step.tool, r.ok, r.error, note !== void 0 ? [note] : []));
2599
- if (!r.ok)
2600
- break;
2601
- }
2602
- } catch (e) {
2603
- results.push({
2604
- tool: step.tool,
2605
- ok: false,
2606
- error: e instanceof Error ? e.message : String(e)
2607
- });
2608
- break;
2609
- }
2610
- }
2611
- return results;
2612
- }
2613
- function buildResult(tool, ok, error, notes) {
2614
- const base = { tool, ok };
2615
- if (!ok)
2616
- base.error = error ?? "command failed";
2617
- if (notes.length > 0)
2618
- base.note = notes.join("; ");
2619
- return base;
2620
- }
2621
-
2622
3104
  // ../server/dist/events/event-filters.js
2623
- function asString3(value) {
3105
+ function asString4(value) {
2624
3106
  return typeof value === "string" ? value : void 0;
2625
3107
  }
2626
- function asNumber(value) {
3108
+ function asNumber2(value) {
2627
3109
  return typeof value === "number" ? value : void 0;
2628
3110
  }
2629
3111
  function matchNet(e, method, urlContains, status) {
2630
3112
  const d = e.data;
2631
- if (method !== void 0 && asString3(d["method"])?.toUpperCase() !== method.toUpperCase()) {
3113
+ if (method !== void 0 && asString4(d["method"])?.toUpperCase() !== method.toUpperCase()) {
2632
3114
  return false;
2633
3115
  }
2634
- if (urlContains !== void 0 && !(asString3(d["url"]) ?? "").includes(urlContains)) {
3116
+ if (urlContains !== void 0 && !(asString4(d["url"]) ?? "").includes(urlContains)) {
2635
3117
  return false;
2636
3118
  }
2637
- if (status !== void 0 && asNumber(d["status"]) !== status)
3119
+ if (status !== void 0 && asNumber2(d["status"]) !== status)
2638
3120
  return false;
2639
3121
  return true;
2640
3122
  }
@@ -2651,8 +3133,8 @@ function matchConsole(e, level) {
2651
3133
  var HINT_SAMPLE_MAX = 5;
2652
3134
  function netEmptyHint(allNet) {
2653
3135
  const present = allNet.slice(-HINT_SAMPLE_MAX).reverse().map((e) => {
2654
- const status = asNumber(e.data["status"]);
2655
- const base = { method: asString3(e.data["method"]) ?? "", url: asString3(e.data["url"]) ?? "" };
3136
+ const status = asNumber2(e.data["status"]);
3137
+ const base = { method: asString4(e.data["method"]) ?? "", url: asString4(e.data["url"]) ?? "" };
2656
3138
  return status === void 0 ? base : { ...base, status };
2657
3139
  });
2658
3140
  return { totalInWindow: allNet.length, present };
@@ -2683,6 +3165,8 @@ function refuseIfThrottled(session, refuse) {
2683
3165
  }
2684
3166
 
2685
3167
  // ../server/dist/session/output-budget.js
3168
+ var LARGE_TIMELINE_EVENTS = 80;
3169
+ var LARGE_TIMELINE_BYTES = 8e3;
2686
3170
  function applyEventBudget(events, maxEvents) {
2687
3171
  if (maxEvents === void 0 || maxEvents < 0 || events.length <= maxEvents) {
2688
3172
  return { events, droppedOldest: 0 };
@@ -2693,8 +3177,92 @@ function applyEventBudget(events, maxEvents) {
2693
3177
  };
2694
3178
  }
2695
3179
  function costHint(payload, events, droppedOldest = 0) {
2696
- const bytes = JSON.stringify(payload)?.length ?? 0;
2697
- return droppedOldest > 0 ? { events, bytes, droppedOldest } : { events, bytes };
3180
+ const json = JSON.stringify(payload) ?? "";
3181
+ const bytes = json.length;
3182
+ const base = droppedOldest > 0 ? { events, bytes, droppedOldest } : { events, bytes };
3183
+ if (events >= LARGE_TIMELINE_EVENTS || bytes >= LARGE_TIMELINE_BYTES) {
3184
+ 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`;
3185
+ }
3186
+ return base;
3187
+ }
3188
+ var CHARS_PER_TOKEN = 4;
3189
+ function estimateTokens(text) {
3190
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
3191
+ }
3192
+ function sizeCost(payload) {
3193
+ const json = JSON.stringify(payload) ?? "";
3194
+ return { bytes: json.length, tokens: estimateTokens(json) };
3195
+ }
3196
+ function withSizeCost(result) {
3197
+ if (typeof result !== "object" || result === null)
3198
+ return result;
3199
+ return { ...result, cost: sizeCost(result) };
3200
+ }
3201
+
3202
+ // ../server/dist/tools/snapshot-delta.js
3203
+ var SnapshotDeltaMode = {
3204
+ FULL: "full",
3205
+ DELTA: "delta",
3206
+ UNCHANGED: "unchanged"
3207
+ };
3208
+ function snapshotDelta(prevTree, nextTree) {
3209
+ if (prevTree === void 0)
3210
+ return { mode: SnapshotDeltaMode.FULL };
3211
+ const { added, removed } = diffLines(normalizeLines(prevTree), normalizeLines(nextTree));
3212
+ if (added.length === 0 && removed.length === 0)
3213
+ return { mode: SnapshotDeltaMode.UNCHANGED };
3214
+ return {
3215
+ mode: SnapshotDeltaMode.DELTA,
3216
+ delta: { added, removed, addedCount: added.length, removedCount: removed.length }
3217
+ };
3218
+ }
3219
+ var DEFAULT_MAX_ENTRIES = 50;
3220
+ var SnapshotCache = class {
3221
+ #map = /* @__PURE__ */ new Map();
3222
+ #max;
3223
+ constructor(max = DEFAULT_MAX_ENTRIES) {
3224
+ this.#max = max;
3225
+ }
3226
+ /** Last tree for this key IF the route still matches; undefined when absent or route changed. */
3227
+ recall(key, route) {
3228
+ const entry = this.#map.get(key);
3229
+ return entry !== void 0 && entry.route === route ? entry.tree : void 0;
3230
+ }
3231
+ remember(key, route, tree) {
3232
+ if (this.#map.size >= this.#max && !this.#map.has(key)) {
3233
+ const oldest = this.#map.keys().next().value;
3234
+ if (oldest !== void 0)
3235
+ this.#map.delete(oldest);
3236
+ }
3237
+ this.#map.set(key, { route, tree });
3238
+ }
3239
+ };
3240
+ function snapshotCacheKey(sessionId, scope, mode) {
3241
+ return `${sessionId}\0${scope}\0${mode}`;
3242
+ }
3243
+ function applySnapshotDelta(raw, opts, cache) {
3244
+ if (typeof raw !== "object" || raw === null)
3245
+ return raw;
3246
+ const r = raw;
3247
+ if (typeof r["tree"] !== "string")
3248
+ return raw;
3249
+ const tree = r["tree"];
3250
+ const status = typeof r["status"] === "object" && r["status"] !== null ? r["status"] : {};
3251
+ const route = typeof status["route"] === "string" ? status["route"] : "";
3252
+ const key = snapshotCacheKey(opts.sessionId, opts.scope, opts.mode);
3253
+ if (!opts.diff) {
3254
+ cache.remember(key, route, tree);
3255
+ return raw;
3256
+ }
3257
+ const prev = cache.recall(key, route);
3258
+ cache.remember(key, route, tree);
3259
+ const decision = snapshotDelta(prev, tree);
3260
+ if (decision.mode === SnapshotDeltaMode.FULL)
3261
+ return raw;
3262
+ if (decision.mode === SnapshotDeltaMode.UNCHANGED) {
3263
+ return { mode: SnapshotDeltaMode.UNCHANGED, status: r["status"] };
3264
+ }
3265
+ return { mode: SnapshotDeltaMode.DELTA, delta: decision.delta, status: r["status"] };
2698
3266
  }
2699
3267
 
2700
3268
  // ../server/dist/session/state-select.js
@@ -2745,25 +3313,55 @@ function capDepth(value, maxDepth) {
2745
3313
  return value;
2746
3314
  }
2747
3315
 
2748
- // ../server/dist/tools/tools-helpers.js
2749
- function parseInteractive(tree) {
2750
- const items = [];
2751
- for (const line of tree.split("\n")) {
2752
- const match = /\(ref=(e\d+)\)/.exec(line);
2753
- if (match !== null) {
2754
- items.push({ ref: match[1] ?? "", desc: line.replace(/\s*\(ref=e\d+\)/, "").trim() });
3316
+ // ../server/dist/tools/query-paginate.js
3317
+ function paginateQueryResult(result, limit, countOnly) {
3318
+ if (typeof result !== "object" || result === null)
3319
+ return result;
3320
+ const record = result;
3321
+ const elements = record["elements"];
3322
+ if (!Array.isArray(elements))
3323
+ return result;
3324
+ const total = elements.length;
3325
+ if (countOnly) {
3326
+ const { elements: _dropped, ...rest } = record;
3327
+ return { ...rest, count: total };
3328
+ }
3329
+ if (limit !== void 0 && limit >= 0 && total > limit) {
3330
+ return { ...record, elements: elements.slice(0, limit), total, truncated: true };
3331
+ }
3332
+ return result;
3333
+ }
3334
+
3335
+ // ../server/dist/tools/assert-grade.js
3336
+ 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.";
3337
+ function walk(predicate) {
3338
+ switch (predicate.kind) {
3339
+ case "signal":
3340
+ case "net":
3341
+ return { consequence: true, presence: false };
3342
+ case "element":
3343
+ case "text":
3344
+ return { consequence: false, presence: true };
3345
+ case "route":
3346
+ case "console":
3347
+ case "animation":
3348
+ case "settled":
3349
+ return { consequence: false, presence: false };
3350
+ case "allOf":
3351
+ case "anyOf": {
3352
+ const subs = predicate.predicates.map(walk);
3353
+ return {
3354
+ consequence: subs.some((s) => s.consequence),
3355
+ presence: subs.some((s) => s.presence)
3356
+ };
2755
3357
  }
3358
+ case "not":
3359
+ return walk(predicate.predicate);
2756
3360
  }
2757
- return items;
2758
- }
2759
- function asString4(value) {
2760
- return typeof value === "string" ? value : void 0;
2761
- }
2762
- function asNumber2(value) {
2763
- return typeof value === "number" ? value : void 0;
2764
3361
  }
2765
- function asRecord3(value) {
2766
- return typeof value === "object" && value !== null ? value : {};
3362
+ function isPresenceOnlyAssertion(predicate) {
3363
+ const kinds = walk(predicate);
3364
+ return kinds.presence && !kinds.consequence;
2767
3365
  }
2768
3366
 
2769
3367
  // ../server/dist/tools/contract-tools.js
@@ -2797,7 +3395,7 @@ var CONTRACT_TOOLS = [
2797
3395
  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");
2798
3396
  return { ...r.capabilities, source: "disk", generatedAt: r.generatedAt };
2799
3397
  }
2800
- const caps = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3398
+ const caps = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2801
3399
  return { ...caps, source: "live" };
2802
3400
  }
2803
3401
  },
@@ -2812,7 +3410,7 @@ var CONTRACT_TOOLS = [
2812
3410
  signalCount: z4.number()
2813
3411
  },
2814
3412
  handler: async (deps, args) => {
2815
- const res = await commandOrThrow(deps, asString4(args["sessionId"]), IrisCommand.CAPABILITIES, {});
3413
+ const res = await commandOrThrow(deps, asString(args["sessionId"]), IrisCommand.CAPABILITIES, {});
2816
3414
  const caps = CapabilitiesSchema.parse(res);
2817
3415
  await writeContract(deps.fs, deps.irisRoot, caps, deps.now);
2818
3416
  return {
@@ -2825,253 +3423,399 @@ var CONTRACT_TOOLS = [
2825
3423
  }
2826
3424
  ];
2827
3425
 
2828
- // ../server/dist/tools/browser-tools.js
3426
+ // ../server/dist/domain/domain-tools.js
2829
3427
  import { z as z5 } from "zod";
2830
- var sessionIdShape2 = {
2831
- sessionId: z5.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3428
+
3429
+ // ../server/dist/flows/flow-classify.js
3430
+ var FlowAssertionGrade = {
3431
+ /** At least one step (or the success end-condition) asserts a signal/network consequence. */
3432
+ ASSERTED: "asserted",
3433
+ /** Only element-presence checks — a healed-but-wrong locator could still pass. */
3434
+ PRESENCE_ONLY: "presence-only",
3435
+ /** Performs actions but asserts nothing observable — passes even if the feature is broken. */
3436
+ ASSERTION_FREE: "assertion-free"
2832
3437
  };
2833
- async function commandOrThrow2(deps, sessionId, name, args) {
2834
- const session = deps.sessions.resolve(sessionId);
2835
- const result = await session.command(name, args);
2836
- if (!result.ok)
2837
- throw new Error(result.error ?? `command '${name}' failed`);
2838
- return result.result;
3438
+ 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.";
3439
+ 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).";
3440
+ function expectIsConsequence(e) {
3441
+ return e !== void 0 && (e.signal !== void 0 || e.net !== void 0);
2839
3442
  }
2840
- var BROWSER_TOOLS = [
2841
- {
2842
- name: IrisTool.NAVIGATE,
2843
- 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.",
2844
- inputSchema: {
2845
- url: z5.string().describe("The URL to navigate to."),
2846
- ...sessionIdShape2
2847
- },
2848
- outputSchema: {
2849
- ok: z5.boolean(),
2850
- url: z5.string().optional(),
2851
- reason: z5.string().optional()
2852
- },
2853
- handler: async (deps, args) => {
2854
- const url = asString4(args["url"]);
2855
- if (url === void 0 || url.length === 0)
2856
- return { ok: false, reason: "url required" };
2857
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.NAVIGATE, { url });
2858
- return { ok: true, url };
2859
- }
2860
- },
2861
- {
2862
- name: IrisTool.REFRESH,
2863
- 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.",
2864
- inputSchema: {
2865
- hard: z5.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
2866
- ...sessionIdShape2
2867
- },
2868
- outputSchema: {
2869
- ok: z5.boolean()
2870
- },
2871
- handler: async (deps, args) => {
2872
- await commandOrThrow2(deps, asString4(args["sessionId"]), IrisCommand.REFRESH, {
2873
- hard: args["hard"] === true
2874
- });
2875
- return { ok: true };
2876
- }
3443
+ function expectIsWeak(e) {
3444
+ return e !== void 0 && e.element !== void 0 && e.signal === void 0 && e.net === void 0;
3445
+ }
3446
+ function flattenSteps(steps) {
3447
+ const out = [];
3448
+ for (const s of steps) {
3449
+ out.push(s);
3450
+ if (s.steps !== void 0)
3451
+ out.push(...flattenSteps(s.steps));
2877
3452
  }
2878
- ];
2879
-
2880
- // ../server/dist/flows/flow-tools.js
2881
- import { z as z6 } from "zod";
3453
+ return out;
3454
+ }
3455
+ function classifyFlowAssertions(flow) {
3456
+ const all = flattenSteps(flow.steps);
3457
+ let consequenceSteps = 0;
3458
+ let weakSteps = 0;
3459
+ for (const s of all) {
3460
+ if (expectIsConsequence(s.expect))
3461
+ consequenceSteps++;
3462
+ else if (expectIsWeak(s.expect))
3463
+ weakSteps++;
3464
+ }
3465
+ const successIsConsequence = expectIsConsequence(flow.success);
3466
+ const successIsWeak = expectIsWeak(flow.success);
3467
+ const hasConsequenceAssertion = consequenceSteps > 0 || successIsConsequence;
3468
+ const hasAnyAssertion = hasConsequenceAssertion || weakSteps > 0 || successIsWeak;
3469
+ let grade;
3470
+ let warning;
3471
+ if (hasConsequenceAssertion) {
3472
+ grade = FlowAssertionGrade.ASSERTED;
3473
+ } else if (hasAnyAssertion) {
3474
+ grade = FlowAssertionGrade.PRESENCE_ONLY;
3475
+ warning = PRESENCE_ONLY_WARNING;
3476
+ } else {
3477
+ grade = FlowAssertionGrade.ASSERTION_FREE;
3478
+ warning = ASSERTION_FREE_WARNING;
3479
+ }
3480
+ return {
3481
+ grade,
3482
+ hasConsequenceAssertion,
3483
+ totalSteps: all.length,
3484
+ consequenceSteps,
3485
+ weakSteps,
3486
+ successIsConsequence,
3487
+ ...warning !== void 0 ? { warning } : {}
3488
+ };
3489
+ }
2882
3490
 
2883
- // ../server/dist/flows/flow-replay.js
2884
- function editDistance(a, b) {
2885
- const s = a.toLowerCase();
2886
- const t = b.toLowerCase();
2887
- const rows = s.length + 1;
2888
- const cols = t.length + 1;
2889
- const prev = new Array(cols);
2890
- const curr = new Array(cols);
2891
- for (let j = 0; j < cols; j++)
2892
- prev[j] = j;
2893
- for (let i = 1; i < rows; i++) {
2894
- curr[0] = i;
2895
- for (let j = 1; j < cols; j++) {
2896
- const cost = s[i - 1] === t[j - 1] ? 0 : 1;
2897
- curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
3491
+ // ../server/dist/flows/flow-success.js
3492
+ function dynamicTestids(flow) {
3493
+ return new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
3494
+ }
3495
+ function successLabel(success) {
3496
+ if (success.signal !== void 0)
3497
+ return success.signal;
3498
+ if (success.net !== void 0)
3499
+ return success.net.urlContains ?? success.net.method ?? "net";
3500
+ return success.element?.testid ?? success.element?.name ?? success.element?.role ?? "success";
3501
+ }
3502
+ function successToPredicate(success, dynamic) {
3503
+ const parts = [];
3504
+ if (success.signal !== void 0) {
3505
+ parts.push(success.signalData !== void 0 ? { kind: "signal", name: success.signal, dataMatches: success.signalData } : { kind: "signal", name: success.signal });
3506
+ }
3507
+ if (success.net !== void 0) {
3508
+ const net2 = { kind: "net" };
3509
+ if (success.net.method !== void 0)
3510
+ net2.method = success.net.method;
3511
+ if (success.net.urlContains !== void 0)
3512
+ net2.urlContains = success.net.urlContains;
3513
+ if (success.net.status !== void 0)
3514
+ net2.status = success.net.status;
3515
+ parts.push(net2);
3516
+ }
3517
+ const element = success.element;
3518
+ if (element !== void 0) {
3519
+ const testid = element.testid;
3520
+ if (testid === void 0 || !dynamic.has(testid)) {
3521
+ const query = {};
3522
+ if (testid !== void 0)
3523
+ query["testid"] = testid;
3524
+ if (element.role !== void 0)
3525
+ query["role"] = element.role;
3526
+ if (element.name !== void 0)
3527
+ query["name"] = element.name;
3528
+ if (Object.keys(query).length > 0)
3529
+ parts.push({ kind: "element", query });
2898
3530
  }
2899
- for (let j = 0; j < cols; j++)
2900
- prev[j] = curr[j] ?? 0;
2901
3531
  }
2902
- return prev[cols - 1] ?? 0;
3532
+ const [first] = parts;
3533
+ if (parts.length === 0)
3534
+ return void 0;
3535
+ if (parts.length === 1 && first !== void 0)
3536
+ return first;
3537
+ return { kind: "allOf", predicates: parts };
3538
+ }
3539
+ async function assertSuccess(session, success, dynamic, waitForSignal, timeoutMs, since = 0) {
3540
+ if (success === void 0)
3541
+ return { pass: true };
3542
+ const predicate = successToPredicate(success, dynamic);
3543
+ if (predicate === void 0)
3544
+ return { pass: true };
3545
+ return waitForSignal(session, predicate, timeoutMs, since);
2903
3546
  }
2904
- function nearestTestid(missing, present) {
2905
- let best = null;
2906
- let bestDistance = Number.POSITIVE_INFINITY;
2907
- for (const candidate of present) {
2908
- const distance = editDistance(missing, candidate);
2909
- if (distance < bestDistance || distance === bestDistance && best !== null && candidate.length < best.length || distance === bestDistance && best !== null && candidate.length === best.length && candidate < best) {
2910
- best = candidate;
2911
- bestDistance = distance;
2912
- }
3547
+
3548
+ // ../server/dist/domain/flow-risk.js
3549
+ var RiskLevel = {
3550
+ HIGH: "high",
3551
+ MEDIUM: "medium",
3552
+ LOW: "low",
3553
+ UNKNOWN: "unknown"
3554
+ };
3555
+ var RANK = {
3556
+ [RiskLevel.HIGH]: 3,
3557
+ [RiskLevel.MEDIUM]: 2,
3558
+ [RiskLevel.UNKNOWN]: 1,
3559
+ [RiskLevel.LOW]: 0
3560
+ };
3561
+ function latestRun(name, runs) {
3562
+ let best;
3563
+ for (const run of runs) {
3564
+ if (run.name === name && (best === void 0 || run.at > best.at))
3565
+ best = run;
2913
3566
  }
2914
3567
  return best;
2915
3568
  }
2916
- function readQuery(result) {
2917
- if (!result.ok)
2918
- return { refs: [] };
2919
- const payload = asRecord3(result.result);
2920
- const elements = Array.isArray(payload["elements"]) ? payload["elements"] : [];
2921
- const refs = elements.map((e) => asString4(asRecord3(e)["ref"]) ?? "").filter((r) => r.length > 0);
2922
- const rawHint = payload["hint"];
2923
- if (typeof rawHint === "object" && rawHint !== null) {
2924
- const hint = asRecord3(rawHint);
2925
- const present = Array.isArray(hint["presentTestids"]) ? hint["presentTestids"].filter((t) => typeof t === "string") : [];
3569
+ function runRisk(run) {
3570
+ if (run === void 0)
3571
+ return { level: RiskLevel.UNKNOWN, reason: "never run" };
3572
+ if (run.status === RunStatus.ERROR || run.status === RunStatus.FAIL) {
3573
+ return { level: RiskLevel.HIGH, reason: "last run failed" };
3574
+ }
3575
+ if (run.status === RunStatus.DRIFT)
3576
+ return { level: RiskLevel.HIGH, reason: "last run drifted" };
3577
+ const errors = (run.evidence?.consoleErrors ?? 0) + (run.evidence?.networkErrors ?? 0);
3578
+ if (errors > 0) {
2926
3579
  return {
2927
- refs,
2928
- hint: {
2929
- route: asString4(hint["route"]) ?? "",
2930
- presentTestids: present,
2931
- presentRegions: [],
2932
- knownEmptyState: hint["knownEmptyState"] === true
2933
- }
3580
+ level: RiskLevel.MEDIUM,
3581
+ reason: `last run passed but logged ${String(errors)} error(s)`
2934
3582
  };
2935
3583
  }
2936
- return { refs };
2937
- }
2938
- function testidDrift(value, hint) {
2939
- return {
2940
- reasonKind: DriftReason.TESTID_NOT_FOUND,
2941
- reason: `testid "${value}" not found`,
2942
- anchor: value,
2943
- nearest: nearestTestid(value, hint?.presentTestids ?? [])
2944
- };
2945
- }
2946
- function anchorLabel(anchor) {
2947
- if (anchor.kind === AnchorKind.TESTID)
2948
- return anchor.value;
2949
- if (anchor.kind === AnchorKind.SIGNAL)
2950
- return anchor.name;
2951
- return anchor.name ?? anchor.role;
3584
+ return { level: RiskLevel.LOW, reason: "last run passed clean" };
2952
3585
  }
2953
- async function runTestidStep(session, step, index, value, dynamic) {
2954
- const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value });
2955
- const { refs, hint } = readQuery(queryResult);
2956
- if (refs.length === 0) {
3586
+ function gradeRisk(grade) {
3587
+ if (grade === FlowAssertionGrade.ASSERTION_FREE) {
2957
3588
  return {
2958
- step: index,
2959
- tool: step.tool,
2960
- anchor: value,
2961
- ok: false,
2962
- drift: testidDrift(value, hint)
3589
+ level: RiskLevel.MEDIUM,
3590
+ reason: "asserts no consequence \u2014 a green run proves little"
2963
3591
  };
2964
3592
  }
2965
- const ref = refs[0] ?? "";
2966
- const note = refs.length > 1 ? `ambiguous testid '${value}', used first match` : void 0;
2967
- const act = await session.command(IrisCommand.ACT, {
2968
- ref,
2969
- action: step.action ?? "",
2970
- args: step.args ?? {}
2971
- });
2972
- const result = { step: index, tool: step.tool, anchor: value, ok: act.ok };
2973
- if (!act.ok) {
2974
- result.error = act.error ?? "command failed";
2975
- if (note !== void 0)
2976
- result.note = note;
2977
- return result;
3593
+ if (grade === FlowAssertionGrade.PRESENCE_ONLY) {
3594
+ return { level: RiskLevel.LOW, reason: "presence-only assertion" };
2978
3595
  }
2979
- const expectTestid = step.expect?.element?.testid;
2980
- if (expectTestid !== void 0 && !dynamic.has(expectTestid)) {
2981
- const expectQuery = await session.command(IrisCommand.QUERY, {
2982
- by: QueryBy.TESTID,
2983
- value: expectTestid
2984
- });
2985
- const expectRefs = readQuery(expectQuery);
2986
- if (expectRefs.refs.length === 0) {
2987
- return {
2988
- step: index,
2989
- tool: step.tool,
2990
- anchor: expectTestid,
2991
- ok: false,
2992
- drift: testidDrift(expectTestid, expectRefs.hint)
2993
- };
2994
- }
3596
+ return { level: RiskLevel.LOW, reason: "asserts a consequence" };
3597
+ }
3598
+ function flowRisk(grade, run) {
3599
+ const r = runRisk(run);
3600
+ const g = gradeRisk(grade);
3601
+ const top = RANK[r.level] >= RANK[g.level] ? r : g;
3602
+ return run === void 0 ? { level: top.level, reason: top.reason } : { level: top.level, reason: top.reason, lastStatus: run.status };
3603
+ }
3604
+ function rankByRisk(entries) {
3605
+ return [...entries].sort((a, b) => RANK[b.risk.level] - RANK[a.risk.level] || a.name.localeCompare(b.name)).map((e) => e.name);
3606
+ }
3607
+
3608
+ // ../server/dist/domain/domain-model.js
3609
+ function flatten(steps) {
3610
+ const out = [];
3611
+ for (const s of steps) {
3612
+ out.push(s);
3613
+ if (s.steps !== void 0)
3614
+ out.push(...flatten(s.steps));
2995
3615
  }
2996
- if (note !== void 0)
2997
- result.note = note;
2998
- return result;
3616
+ return out;
2999
3617
  }
3000
- async function runSignalStep(session, step, index, name, waitForSignal, signalTimeoutMs) {
3001
- const verdict = await waitForSignal(session, { kind: "signal", name }, signalTimeoutMs);
3002
- if (verdict.pass)
3003
- return { step: index, tool: step.tool, anchor: name, ok: true };
3618
+ function flowSignals(flow) {
3619
+ const set = /* @__PURE__ */ new Set();
3620
+ for (const step of flatten(flow.steps)) {
3621
+ if (step.anchor.kind === AnchorKind.SIGNAL)
3622
+ set.add(step.anchor.name);
3623
+ if (step.expect?.signal !== void 0)
3624
+ set.add(step.expect.signal);
3625
+ }
3626
+ if (flow.success?.signal !== void 0)
3627
+ set.add(flow.success.signal);
3628
+ return [...set];
3629
+ }
3630
+ function flowTestids(flow) {
3631
+ const set = /* @__PURE__ */ new Set();
3632
+ for (const step of flatten(flow.steps)) {
3633
+ if (step.anchor.kind === AnchorKind.TESTID)
3634
+ set.add(step.anchor.value);
3635
+ if (step.expect?.element?.testid !== void 0)
3636
+ set.add(step.expect.element.testid);
3637
+ }
3638
+ if (flow.success?.element?.testid !== void 0)
3639
+ set.add(flow.success.element.testid);
3640
+ return [...set];
3641
+ }
3642
+ var EMPTY_CONTRACT = { testids: [], signals: [], stores: [], flows: [] };
3643
+ function buildDomainModel(flows, contract, runs = []) {
3644
+ const caps = contract ?? EMPTY_CONTRACT;
3645
+ const hasHistory = runs.length > 0;
3646
+ const flowSummaries = flows.map((flow) => {
3647
+ const c = classifyFlowAssertions(flow);
3648
+ const summary = {
3649
+ name: flow.name,
3650
+ steps: c.totalSteps,
3651
+ grade: c.grade,
3652
+ asserts: c.hasConsequenceAssertion,
3653
+ signals: flowSignals(flow),
3654
+ testids: flowTestids(flow)
3655
+ };
3656
+ if (flow.success !== void 0)
3657
+ summary.mustHold = successLabel(flow.success);
3658
+ if (c.warning !== void 0)
3659
+ summary.warning = c.warning;
3660
+ if (hasHistory)
3661
+ summary.risk = flowRisk(c.grade, latestRun(flow.name, runs));
3662
+ return summary;
3663
+ });
3664
+ const testedSignals = new Set(flowSummaries.flatMap((f) => f.signals));
3665
+ const testedTestids = new Set(flowSummaries.flatMap((f) => f.testids));
3666
+ const coverage = {
3667
+ asserted: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.ASSERTED).length,
3668
+ presenceOnly: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.PRESENCE_ONLY).length,
3669
+ assertionFree: flowSummaries.filter((f) => f.grade === FlowAssertionGrade.ASSERTION_FREE).length
3670
+ };
3671
+ const gaps = {
3672
+ unassertedFlows: flowSummaries.filter((f) => !f.asserts).map((f) => f.name),
3673
+ declaredUntestedSignals: caps.signals.filter((s) => !testedSignals.has(s)),
3674
+ declaredUntestedTestids: caps.testids.filter((t) => !testedTestids.has(t))
3675
+ };
3676
+ const riskRanked = hasHistory ? rankByRisk(flowSummaries.filter((f) => f.risk !== void 0).map((f) => ({ name: f.name, risk: f.risk }))) : [];
3677
+ const top = riskRanked[0];
3678
+ const topFlow = top === void 0 ? void 0 : flowSummaries.find((f) => f.name === top);
3679
+ 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;
3004
3680
  return {
3005
- step: index,
3006
- tool: step.tool,
3007
- anchor: name,
3008
- ok: false,
3009
- drift: {
3010
- reasonKind: DriftReason.SIGNAL_NOT_OBSERVED,
3011
- reason: `signal "${name}" not observed`,
3012
- anchor: name,
3013
- nearest: null
3014
- }
3681
+ flowCount: flows.length,
3682
+ flows: flowSummaries,
3683
+ declared: { testids: caps.testids.length, signals: caps.signals, stores: caps.stores },
3684
+ coverage,
3685
+ gaps,
3686
+ riskRanked,
3687
+ summary: buildSummary(flows.length, coverage, gaps, topRisk)
3015
3688
  };
3016
3689
  }
3017
- async function replayFlow(session, flow, waitForSignal, signalTimeoutMs) {
3018
- const results = [];
3019
- const dynamic = new Set((flow.dynamic ?? []).filter((a) => a.kind === AnchorKind.TESTID).map((a) => a.kind === AnchorKind.TESTID ? a.value : ""));
3020
- let index = 0;
3021
- for (const step of flow.steps) {
3022
- const label = anchorLabel(step.anchor);
3023
- let result;
3024
- if (step.anchor.kind === AnchorKind.SIGNAL) {
3025
- result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs);
3026
- } else {
3027
- result = await runTestidStep(session, step, index, label, dynamic);
3028
- }
3029
- results.push(result);
3030
- if (result.drift !== void 0 || !result.ok)
3031
- break;
3032
- index += 1;
3690
+ function buildSummary(flowCount, coverage, gaps, topRisk) {
3691
+ if (flowCount === 0) {
3692
+ return "No saved flows yet \u2014 record the critical journeys (iris_record_start) so the agent learns the app.";
3033
3693
  }
3034
- return results;
3694
+ const parts = [
3695
+ `${String(flowCount)} flow${flowCount === 1 ? "" : "s"}: ${String(coverage.asserted)} asserted, ${String(coverage.presenceOnly)} presence-only, ${String(coverage.assertionFree)} assertion-free`
3696
+ ];
3697
+ if (topRisk !== void 0) {
3698
+ parts.push(`test first: ${topRisk.name} (${topRisk.reason})`);
3699
+ }
3700
+ if (gaps.declaredUntestedSignals.length > 0) {
3701
+ parts.push(`${String(gaps.declaredUntestedSignals.length)} declared signal(s) no flow asserts (${gaps.declaredUntestedSignals.join(", ")})`);
3702
+ }
3703
+ if (gaps.unassertedFlows.length > 0) {
3704
+ parts.push(`${String(gaps.unassertedFlows.length)} flow(s) assert no consequence`);
3705
+ }
3706
+ return parts.join(". ") + ".";
3035
3707
  }
3036
3708
 
3037
- // ../server/dist/flows/heal.js
3038
- function confidenceFor(from, to) {
3039
- if (from === to)
3040
- return 1;
3041
- const span = Math.max(from.length, to.length);
3042
- if (span === 0)
3043
- return 1;
3044
- const raw = 1 - editDistance(from, to) / span;
3045
- if (raw >= 1)
3046
- return 1;
3047
- if (raw <= 0)
3048
- return Number.EPSILON;
3049
- return raw;
3050
- }
3051
- function proposeRebindWith(drift, step, minConfidence) {
3052
- if (drift.reasonKind !== DriftReason.TESTID_NOT_FOUND)
3053
- return void 0;
3054
- const to = drift.nearest;
3055
- if (to === null)
3056
- return void 0;
3057
- const confidence = confidenceFor(drift.anchor, to);
3058
- if (confidence < minConfidence)
3059
- return void 0;
3060
- return { step, from: drift.anchor, to, confidence };
3709
+ // ../server/dist/domain/domain-tools.js
3710
+ var DOMAIN_TOOLS = [
3711
+ {
3712
+ name: IrisTool.DOMAIN,
3713
+ 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).",
3714
+ inputSchema: {},
3715
+ outputSchema: {
3716
+ flowCount: z5.number(),
3717
+ flows: z5.array(z5.object({
3718
+ name: z5.string(),
3719
+ steps: z5.number(),
3720
+ grade: z5.string(),
3721
+ asserts: z5.boolean(),
3722
+ mustHold: z5.string().optional().describe("The success consequence that must hold for this flow (what it actually tests)."),
3723
+ warning: z5.string().optional(),
3724
+ signals: z5.array(z5.string()),
3725
+ testids: z5.array(z5.string())
3726
+ })),
3727
+ declared: z5.object({
3728
+ testids: z5.number(),
3729
+ signals: z5.array(z5.string()),
3730
+ stores: z5.array(z5.string())
3731
+ }),
3732
+ coverage: z5.object({
3733
+ asserted: z5.number(),
3734
+ presenceOnly: z5.number(),
3735
+ assertionFree: z5.number()
3736
+ }),
3737
+ gaps: z5.object({
3738
+ unassertedFlows: z5.array(z5.string()),
3739
+ declaredUntestedSignals: z5.array(z5.string()),
3740
+ declaredUntestedTestids: z5.array(z5.string())
3741
+ }),
3742
+ riskRanked: z5.array(z5.string()).describe("Flow names worst-risk first (run history + assertion quality). Test these first."),
3743
+ summary: z5.string()
3744
+ },
3745
+ handler: async (deps) => {
3746
+ const names = await deps.flows.list();
3747
+ const flows = [];
3748
+ for (const name of names) {
3749
+ const loaded = await deps.flows.load(name);
3750
+ if (loaded.ok)
3751
+ flows.push(loaded.value);
3752
+ }
3753
+ const contract = await readContract(deps.fs, deps.irisRoot);
3754
+ const project = await deps.project.read();
3755
+ const runs = project.ok ? project.file.runs : [];
3756
+ return buildDomainModel(flows, contract.ok ? contract.capabilities : null, runs);
3757
+ }
3758
+ }
3759
+ ];
3760
+
3761
+ // ../server/dist/tools/browser-tools.js
3762
+ import { z as z6 } from "zod";
3763
+ var sessionIdShape2 = {
3764
+ sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3765
+ };
3766
+ async function commandOrThrow2(deps, sessionId, name, args) {
3767
+ const session = deps.sessions.resolve(sessionId);
3768
+ const result = await session.command(name, args);
3769
+ if (!result.ok)
3770
+ throw new Error(result.error ?? `command '${name}' failed`);
3771
+ return result.result;
3061
3772
  }
3062
- function collectProposals(steps, minConfidence = HEAL_CONFIDENCE_MIN) {
3063
- const proposals = [];
3064
- for (const step of steps) {
3065
- if (step.drift === void 0)
3066
- continue;
3067
- const proposal = proposeRebindWith(step.drift, step.step, minConfidence);
3068
- if (proposal !== void 0)
3069
- proposals.push(proposal);
3773
+ var BROWSER_TOOLS = [
3774
+ {
3775
+ name: IrisTool.NAVIGATE,
3776
+ 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.",
3777
+ inputSchema: {
3778
+ url: z6.string().describe("The URL to navigate to."),
3779
+ ...sessionIdShape2
3780
+ },
3781
+ outputSchema: {
3782
+ ok: z6.boolean(),
3783
+ url: z6.string().optional(),
3784
+ reason: z6.string().optional()
3785
+ },
3786
+ handler: async (deps, args) => {
3787
+ const url = asString(args["url"]);
3788
+ if (url === void 0 || url.length === 0)
3789
+ return { ok: false, reason: "url required" };
3790
+ const result = await commandOrThrow2(deps, asString(args["sessionId"]), IrisCommand.NAVIGATE, { url });
3791
+ return {
3792
+ ok: result.ok === true,
3793
+ ...typeof result.url === "string" ? { url: result.url } : {},
3794
+ ...typeof result.reason === "string" ? { reason: result.reason } : {}
3795
+ };
3796
+ }
3797
+ },
3798
+ {
3799
+ name: IrisTool.REFRESH,
3800
+ 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.",
3801
+ inputSchema: {
3802
+ hard: z6.boolean().optional().describe("Set true to bypass the browser cache. Default: false (normal reload)."),
3803
+ ...sessionIdShape2
3804
+ },
3805
+ outputSchema: {
3806
+ ok: z6.boolean()
3807
+ },
3808
+ handler: async (deps, args) => {
3809
+ await commandOrThrow2(deps, asString(args["sessionId"]), IrisCommand.REFRESH, {
3810
+ hard: args["hard"] === true
3811
+ });
3812
+ return { ok: true };
3813
+ }
3070
3814
  }
3071
- return proposals;
3072
- }
3815
+ ];
3073
3816
 
3074
3817
  // ../server/dist/flows/flow-tools.js
3818
+ import { z as z7 } from "zod";
3075
3819
  function latestRecordedFlow(events) {
3076
3820
  for (let i = events.length - 1; i >= 0; i--) {
3077
3821
  const event = events[i];
@@ -3117,18 +3861,26 @@ async function recordReplayRun(deps, name, status, driftSteps, durationMs) {
3117
3861
  var FLOW_TOOLS = [
3118
3862
  {
3119
3863
  name: IrisTool.FLOW_SAVE,
3120
- 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 }.',
3864
+ 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).',
3121
3865
  inputSchema: {
3122
- flowName: z6.string().describe("Name for the flow file (saved to .iris/flows/<flowName>.json). Use again in iris_flow_load/iris_flow_replay.")
3866
+ flowName: z7.string().describe("Name for the flow file (saved to .iris/flows/<flowName>.json). Use again in iris_flow_load/iris_flow_replay.")
3123
3867
  },
3124
3868
  outputSchema: {
3125
- saved: z6.boolean(),
3126
- path: z6.string(),
3127
- stepCount: z6.number().optional(),
3128
- degraded: z6.number().optional()
3869
+ saved: z7.boolean(),
3870
+ path: z7.string(),
3871
+ stepCount: z7.number().optional(),
3872
+ degraded: z7.number().optional(),
3873
+ assertions: z7.object({
3874
+ grade: z7.string().describe("asserted | presence-only | assertion-free"),
3875
+ hasConsequenceAssertion: z7.boolean(),
3876
+ totalSteps: z7.number(),
3877
+ consequenceSteps: z7.number(),
3878
+ weakSteps: z7.number(),
3879
+ warning: z7.string().optional()
3880
+ }).optional()
3129
3881
  },
3130
3882
  handler: (deps, args) => {
3131
- const name = asString4(args["flowName"]) ?? "";
3883
+ const name = asString(args["flowName"]) ?? "";
3132
3884
  const program = deps.recordings.getCompiled(name);
3133
3885
  if (program === void 0) {
3134
3886
  return Promise.resolve({
@@ -3142,10 +3894,12 @@ var FLOW_TOOLS = [
3142
3894
  dynamic: deps.annotations.dynamic(name),
3143
3895
  ...success !== void 0 ? { success } : {}
3144
3896
  };
3145
- return deps.flows.save(program, annotations).then((res) => {
3146
- if (res.ok)
3147
- deps.annotations.clear(name);
3148
- return res.ok ? res.value : { error: flowErrorMessage(res.code), code: res.code };
3897
+ return deps.flows.save(program, annotations).then(async (res) => {
3898
+ if (!res.ok)
3899
+ return { error: flowErrorMessage(res.code), code: res.code };
3900
+ deps.annotations.clear(name);
3901
+ const loaded = await deps.flows.load(res.value.name);
3902
+ return loaded.ok ? { ...res.value, assertions: classifyFlowAssertions(loaded.value) } : res.value;
3149
3903
  });
3150
3904
  }
3151
3905
  },
@@ -3154,22 +3908,27 @@ var FLOW_TOOLS = [
3154
3908
  description: "List saved flow names under .iris/flows (a fresh agent learns the demonstrated journeys without a browser).",
3155
3909
  inputSchema: {},
3156
3910
  outputSchema: {
3157
- flows: z6.array(z6.object({ name: z6.string(), path: z6.string(), createdAt: z6.number().optional() }))
3911
+ flows: z7.array(z7.object({ name: z7.string(), path: z7.string(), createdAt: z7.number().optional() }))
3158
3912
  },
3159
- handler: (deps) => deps.flows.list().then((flows) => ({ flows }))
3913
+ // Return {name, path} objects to MATCH the declared outputSchema. Returning bare name strings
3914
+ // (the prior bug) made schema-validating MCP clients reject the result ("expected object,
3915
+ // received string") — caught driving the live demo.
3916
+ handler: (deps) => deps.flows.list().then((names) => ({
3917
+ flows: names.map((name) => ({ name, path: flowPath(deps.irisRoot, name) }))
3918
+ }))
3160
3919
  },
3161
3920
  {
3162
3921
  name: IrisTool.FLOW_LOAD,
3163
3922
  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 }.",
3164
3923
  inputSchema: {
3165
- flowName: z6.string().describe("Flow file name (without .json extension) from iris_flow_list.")
3924
+ flowName: z7.string().describe("Flow file name (without .json extension) from iris_flow_list.")
3166
3925
  },
3167
3926
  outputSchema: {
3168
- flowName: z6.string(),
3169
- steps: z6.array(z6.unknown()),
3170
- createdAt: z6.number().optional()
3927
+ flowName: z7.string(),
3928
+ steps: z7.array(z7.unknown()),
3929
+ createdAt: z7.number().optional()
3171
3930
  },
3172
- handler: (deps, args) => deps.flows.load(asString4(args["flowName"]) ?? "").then((res) => {
3931
+ handler: (deps, args) => deps.flows.load(asString(args["flowName"]) ?? "").then((res) => {
3173
3932
  if (!res.ok)
3174
3933
  return { error: flowErrorMessage(res.code), code: res.code };
3175
3934
  const { name, ...rest } = res.value;
@@ -3178,20 +3937,21 @@ var FLOW_TOOLS = [
3178
3937
  },
3179
3938
  {
3180
3939
  name: IrisTool.FLOW_REPLAY,
3181
- 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).`,
3940
+ 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).`,
3182
3941
  inputSchema: {
3183
- flowName: z6.string().describe("Flow file name (without .json extension) from iris_flow_list."),
3184
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3942
+ flowName: z7.string().describe("Flow file name (without .json extension) from iris_flow_list."),
3943
+ confirmDangerous: z7.boolean().optional().describe("Set true to allow destructive controls during this replay only."),
3944
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3185
3945
  },
3186
3946
  outputSchema: {
3187
- status: z6.string().describe("ok | drift | error"),
3188
- steps: z6.array(z6.unknown()),
3189
- proposals: z6.array(z6.unknown()).optional(),
3190
- error: z6.object({ code: z6.string(), message: z6.string() }).optional()
3947
+ status: z7.string().describe("ok | drift | error"),
3948
+ steps: z7.array(z7.unknown()),
3949
+ proposals: z7.array(z7.unknown()).optional(),
3950
+ error: z7.object({ code: z7.string(), message: z7.string() }).optional()
3191
3951
  },
3192
3952
  handler: async (deps, args) => {
3193
3953
  const startedAt = deps.now();
3194
- const name = asString4(args["flowName"]) ?? "";
3954
+ const name = asString(args["flowName"]) ?? "";
3195
3955
  const loaded = await deps.flows.load(name);
3196
3956
  if (!loaded.ok) {
3197
3957
  await recordReplayRun(deps, name, ReplayStatus.ERROR, 0, deps.now() - startedAt);
@@ -3202,12 +3962,35 @@ var FLOW_TOOLS = [
3202
3962
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3203
3963
  };
3204
3964
  }
3205
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3206
- const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS);
3965
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
3966
+ const replayFloor = session.elapsed();
3967
+ const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
3968
+ const stepsClean = steps.length > 0 && steps.every((s) => s.ok && s.drift === void 0);
3969
+ if (stepsClean && loaded.value.success !== void 0) {
3970
+ const verdict = await assertSuccess(session, loaded.value.success, dynamicTestids(loaded.value), waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, replayFloor);
3971
+ const row = {
3972
+ step: steps.length,
3973
+ tool: "success",
3974
+ anchor: successLabel(loaded.value.success),
3975
+ ok: verdict.pass,
3976
+ ...verdict.pass ? {} : { error: verdict.failureReason ?? "flow.success not satisfied" }
3977
+ };
3978
+ steps.push(row);
3979
+ }
3207
3980
  const driftSteps = steps.filter((s) => s.drift !== void 0).length;
3208
3981
  const allOk = steps.every((s) => s.ok);
3209
- const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.DRIFT;
3982
+ const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.ERROR;
3210
3983
  await recordReplayRun(deps, name, status, driftSteps, deps.now() - startedAt);
3984
+ const failed = steps.find((step) => !step.ok && step.drift === void 0);
3985
+ if (failed !== void 0) {
3986
+ const message = failed.error ?? "flow action failed";
3987
+ return {
3988
+ name,
3989
+ status,
3990
+ steps,
3991
+ error: { code: ReplayStatus.ERROR, message }
3992
+ };
3993
+ }
3211
3994
  return { name, status, steps };
3212
3995
  }
3213
3996
  },
@@ -3215,20 +3998,20 @@ var FLOW_TOOLS = [
3215
3998
  name: IrisTool.FLOW_SAVE_RECORDED,
3216
3999
  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).",
3217
4000
  inputSchema: {
3218
- flowName: z6.string().optional().describe("Override the flow name embedded in the recorded flow. Omit to use the recorder-assigned name."),
4001
+ flowName: z7.string().optional().describe("Override the flow name embedded in the recorded flow. Omit to use the recorder-assigned name."),
3219
4002
  ...{
3220
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4003
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3221
4004
  }
3222
4005
  },
3223
4006
  outputSchema: {
3224
- flowName: z6.string().optional(),
3225
- stepCount: z6.number().optional(),
3226
- degraded: z6.number().optional(),
3227
- error: z6.string().optional(),
3228
- code: z6.string().optional()
4007
+ flowName: z7.string().optional(),
4008
+ stepCount: z7.number().optional(),
4009
+ degraded: z7.number().optional(),
4010
+ error: z7.string().optional(),
4011
+ code: z7.string().optional()
3229
4012
  },
3230
4013
  handler: async (deps, args) => {
3231
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4014
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
3232
4015
  const recorded = latestRecordedFlow(session.eventsSince(0));
3233
4016
  if (recorded === void 0) {
3234
4017
  return {
@@ -3236,7 +4019,7 @@ var FLOW_TOOLS = [
3236
4019
  code: RecordedSaveError.NO_RECORDED_FLOW
3237
4020
  };
3238
4021
  }
3239
- const override = asString4(args["flowName"]);
4022
+ const override = asString(args["flowName"]);
3240
4023
  const flow = override !== void 0 ? { ...recorded.flow, name: override } : recorded.flow;
3241
4024
  const res = await deps.flows.saveFlow(flow);
3242
4025
  if (!res.ok)
@@ -3247,35 +4030,38 @@ var FLOW_TOOLS = [
3247
4030
  },
3248
4031
  {
3249
4032
  name: IrisTool.FLOW_HEAL,
3250
- 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 }.",
4033
+ 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 }.",
3251
4034
  inputSchema: {
3252
- flowName: z6.string().describe("Flow file name to heal (from iris_flow_list)."),
3253
- apply: z6.boolean().optional(),
3254
- sessionId: z6.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4035
+ flowName: z7.string().describe("Flow file name to heal (from iris_flow_list)."),
4036
+ apply: z7.boolean().optional(),
4037
+ confirmDangerous: z7.boolean().optional().describe("Set true to allow destructive controls during this heal replay only."),
4038
+ sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3255
4039
  },
3256
4040
  outputSchema: {
3257
- flowName: z6.string(),
3258
- status: z6.string(),
3259
- applied: z6.boolean(),
3260
- proposals: z6.array(z6.unknown()),
3261
- changed: z6.array(z6.unknown()),
3262
- message: z6.string(),
3263
- error: z6.object({ code: z6.string(), message: z6.string() }).optional()
4041
+ flowName: z7.string(),
4042
+ status: z7.string(),
4043
+ applied: z7.boolean(),
4044
+ proposals: z7.array(z7.unknown()),
4045
+ changed: z7.array(z7.unknown()),
4046
+ message: z7.string(),
4047
+ error: z7.object({ code: z7.string(), message: z7.string() }).optional()
3264
4048
  },
3265
4049
  handler: (deps, args) => healFlow(deps, args).then(({ name, ...rest }) => ({ flowName: name, ...rest }))
3266
4050
  }
3267
4051
  ];
3268
4052
  var HEAL_MESSAGES = {
3269
4053
  NOTHING: "nothing to heal \u2014 every anchor resolved on replay",
3270
- HEALED: "rewrote drifted testid anchors to their nearest surviving match",
4054
+ HEALED: "rewrote drifted testid anchors to their nearest surviving match and re-verified the flow's success consequence still fires",
3271
4055
  DRIFT_DRY: "confident rebind(s) proposed \u2014 re-run with apply:true to write them to disk",
3272
- 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`
4056
+ 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`,
4057
+ 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.",
4058
+ 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"
3273
4059
  };
3274
4060
  function toChange(proposal) {
3275
4061
  return { step: proposal.step, from: proposal.from, to: proposal.to };
3276
4062
  }
3277
4063
  async function healFlow(deps, args) {
3278
- const name = asString4(args["flowName"]) ?? "";
4064
+ const name = asString(args["flowName"]) ?? "";
3279
4065
  const apply = args["apply"] === true;
3280
4066
  const loaded = await deps.flows.load(name);
3281
4067
  if (!loaded.ok) {
@@ -3289,9 +4075,22 @@ async function healFlow(deps, args) {
3289
4075
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) }
3290
4076
  };
3291
4077
  }
3292
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3293
- const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS);
4078
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4079
+ const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
3294
4080
  const drifted = steps.some((s) => s.drift !== void 0);
4081
+ const failed = steps.find((s) => !s.ok && s.drift === void 0);
4082
+ if (failed !== void 0) {
4083
+ const message = failed.error ?? "flow replay failed before an anchor could be healed";
4084
+ return {
4085
+ name,
4086
+ status: HealStatus.ERROR,
4087
+ applied: false,
4088
+ proposals: [],
4089
+ changed: [],
4090
+ message,
4091
+ error: { code: ReplayStatus.ERROR, message }
4092
+ };
4093
+ }
3295
4094
  if (!drifted) {
3296
4095
  return {
3297
4096
  name,
@@ -3323,6 +4122,23 @@ async function healFlow(deps, args) {
3323
4122
  message: HEAL_MESSAGES.DRIFT_DRY
3324
4123
  };
3325
4124
  }
4125
+ const { flow: healed } = applyHealChanges(loaded.value, proposals.map(toChange));
4126
+ if (healed.success !== void 0) {
4127
+ const verifyFloor = session.elapsed();
4128
+ const verifySteps = await replayFlow(session, healed, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, args["confirmDangerous"] === true);
4129
+ const verifyClean = verifySteps.length > 0 && verifySteps.every((s) => s.ok && s.drift === void 0);
4130
+ 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" };
4131
+ if (!verdict.pass) {
4132
+ return {
4133
+ name,
4134
+ status: HealStatus.CONSEQUENCE_BROKEN,
4135
+ applied: false,
4136
+ proposals,
4137
+ changed: [],
4138
+ message: `${HEAL_MESSAGES.CONSEQUENCE_BROKEN} (${successLabel(healed.success)}: ${verdict.failureReason ?? "not satisfied"})`
4139
+ };
4140
+ }
4141
+ }
3326
4142
  const written = await deps.flows.heal(name, proposals.map(toChange));
3327
4143
  if (!written.ok) {
3328
4144
  return {
@@ -3341,14 +4157,14 @@ async function healFlow(deps, args) {
3341
4157
  applied: written.value.changed.length > 0,
3342
4158
  proposals,
3343
4159
  changed: written.value.changed,
3344
- message: HEAL_MESSAGES.HEALED
4160
+ message: loaded.value.success !== void 0 ? HEAL_MESSAGES.HEALED : HEAL_MESSAGES.HEALED_UNVERIFIED
3345
4161
  };
3346
4162
  }
3347
4163
 
3348
4164
  // ../server/dist/project/project-tools.js
3349
- import { z as z7 } from "zod";
4165
+ import { z as z8 } from "zod";
3350
4166
  var sessionIdShape3 = {
3351
- sessionId: z7.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4167
+ sessionId: z8.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3352
4168
  };
3353
4169
  var REGRESSION_STATUSES = /* @__PURE__ */ new Set([
3354
4170
  RunStatus.FAIL,
@@ -3389,12 +4205,12 @@ var PROJECT_TOOLS = [
3389
4205
  name: IrisTool.PROJECT,
3390
4206
  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.',
3391
4207
  inputSchema: {
3392
- name: z7.string().optional().describe("Filter runs by this name. Omit to return all runs."),
4208
+ name: z8.string().optional().describe("Filter runs by this name. Omit to return all runs."),
3393
4209
  ...sessionIdShape3
3394
4210
  },
3395
4211
  outputSchema: {
3396
- runs: z7.array(z7.unknown()),
3397
- diff: z7.unknown().optional()
4212
+ runs: z8.array(z8.unknown()),
4213
+ diff: z8.unknown().optional()
3398
4214
  },
3399
4215
  handler: async (deps, args) => {
3400
4216
  const read = await deps.project.read();
@@ -3404,7 +4220,7 @@ var PROJECT_TOOLS = [
3404
4220
  reason: read.reason
3405
4221
  };
3406
4222
  }
3407
- const name = asString4(args["name"]);
4223
+ const name = asString(args["name"]);
3408
4224
  if (name === void 0) {
3409
4225
  return { runs: read.file.runs, learned: read.file.learned };
3410
4226
  }
@@ -3422,22 +4238,22 @@ var PROJECT_TOOLS = [
3422
4238
  name: IrisTool.RUN_RECORD,
3423
4239
  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 }.",
3424
4240
  inputSchema: {
3425
- name: z7.string().describe("Run name for grouping in iris_project history."),
3426
- status: z7.nativeEnum(RunStatus).describe("Outcome: pass | fail | drift | error"),
3427
- kind: z7.nativeEnum(RunKind).optional(),
3428
- summary: z7.string().optional().describe("One-line human summary of what this run covered."),
4241
+ name: z8.string().describe("Run name for grouping in iris_project history."),
4242
+ status: z8.nativeEnum(RunStatus).describe("Outcome: pass | fail | drift | error"),
4243
+ kind: z8.nativeEnum(RunKind).optional(),
4244
+ summary: z8.string().optional().describe("One-line human summary of what this run covered."),
3429
4245
  ...sessionIdShape3
3430
4246
  },
3431
4247
  outputSchema: {
3432
- recorded: z7.boolean(),
3433
- runName: z7.string(),
3434
- status: z7.string()
4248
+ recorded: z8.boolean(),
4249
+ runName: z8.string(),
4250
+ status: z8.string()
3435
4251
  },
3436
4252
  handler: async (deps, args) => {
3437
- const name = asString4(args["name"]) ?? "";
4253
+ const name = asString(args["name"]) ?? "";
3438
4254
  const status = args["status"];
3439
4255
  const kindArg = args["kind"];
3440
- const summary = asString4(args["summary"]);
4256
+ const summary = asString(args["summary"]);
3441
4257
  await deps.project.recordRun({
3442
4258
  kind: typeof kindArg === "string" ? kindArg : RunKind.MANUAL,
3443
4259
  name,
@@ -3450,7 +4266,7 @@ var PROJECT_TOOLS = [
3450
4266
  ];
3451
4267
 
3452
4268
  // ../server/dist/visual/visual-tools.js
3453
- import { z as z8 } from "zod";
4269
+ import { z as z9 } from "zod";
3454
4270
 
3455
4271
  // ../server/dist/visual/visual-diff.js
3456
4272
  async function loadDeps() {
@@ -3601,13 +4417,13 @@ var VisualStore = class {
3601
4417
 
3602
4418
  // ../server/dist/visual/visual-tools.js
3603
4419
  var sessionIdShape4 = {
3604
- sessionId: z8.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4420
+ sessionId: z9.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3605
4421
  };
3606
- var rectShape = z8.object({
3607
- x: z8.number(),
3608
- y: z8.number(),
3609
- width: z8.number(),
3610
- height: z8.number()
4422
+ var rectShape = z9.object({
4423
+ x: z9.number(),
4424
+ y: z9.number(),
4425
+ width: z9.number(),
4426
+ height: z9.number()
3611
4427
  });
3612
4428
  function screenshotProvider(deps) {
3613
4429
  const p = deps.realInput;
@@ -3619,11 +4435,11 @@ var noProvider = {
3619
4435
  recommendation: VISUAL_NO_PROVIDER_RECOMMENDATION
3620
4436
  };
3621
4437
  function asBox(value) {
3622
- const b = asRecord3(asRecord3(value)["box"]);
3623
- const x = asNumber2(b["x"]);
3624
- const y = asNumber2(b["y"]);
3625
- const w = asNumber2(b["width"]);
3626
- const h = asNumber2(b["height"]);
4438
+ const b = asRecord(asRecord(value)["box"]);
4439
+ const x = asNumber(b["x"]);
4440
+ const y = asNumber(b["y"]);
4441
+ const w = asNumber(b["width"]);
4442
+ const h = asNumber(b["height"]);
3627
4443
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
3628
4444
  return void 0;
3629
4445
  if (w <= 0 || h <= 0)
@@ -3633,12 +4449,12 @@ function asBox(value) {
3633
4449
  async function buildOpts(deps, sessionId, args) {
3634
4450
  const clipArg = args["clip"];
3635
4451
  if (clipArg !== void 0) {
3636
- const c = asRecord3(clipArg);
4452
+ const c = asRecord(clipArg);
3637
4453
  const box = asBox({ box: c });
3638
4454
  if (box !== void 0)
3639
4455
  return { clip: box };
3640
4456
  }
3641
- const ref = asString4(args["ref"]);
4457
+ const ref = asString(args["ref"]);
3642
4458
  if (ref !== void 0) {
3643
4459
  const session = deps.sessions.resolve(sessionId);
3644
4460
  const res = await session.command(IrisCommand.INSPECT, { ref });
@@ -3664,31 +4480,31 @@ var VISUAL_TOOLS = [
3664
4480
  name: IrisTool.SCREENSHOT,
3665
4481
  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.",
3666
4482
  inputSchema: {
3667
- name: z8.string().describe("Baseline name \u2014 saved as .iris/visual/<name>.png. Use the same name in iris_visual_diff to compare."),
3668
- fullPage: z8.boolean().optional().describe("Capture the full scroll height. Default: viewport only."),
3669
- ref: z8.string().optional().describe("Element ref to screenshot (scopes to element bounding box). Omit for full page."),
4483
+ name: z9.string().describe("Baseline name \u2014 saved as .iris/visual/<name>.png. Use the same name in iris_visual_diff to compare."),
4484
+ fullPage: z9.boolean().optional().describe("Capture the full scroll height. Default: viewport only."),
4485
+ ref: z9.string().optional().describe("Element ref to screenshot (scopes to element bounding box). Omit for full page."),
3670
4486
  clip: rectShape.optional().describe("Explicit { x, y, width, height } clip rectangle in page coordinates."),
3671
4487
  ...sessionIdShape4
3672
4488
  },
3673
4489
  outputSchema: {
3674
- ok: z8.boolean(),
3675
- saved: z8.boolean().optional(),
3676
- name: z8.string().optional(),
3677
- path: z8.string().optional(),
3678
- bytes: z8.number().optional(),
3679
- reason: z8.string().optional(),
3680
- recommendation: z8.string().optional()
4490
+ ok: z9.boolean(),
4491
+ saved: z9.boolean().optional(),
4492
+ name: z9.string().optional(),
4493
+ path: z9.string().optional(),
4494
+ bytes: z9.number().optional(),
4495
+ reason: z9.string().optional(),
4496
+ recommendation: z9.string().optional()
3681
4497
  },
3682
4498
  handler: async (deps, args) => {
3683
4499
  const provider = screenshotProvider(deps);
3684
4500
  if (provider === void 0)
3685
4501
  return noProvider;
3686
- const sessionId = asString4(args["sessionId"]);
4502
+ const sessionId = asString(args["sessionId"]);
3687
4503
  const session = deps.sessions.resolve(sessionId);
3688
4504
  const png = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3689
4505
  if (png === void 0)
3690
4506
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3691
- const name = asString4(args["name"]) ?? "default";
4507
+ const name = asString(args["name"]) ?? "default";
3692
4508
  const store = new VisualStore(deps.fs, deps.irisRoot);
3693
4509
  const path = await store.saveBaseline(name, png);
3694
4510
  return { ok: true, saved: true, name, path, bytes: png.length };
@@ -3698,39 +4514,39 @@ var VISUAL_TOOLS = [
3698
4514
  name: IrisTool.VISUAL_DIFF,
3699
4515
  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).",
3700
4516
  inputSchema: {
3701
- baseline: z8.string().describe("Baseline screenshot name (from iris_screenshot). Used to compare with the current screenshot."),
3702
- fullPage: z8.boolean().optional(),
3703
- ref: z8.string().optional(),
4517
+ baseline: z9.string().describe("Baseline screenshot name (from iris_screenshot). Used to compare with the current screenshot."),
4518
+ fullPage: z9.boolean().optional(),
4519
+ ref: z9.string().optional(),
3704
4520
  clip: rectShape.optional(),
3705
- masks: z8.array(rectShape).optional(),
3706
- maxRatio: z8.number().optional(),
3707
- threshold: z8.number().optional().describe("Pixel difference threshold (0\u20131). Default: 0.01."),
4521
+ masks: z9.array(rectShape).optional(),
4522
+ maxRatio: z9.number().optional(),
4523
+ threshold: z9.number().optional().describe("Pixel difference threshold (0\u20131). Default: 0.01."),
3708
4524
  ...sessionIdShape4
3709
4525
  },
3710
4526
  outputSchema: {
3711
- ok: z8.boolean(),
3712
- match: z8.boolean().optional(),
3713
- diffPct: z8.number().optional(),
3714
- diffPath: z8.string().optional(),
3715
- reason: z8.string().optional()
4527
+ ok: z9.boolean(),
4528
+ match: z9.boolean().optional(),
4529
+ diffPct: z9.number().optional(),
4530
+ diffPath: z9.string().optional(),
4531
+ reason: z9.string().optional()
3716
4532
  },
3717
4533
  handler: async (deps, args) => {
3718
4534
  const provider = screenshotProvider(deps);
3719
4535
  if (provider === void 0)
3720
4536
  return noProvider;
3721
- const baseline = asString4(args["baseline"]) ?? "";
4537
+ const baseline = asString(args["baseline"]) ?? "";
3722
4538
  const store = new VisualStore(deps.fs, deps.irisRoot);
3723
4539
  const baselineBytes = await store.readBaseline(baseline);
3724
4540
  if (baselineBytes === void 0)
3725
4541
  return { ok: false, reason: VisualReason.BASELINE_MISSING };
3726
- const sessionId = asString4(args["sessionId"]);
4542
+ const sessionId = asString(args["sessionId"]);
3727
4543
  const session = deps.sessions.resolve(sessionId);
3728
4544
  const current = await provider.screenshot(session.url, await buildOpts(deps, sessionId, args));
3729
4545
  if (current === void 0)
3730
4546
  return { ok: false, reason: VisualReason.CAPTURE_FAILED };
3731
4547
  const masks = rectsFrom(args["masks"]);
3732
- const threshold = asNumber2(args["threshold"]);
3733
- const maxRatio = asNumber2(args["maxRatio"]);
4548
+ const threshold = asNumber(args["threshold"]);
4549
+ const maxRatio = asNumber(args["maxRatio"]);
3734
4550
  const result = await diffPng(baselineBytes, current, {
3735
4551
  ...threshold !== void 0 ? { threshold } : {},
3736
4552
  ...maxRatio !== void 0 ? { maxRatio } : {},
@@ -3747,7 +4563,7 @@ var VISUAL_TOOLS = [
3747
4563
  ];
3748
4564
 
3749
4565
  // ../server/dist/crawl/crawl-tools.js
3750
- import { z as z9 } from "zod";
4566
+ import { z as z10 } from "zod";
3751
4567
 
3752
4568
  // ../server/dist/crawl/crawl.js
3753
4569
  function isActivity(e) {
@@ -3760,7 +4576,7 @@ function failedRequests(events, floor) {
3760
4576
  return events.filter((e) => {
3761
4577
  if (e.type !== EventType.NET_REQUEST)
3762
4578
  return false;
3763
- const status = asNumber2(e.data["status"]);
4579
+ const status = asNumber(e.data["status"]);
3764
4580
  return status !== void 0 && status >= floor;
3765
4581
  });
3766
4582
  }
@@ -3790,7 +4606,7 @@ async function crawl(session, opts, sleep) {
3790
4606
  const act = await session.command(IrisCommand.ACT, {
3791
4607
  ref: item.ref,
3792
4608
  action: ActionType.CLICK,
3793
- args: {}
4609
+ args: opts.confirmDangerous === true ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {}
3794
4610
  });
3795
4611
  await sleep(settleMs);
3796
4612
  const events = session.eventsSince(since);
@@ -3801,14 +4617,14 @@ async function crawl(session, opts, sleep) {
3801
4617
  kind: CrawlAnomalyKind.CONSOLE_ERROR,
3802
4618
  ref: item.ref,
3803
4619
  desc: item.desc,
3804
- detail: asString4(e.data["message"]) ?? e.type
4620
+ detail: asString(e.data["message"]) ?? e.type
3805
4621
  });
3806
4622
  }
3807
4623
  for (const e of failedRequests(events, CRAWL_DEFAULTS.FAILED_STATUS)) {
3808
4624
  counts.failedRequests += 1;
3809
- const method = asString4(e.data["method"]) ?? "";
3810
- const url = asString4(e.data["url"]) ?? "";
3811
- const status = asNumber2(e.data["status"]);
4625
+ const method = asString(e.data["method"]) ?? "";
4626
+ const url = asString(e.data["url"]) ?? "";
4627
+ const status = asNumber(e.data["status"]);
3812
4628
  anomalies.push({
3813
4629
  kind: CrawlAnomalyKind.FAILED_REQUEST,
3814
4630
  ref: item.ref,
@@ -3816,7 +4632,7 @@ async function crawl(session, opts, sleep) {
3816
4632
  detail: `${method} ${url} \u2192 ${status ?? ""}`.trim()
3817
4633
  });
3818
4634
  }
3819
- const dispatched = asRecord3(act.result)["dispatched"] !== false && act.ok;
4635
+ const dispatched = asRecord(act.result)["dispatched"] !== false && act.ok;
3820
4636
  if (dispatched && errs.length === 0 && !events.some(isActivity)) {
3821
4637
  counts.deadControls += 1;
3822
4638
  anomalies.push({
@@ -3844,32 +4660,34 @@ var CRAWL_TOOLS = [
3844
4660
  name: IrisTool.CRAWL,
3845
4661
  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 }.",
3846
4662
  inputSchema: {
3847
- maxSteps: z9.number().optional().describe("Maximum number of controls to click. Default: 25."),
3848
- settleMs: z9.number().optional().describe("Milliseconds to wait after each click for the app to react. Default: 500."),
3849
- scope: z9.string().optional().describe("CSS selector or element ref to restrict crawling to a subtree."),
3850
- sessionId: z9.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4663
+ maxSteps: z10.number().optional().describe("Maximum number of controls to click. Default: 25."),
4664
+ settleMs: z10.number().optional().describe("Milliseconds to wait after each click for the app to react. Default: 500."),
4665
+ scope: z10.string().optional().describe("CSS selector or element ref to restrict crawling to a subtree."),
4666
+ confirmDangerous: z10.boolean().optional().describe("Set true to allow controls classified as destructive. Default false; those controls are blocked by the browser."),
4667
+ sessionId: z10.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3851
4668
  },
3852
4669
  outputSchema: {
3853
- interactiveFound: z9.number(),
3854
- stepsRun: z9.number(),
3855
- anomalies: z9.array(z9.object({
3856
- kind: z9.string(),
3857
- ref: z9.string(),
3858
- desc: z9.string(),
3859
- detail: z9.string().optional()
4670
+ interactiveFound: z10.number(),
4671
+ stepsRun: z10.number(),
4672
+ anomalies: z10.array(z10.object({
4673
+ kind: z10.string(),
4674
+ ref: z10.string(),
4675
+ desc: z10.string(),
4676
+ detail: z10.string().optional()
3860
4677
  })),
3861
- counts: z9.record(z9.number()),
3862
- truncated: z9.boolean()
4678
+ counts: z10.record(z10.number()),
4679
+ truncated: z10.boolean()
3863
4680
  },
3864
4681
  handler: (deps, args) => {
3865
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3866
- const maxSteps = asNumber2(args["maxSteps"]);
3867
- const settleMs = asNumber2(args["settleMs"]);
3868
- const scope = asString4(args["scope"]);
4682
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4683
+ const maxSteps = asNumber(args["maxSteps"]);
4684
+ const settleMs = asNumber(args["settleMs"]);
4685
+ const scope = asString(args["scope"]);
3869
4686
  const opts = {
3870
4687
  ...maxSteps !== void 0 ? { maxSteps } : {},
3871
4688
  ...settleMs !== void 0 ? { settleMs } : {},
3872
- ...scope !== void 0 ? { scope } : {}
4689
+ ...scope !== void 0 ? { scope } : {},
4690
+ ...args["confirmDangerous"] === true ? { confirmDangerous: true } : {}
3873
4691
  };
3874
4692
  return crawl(session, opts, nodeSleep2);
3875
4693
  }
@@ -3877,7 +4695,7 @@ var CRAWL_TOOLS = [
3877
4695
  ];
3878
4696
 
3879
4697
  // ../server/dist/input/scroll-tools.js
3880
- import { z as z10 } from "zod";
4698
+ import { z as z11 } from "zod";
3881
4699
 
3882
4700
  // ../server/dist/input/scroll-find.js
3883
4701
  async function queryFirst(session, q) {
@@ -3886,9 +4704,9 @@ async function queryFirst(session, q) {
3886
4704
  value: q.value,
3887
4705
  ...q.name !== void 0 ? { name: q.name } : {}
3888
4706
  });
3889
- const elements = asRecord3(res.result)["elements"];
4707
+ const elements = asRecord(res.result)["elements"];
3890
4708
  if (Array.isArray(elements) && elements.length > 0)
3891
- return asRecord3(elements[0]);
4709
+ return asRecord(elements[0]);
3892
4710
  return void 0;
3893
4711
  }
3894
4712
  async function scrollToFind(session, q, opts = {}) {
@@ -3907,7 +4725,7 @@ async function scrollToFind(session, q, opts = {}) {
3907
4725
  const hit = await queryFirst(session, q);
3908
4726
  if (hit !== void 0)
3909
4727
  return { found: true, element: hit, scrolls, exhausted: false };
3910
- const data = asRecord3(sr.result);
4728
+ const data = asRecord(sr.result);
3911
4729
  if (data["atEnd"] === true || data["scrolled"] === false) {
3912
4730
  return { found: false, scrolls, exhausted: true };
3913
4731
  }
@@ -3915,7 +4733,7 @@ async function scrollToFind(session, q, opts = {}) {
3915
4733
  for (let i = 0; i < max; i += 1) {
3916
4734
  const sr = await session.command(IrisCommand.SCROLL, q.container !== void 0 ? { ref: q.container } : {});
3917
4735
  scrolls += 1;
3918
- const data = asRecord3(sr.result);
4736
+ const data = asRecord(sr.result);
3919
4737
  const hit = await queryFirst(session, q);
3920
4738
  if (hit !== void 0)
3921
4739
  return { found: true, element: hit, scrolls, exhausted: false };
@@ -3932,58 +4750,58 @@ var SCROLL_TOOLS = [
3932
4750
  name: IrisTool.SCROLL_TO,
3933
4751
  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 }.",
3934
4752
  inputSchema: {
3935
- by: z10.string().describe("Query strategy for finding the target: role | text | testid | label | placeholder | alt"),
3936
- value: z10.string().describe("Query value for the selected strategy (the element to scroll into view)."),
3937
- name: z10.string().optional().describe("Optional accessible name filter when using by=role."),
3938
- container: z10.string().optional().describe("Element ref for the scrollable container. Omit to scroll the document."),
3939
- maxScrolls: z10.number().optional().describe("Maximum number of scroll steps before giving up. Default: 20."),
3940
- 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."),
3941
- totalCount: z10.number().optional().describe("Total item count in the virtualized list. Required for bisection with targetIndex."),
3942
- sessionId: z10.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4753
+ by: z11.string().describe("Query strategy for finding the target: role | text | testid | label | placeholder | alt"),
4754
+ value: z11.string().describe("Query value for the selected strategy (the element to scroll into view)."),
4755
+ name: z11.string().optional().describe("Optional accessible name filter when using by=role."),
4756
+ container: z11.string().optional().describe("Element ref for the scrollable container. Omit to scroll the document."),
4757
+ maxScrolls: z11.number().optional().describe("Maximum number of scroll steps before giving up. Default: 20."),
4758
+ 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."),
4759
+ totalCount: z11.number().optional().describe("Total item count in the virtualized list. Required for bisection with targetIndex."),
4760
+ sessionId: z11.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3943
4761
  },
3944
4762
  outputSchema: {
3945
- found: z10.boolean(),
3946
- element: z10.object({ ref: z10.string(), role: z10.string(), name: z10.string() }).optional(),
3947
- scrolls: z10.number(),
3948
- exhausted: z10.boolean()
4763
+ found: z11.boolean(),
4764
+ element: z11.object({ ref: z11.string(), role: z11.string(), name: z11.string() }).optional(),
4765
+ scrolls: z11.number(),
4766
+ exhausted: z11.boolean()
3949
4767
  },
3950
4768
  handler: (deps, args) => {
3951
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3952
- const name = asString4(args["name"]);
3953
- const container = asString4(args["container"]);
3954
- const targetIndex = asNumber2(args["targetIndex"]);
3955
- const totalCount = asNumber2(args["totalCount"]);
4769
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4770
+ const name = asString(args["name"]);
4771
+ const container = asString(args["container"]);
4772
+ const targetIndex = asNumber(args["targetIndex"]);
4773
+ const totalCount = asNumber(args["totalCount"]);
3956
4774
  const q = {
3957
- by: asString4(args["by"]) ?? "",
3958
- value: asString4(args["value"]) ?? "",
4775
+ by: asString(args["by"]) ?? "",
4776
+ value: asString(args["value"]) ?? "",
3959
4777
  ...name !== void 0 ? { name } : {},
3960
4778
  ...container !== void 0 ? { container } : {},
3961
4779
  ...targetIndex !== void 0 ? { targetIndex } : {},
3962
4780
  ...totalCount !== void 0 ? { totalCount } : {}
3963
4781
  };
3964
- const maxScrolls = asNumber2(args["maxScrolls"]);
4782
+ const maxScrolls = asNumber(args["maxScrolls"]);
3965
4783
  return scrollToFind(session, q, maxScrolls !== void 0 ? { maxScrolls } : {});
3966
4784
  }
3967
4785
  }
3968
4786
  ];
3969
4787
 
3970
4788
  // ../server/dist/session/session-tools.js
3971
- import { z as z11 } from "zod";
4789
+ import { z as z12 } from "zod";
3972
4790
  var SESSION_TOOLS = [
3973
4791
  {
3974
4792
  name: IrisTool.SESSION,
3975
4793
  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 }.",
3976
4794
  inputSchema: {
3977
- idleEndMs: z11.number().optional().describe("Idle window in milliseconds after which the presenter session auto-ends. Default: 300000 (5 min). Raise for slow apps."),
3978
- sessionId: z11.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4795
+ idleEndMs: z12.number().optional().describe("Idle window in milliseconds after which the presenter session auto-ends. Default: 300000 (5 min). Raise for slow apps."),
4796
+ sessionId: z12.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
3979
4797
  },
3980
4798
  outputSchema: {
3981
- applied: z11.boolean(),
3982
- idleEndMs: z11.number().optional()
4799
+ applied: z12.boolean(),
4800
+ idleEndMs: z12.number().optional()
3983
4801
  },
3984
4802
  handler: async (deps, args) => {
3985
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
3986
- const idleEndMs = asNumber2(args["idleEndMs"]);
4803
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4804
+ const idleEndMs = asNumber(args["idleEndMs"]);
3987
4805
  if (idleEndMs !== void 0)
3988
4806
  session.setIdleEndMs(idleEndMs);
3989
4807
  const res = await session.command(IrisCommand.SESSION_CONFIG, idleEndMs !== void 0 ? { idleEndMs } : {});
@@ -3995,7 +4813,7 @@ var SESSION_TOOLS = [
3995
4813
  ];
3996
4814
 
3997
4815
  // ../server/dist/flows/annotate-tools.js
3998
- import { z as z12 } from "zod";
4816
+ import { z as z13 } from "zod";
3999
4817
 
4000
4818
  // ../server/dist/flows/annotate.js
4001
4819
  function compileAnnotation(a, stepCount) {
@@ -4072,23 +4890,23 @@ var ANNOTATE_TOOLS = [
4072
4890
  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.",
4073
4891
  inputSchema: {
4074
4892
  // `flow` selects the recording; `name`/`signal`/`testid`/`dataMatches` are the annotation fields.
4075
- flow: z12.string().optional().describe("Named recording to annotate. Defaults to 'default'."),
4076
- kind: z12.string().describe("Annotation kind: assert-signal | assert-visible | mark-dynamic | success-state."),
4077
- name: z12.string().optional().describe("Signal name for assert-signal annotations."),
4078
- testid: z12.string().optional().describe("data-testid value for assert-visible / mark-dynamic / success-state annotations."),
4079
- signal: z12.string().optional().describe("Signal name for success-state annotations."),
4080
- dataMatches: z12.record(z12.unknown()).optional().describe("Key/value pairs the signal payload must match (assert-signal only)."),
4081
- sessionId: z12.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open."),
4082
- 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.")
4893
+ flow: z13.string().optional().describe("Named recording to annotate. Defaults to 'default'."),
4894
+ kind: z13.string().describe("Annotation kind: assert-signal | assert-visible | mark-dynamic | success-state."),
4895
+ name: z13.string().optional().describe("Signal name for assert-signal annotations."),
4896
+ testid: z13.string().optional().describe("data-testid value for assert-visible / mark-dynamic / success-state annotations."),
4897
+ signal: z13.string().optional().describe("Signal name for success-state annotations."),
4898
+ dataMatches: z13.record(z13.unknown()).optional().describe("Key/value pairs the signal payload must match (assert-signal only)."),
4899
+ sessionId: z13.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open."),
4900
+ 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.")
4083
4901
  },
4084
4902
  outputSchema: {
4085
- ok: z12.boolean(),
4086
- target: z12.string().optional(),
4087
- compiled: z12.string().optional(),
4088
- code: z12.string().optional()
4903
+ ok: z13.boolean(),
4904
+ target: z13.string().optional(),
4905
+ compiled: z13.string().optional(),
4906
+ code: z13.string().optional()
4089
4907
  },
4090
4908
  handler: (deps, args) => {
4091
- const name = asString4(args["flow"]) ?? DEFAULT_RECORDING;
4909
+ const name = asString(args["flow"]) ?? DEFAULT_RECORDING;
4092
4910
  const parsed = AnnotationSchema.safeParse(args);
4093
4911
  if (!parsed.success) {
4094
4912
  return Promise.resolve({ ok: false, code: AnnotationErrorCode.UNKNOWN_KIND });
@@ -4117,19 +4935,19 @@ var ANNOTATE_TOOLS = [
4117
4935
  ];
4118
4936
 
4119
4937
  // ../server/dist/session/live-control-tools.js
4120
- import { z as z13 } from "zod";
4938
+ import { z as z14 } from "zod";
4121
4939
  var sessionIdShape5 = {
4122
- sessionId: z13.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4940
+ sessionId: z14.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open.")
4123
4941
  };
4124
4942
  var LIVE_CONTROL_TOOLS = [
4125
4943
  {
4126
4944
  name: IrisTool.END_SESSION,
4127
4945
  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.',
4128
- inputSchema: { summary: z13.string().optional(), ...sessionIdShape5 },
4129
- outputSchema: { ended: z13.boolean(), sessionId: z13.string() },
4946
+ inputSchema: { summary: z14.string().optional(), ...sessionIdShape5 },
4947
+ outputSchema: { ended: z14.boolean(), sessionId: z14.string() },
4130
4948
  handler: (deps, args) => {
4131
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4132
- session.setState(SessionState.ENDED, asString4(args["summary"]));
4949
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4950
+ session.setState(SessionState.ENDED, asString(args["summary"]));
4133
4951
  return Promise.resolve({ ended: true, sessionId: session.id });
4134
4952
  }
4135
4953
  },
@@ -4137,9 +4955,9 @@ var LIVE_CONTROL_TOOLS = [
4137
4955
  name: IrisTool.RESUME,
4138
4956
  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.',
4139
4957
  inputSchema: { ...sessionIdShape5 },
4140
- outputSchema: { ok: z13.boolean() },
4958
+ outputSchema: { ok: z14.boolean() },
4141
4959
  handler: (deps, args) => {
4142
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4960
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4143
4961
  session.setState(SessionState.ACTIVE);
4144
4962
  return Promise.resolve({ ok: true });
4145
4963
  }
@@ -4148,9 +4966,9 @@ var LIVE_CONTROL_TOOLS = [
4148
4966
  name: IrisTool.MESSAGES,
4149
4967
  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.",
4150
4968
  inputSchema: { ...sessionIdShape5 },
4151
- outputSchema: { messages: z13.array(z13.unknown()) },
4969
+ outputSchema: { messages: z14.array(z14.unknown()) },
4152
4970
  handler: (deps, args) => {
4153
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4971
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4154
4972
  return Promise.resolve({ messages: session.drainInbox() });
4155
4973
  }
4156
4974
  }
@@ -4176,7 +4994,7 @@ function withControl(session, result) {
4176
4994
  }
4177
4995
 
4178
4996
  // ../server/dist/update/update-tools.js
4179
- import { z as z14 } from "zod";
4997
+ import { z as z15 } from "zod";
4180
4998
 
4181
4999
  // ../server/dist/update/update-checker.js
4182
5000
  import * as fs from "fs";
@@ -4370,14 +5188,14 @@ var UPDATE_TOOLS = [
4370
5188
  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.",
4371
5189
  inputSchema: {},
4372
5190
  outputSchema: {
4373
- currentVersion: z14.string().describe("The Iris server version currently running."),
4374
- latestVersion: z14.string().optional().describe("Latest published version on npm."),
4375
- updateAvailable: z14.boolean().describe("True when a newer version is available to install."),
4376
- executionKind: z14.string().describe('How iris was launched: "npx" (no install needed \u2014 restart applies update), "global" (npm install -g), or "local" (project node_modules).'),
4377
- changelog: z14.string().optional().describe("Release notes for the latest version."),
4378
- breakingChanges: z14.array(z14.string()).optional().describe("Breaking changes in the latest version that may affect your scripts."),
4379
- rollbackAvailable: z14.boolean().describe("True when a previous version is stored and can be restored."),
4380
- previousVersion: z14.string().optional().describe("The version that would be restored on rollback.")
5191
+ currentVersion: z15.string().describe("The Iris server version currently running."),
5192
+ latestVersion: z15.string().optional().describe("Latest published version on npm."),
5193
+ updateAvailable: z15.boolean().describe("True when a newer version is available to install."),
5194
+ 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).'),
5195
+ changelog: z15.string().optional().describe("Release notes for the latest version."),
5196
+ breakingChanges: z15.array(z15.string()).optional().describe("Breaking changes in the latest version that may affect your scripts."),
5197
+ rollbackAvailable: z15.boolean().describe("True when a previous version is stored and can be restored."),
5198
+ previousVersion: z15.string().optional().describe("The version that would be restored on rollback.")
4381
5199
  },
4382
5200
  handler: async (_deps) => {
4383
5201
  const manifest = await checkForUpdate(SERVER_VERSION);
@@ -4397,11 +5215,11 @@ var UPDATE_TOOLS = [
4397
5215
  name: IrisTool.APPLY_UPDATE,
4398
5216
  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.',
4399
5217
  inputSchema: {
4400
- confirm: z14.boolean().describe("Set to true to confirm the update should be applied. Required to prevent accidental upgrades.")
5218
+ confirm: z15.boolean().describe("Set to true to confirm the update should be applied. Required to prevent accidental upgrades.")
4401
5219
  },
4402
5220
  outputSchema: {
4403
- ok: z14.boolean(),
4404
- message: z14.string().optional()
5221
+ ok: z15.boolean(),
5222
+ message: z15.string().optional()
4405
5223
  },
4406
5224
  handler: async (_deps, args) => {
4407
5225
  if (args["confirm"] !== true) {
@@ -4419,11 +5237,11 @@ var UPDATE_TOOLS = [
4419
5237
  name: IrisTool.ROLLBACK,
4420
5238
  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.",
4421
5239
  inputSchema: {
4422
- confirm: z14.boolean().describe("Set to true to confirm the rollback. Required to prevent accidental downgrades.")
5240
+ confirm: z15.boolean().describe("Set to true to confirm the rollback. Required to prevent accidental downgrades.")
4423
5241
  },
4424
5242
  outputSchema: {
4425
- ok: z14.boolean(),
4426
- message: z14.string().optional()
5243
+ ok: z15.boolean(),
5244
+ message: z15.string().optional()
4427
5245
  },
4428
5246
  handler: async (_deps, args) => {
4429
5247
  if (args["confirm"] !== true) {
@@ -4445,7 +5263,7 @@ async function snapshotTree(deps, sessionId) {
4445
5263
  return { lines: normalizeLines(snap.tree ?? ""), route: snap.status?.route ?? "" };
4446
5264
  }
4447
5265
  var sessionIdShape6 = {
4448
- sessionId: z15.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open \u2014 Iris resolves it automatically.")
5266
+ sessionId: z16.string().optional().describe("Active session ID from iris_sessions. Omit when only one browser session is open \u2014 Iris resolves it automatically.")
4449
5267
  };
4450
5268
  async function commandOrThrow3(deps, sessionId, name, args) {
4451
5269
  const session = deps.sessions.resolve(sessionId);
@@ -4455,11 +5273,11 @@ async function commandOrThrow3(deps, sessionId, name, args) {
4455
5273
  return result.result;
4456
5274
  }
4457
5275
  function asBox2(value) {
4458
- const b = asRecord3(asRecord3(value)["box"]);
4459
- const x = asNumber2(b["x"]);
4460
- const y = asNumber2(b["y"]);
4461
- const w = asNumber2(b["width"]);
4462
- const h = asNumber2(b["height"]);
5276
+ const b = asRecord(asRecord(value)["box"]);
5277
+ const x = asNumber(b["x"]);
5278
+ const y = asNumber(b["y"]);
5279
+ const w = asNumber(b["width"]);
5280
+ const h = asNumber(b["height"]);
4463
5281
  if (x === void 0 || y === void 0 || w === void 0 || h === void 0)
4464
5282
  return void 0;
4465
5283
  if (w <= 0 || h <= 0)
@@ -4475,29 +5293,51 @@ async function tryRealInput(deps, session, ref, action, args) {
4475
5293
  return synthetic();
4476
5294
  if (!isPointerAction(action))
4477
5295
  return synthetic(InputModeReason.NOT_POINTER);
4478
- const inner = asRecord3(args["args"]);
5296
+ const inner = asRecord(args["args"]);
4479
5297
  if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && inner["native"] !== true) {
4480
5298
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
4481
5299
  }
4482
5300
  if (!await provider.isAvailableFor(session.url))
4483
5301
  return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
4484
- const box = asBox2(await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref }));
5302
+ const inspected = await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref });
5303
+ const confirmed = inner[DANGEROUS_ACTION_CONFIRM_ARG] === true;
5304
+ const dangerousDescriptorText = (value2) => {
5305
+ const descriptor = asRecord(value2);
5306
+ return [
5307
+ asString(descriptor["name"]) ?? "",
5308
+ asString(descriptor["text"]) ?? "",
5309
+ asString(descriptor["value"]) ?? "",
5310
+ asString(descriptor["href"]) ?? "",
5311
+ asString(descriptor["formAction"]) ?? "",
5312
+ asString(descriptor["formText"]) ?? ""
5313
+ ].join(" ");
5314
+ };
5315
+ if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !confirmed && isDangerousActionText(dangerousDescriptorText(inspected))) {
5316
+ throw new Error(`potentially destructive native action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
5317
+ }
5318
+ const box = asBox2(inspected);
4485
5319
  if (box === void 0)
4486
5320
  return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
4487
5321
  let toBox;
4488
5322
  if (action === ActionType.DRAG) {
4489
- const toRef = asString4(inner["toRef"]);
5323
+ const toRef = asString(inner["toRef"]);
4490
5324
  if (toRef === void 0)
4491
5325
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4492
- toBox = asBox2(await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, { ref: toRef }));
5326
+ const targetInspected = await commandOrThrow3(deps, session.id, IrisCommand.INSPECT, {
5327
+ ref: toRef
5328
+ });
5329
+ if (!confirmed && isDangerousActionText(`${dangerousDescriptorText(inspected)} ${dangerousDescriptorText(targetInspected)}`)) {
5330
+ throw new Error(`potentially destructive native action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
5331
+ }
5332
+ toBox = asBox2(targetInspected);
4493
5333
  if (toBox === void 0)
4494
5334
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
4495
5335
  }
4496
5336
  const performArgs = {};
4497
- const value = asString4(inner["value"]);
5337
+ const value = asString(inner["value"]);
4498
5338
  if (value !== void 0)
4499
5339
  performArgs.value = value;
4500
- const text = asString4(inner["text"]);
5340
+ const text = asString(inner["text"]);
4501
5341
  if (text !== void 0)
4502
5342
  performArgs.text = text;
4503
5343
  if (toBox !== void 0)
@@ -4516,23 +5356,24 @@ async function tryRealInput(deps, session, ref, action, args) {
4516
5356
  };
4517
5357
  }
4518
5358
  }
5359
+ var SNAPSHOT_CACHE = new SnapshotCache();
4519
5360
  var TOOLS = [
4520
5361
  {
4521
5362
  name: IrisTool.SESSIONS,
4522
5363
  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.",
4523
5364
  inputSchema: {},
4524
5365
  outputSchema: {
4525
- sessions: z15.array(z15.object({
4526
- sessionId: z15.string(),
4527
- url: z15.string(),
4528
- title: z15.string().optional(),
4529
- lastSeenMs: z15.number(),
4530
- throttled: z15.boolean(),
4531
- focused: z15.boolean(),
4532
- hidden: z15.boolean(),
4533
- realInputAvailable: z15.boolean().optional(),
4534
- stale: z15.boolean().optional(),
4535
- recommendation: z15.string().optional()
5366
+ sessions: z16.array(z16.object({
5367
+ sessionId: z16.string(),
5368
+ url: z16.string(),
5369
+ title: z16.string().optional(),
5370
+ lastSeenMs: z16.number(),
5371
+ throttled: z16.boolean(),
5372
+ focused: z16.boolean(),
5373
+ hidden: z16.boolean(),
5374
+ realInputAvailable: z16.boolean().optional(),
5375
+ stale: z16.boolean().optional(),
5376
+ recommendation: z16.string().optional()
4536
5377
  })).describe("Connected browser sessions with health state.")
4537
5378
  },
4538
5379
  handler: async (deps) => {
@@ -4546,71 +5387,95 @@ var TOOLS = [
4546
5387
  },
4547
5388
  {
4548
5389
  name: IrisTool.SNAPSHOT,
4549
- description: "Semantic accessibility snapshot of the page or a subtree. mode: full|interactive|status. Use to see what is on screen right now.",
5390
+ 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.",
4550
5391
  inputSchema: {
4551
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the snapshot to a subtree. Omit to snapshot the whole page."),
4552
- mode: z15.nativeEnum(SnapshotMode).optional().describe("full = all elements; interactive = only clickable/focusable elements; status = only route + title. Default: full."),
5392
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the snapshot to a subtree. Omit to snapshot the whole page."),
5393
+ mode: z16.nativeEnum(SnapshotMode).optional().describe("full = all elements; interactive = only clickable/focusable elements; status = only route + title. Default: full."),
5394
+ 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."),
4553
5395
  ...sessionIdShape6
4554
5396
  },
4555
5397
  outputSchema: {
4556
- tree: z15.string().optional().describe("Indented ARIA tree of every element on the page (or the scoped subtree)."),
4557
- status: z15.object({ route: z15.string(), title: z15.string().optional() }).optional()
5398
+ tree: z16.string().optional().describe("Indented ARIA tree of every element on the page (or the scoped subtree)."),
5399
+ status: z16.object({ route: z16.string(), title: z16.string().optional() }).optional(),
5400
+ mode: z16.string().optional().describe("delta | unchanged when diff:true returned a change set."),
5401
+ delta: z16.object({
5402
+ added: z16.array(z16.string()),
5403
+ removed: z16.array(z16.string()),
5404
+ addedCount: z16.number(),
5405
+ removedCount: z16.number()
5406
+ }).optional().describe("Only present on a diff:true call that found changes."),
5407
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional().describe("Estimated size of this result \u2014 re-scope if large.")
4558
5408
  },
4559
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.SNAPSHOT, {
4560
- scope: args["scope"],
4561
- mode: args["mode"] ?? SnapshotMode.FULL
4562
- })
5409
+ handler: (deps, args) => {
5410
+ const sessionId = asString(args["sessionId"]);
5411
+ const mode = asString(args["mode"]) ?? SnapshotMode.FULL;
5412
+ return commandOrThrow3(deps, sessionId, IrisCommand.SNAPSHOT, {
5413
+ scope: args["scope"],
5414
+ mode
5415
+ }).then((raw) => withSizeCost(applySnapshotDelta(raw, {
5416
+ sessionId: sessionId ?? "default",
5417
+ scope: asString(args["scope"]) ?? "",
5418
+ mode,
5419
+ diff: args["diff"] === true
5420
+ }, SNAPSHOT_CACHE)));
5421
+ }
4563
5422
  },
4564
5423
  {
4565
5424
  name: IrisTool.QUERY,
4566
- 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.",
5425
+ 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.",
4567
5426
  inputSchema: {
4568
- by: z15.string().describe("Query strategy: role | text | label | placeholder | testid | alt"),
4569
- value: z15.string().describe("Query value for the selected strategy (e.g. by=role value=button, or by=testid value=submit-btn)."),
4570
- name: z15.string().optional().describe("Accessible name filter \u2014 narrows results when `by` is role and the page has many elements of that role."),
4571
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the search to a subtree."),
5427
+ by: z16.string().describe("Query strategy: role | text | label | placeholder | testid | alt"),
5428
+ value: z16.string().describe("Query value for the selected strategy (e.g. by=role value=button, or by=testid value=submit-btn)."),
5429
+ name: z16.string().optional().describe("Accessible name filter \u2014 narrows results when `by` is role and the page has many elements of that role."),
5430
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the search to a subtree."),
5431
+ 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."),
5432
+ 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.'),
4572
5433
  ...sessionIdShape6
4573
5434
  },
4574
5435
  outputSchema: {
4575
- elements: z15.array(z15.object({
4576
- ref: z15.string(),
4577
- role: z15.string(),
4578
- name: z15.string(),
4579
- value: z15.string().optional(),
4580
- states: z15.array(z15.string()),
4581
- visible: z15.boolean()
4582
- })),
4583
- hint: z15.object({
4584
- route: z15.string(),
4585
- presentTestids: z15.array(z15.string()),
4586
- knownEmptyState: z15.boolean()
4587
- }).optional().describe("Present only on zero matches \u2014 tells you what IS on the page so you can diagnose the miss.")
5436
+ elements: z16.array(z16.object({
5437
+ ref: z16.string(),
5438
+ role: z16.string(),
5439
+ name: z16.string(),
5440
+ value: z16.string().optional(),
5441
+ states: z16.array(z16.string()),
5442
+ visible: z16.boolean()
5443
+ })).optional(),
5444
+ count: z16.number().optional().describe("Match count \u2014 present when count_only is set."),
5445
+ total: z16.number().optional().describe("Total matches before `limit` truncation \u2014 present only when truncated."),
5446
+ truncated: z16.boolean().optional().describe("True when `limit` dropped some matches."),
5447
+ hint: z16.object({
5448
+ route: z16.string(),
5449
+ presentTestids: z16.array(z16.string()),
5450
+ knownEmptyState: z16.boolean()
5451
+ }).optional().describe("Present only on zero matches \u2014 tells you what IS on the page so you can diagnose the miss."),
5452
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional().describe("Estimated size of this result \u2014 narrow with `name`/`scope`/`limit` if large.")
4588
5453
  },
4589
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.QUERY, {
5454
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.QUERY, {
4590
5455
  by: args["by"],
4591
5456
  value: args["value"],
4592
5457
  name: args["name"],
4593
5458
  scope: args["scope"]
4594
- })
5459
+ }).then((result) => withSizeCost(paginateQueryResult(result, asNumber(args["limit"]), args["count_only"] === true)))
4595
5460
  },
4596
5461
  {
4597
5462
  name: IrisTool.INSPECT,
4598
5463
  description: "Deep info on one element by ref: full a11y props, visibility, box, and (with @syrin/iris-react) component stack + source file.",
4599
5464
  inputSchema: {
4600
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
5465
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
4601
5466
  ...sessionIdShape6
4602
5467
  },
4603
5468
  outputSchema: {
4604
- ref: z15.string(),
4605
- role: z15.string(),
4606
- name: z15.string(),
4607
- value: z15.string().optional(),
4608
- states: z15.array(z15.string()),
4609
- visible: z15.boolean(),
4610
- box: z15.object({ x: z15.number(), y: z15.number(), width: z15.number(), height: z15.number() }).optional(),
4611
- component: z15.object({ name: z15.string().optional(), sourceFile: z15.string().optional() }).optional()
5469
+ ref: z16.string(),
5470
+ role: z16.string(),
5471
+ name: z16.string(),
5472
+ value: z16.string().optional(),
5473
+ states: z16.array(z16.string()),
5474
+ visible: z16.boolean(),
5475
+ box: z16.object({ x: z16.number(), y: z16.number(), width: z16.number(), height: z16.number() }).optional(),
5476
+ component: z16.object({ name: z16.string().optional(), sourceFile: z16.string().optional() }).optional()
4612
5477
  },
4613
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.INSPECT, {
5478
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.INSPECT, {
4614
5479
  ref: args["ref"]
4615
5480
  })
4616
5481
  },
@@ -4618,30 +5483,30 @@ var TOOLS = [
4618
5483
  name: IrisTool.ACT,
4619
5484
  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.',
4620
5485
  inputSchema: {
4621
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
4622
- action: z15.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4623
- args: z15.record(z15.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click."),
4624
- refuseWhenThrottled: z15.boolean().optional().describe("Throw instead of silently sending synthetic events when the tab is throttled/backgrounded. Default: false (synthetic events are still sent)."),
5486
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query (e.g. 'e42')."),
5487
+ action: z16.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
5488
+ 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."),
5489
+ 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)."),
4625
5490
  ...sessionIdShape6
4626
5491
  },
4627
5492
  outputSchema: {
4628
- since: z15.number().describe("Cursor \u2014 pass to iris_observe/iris_wait_for/iris_assert to scope reaction queries to this act."),
4629
- dispatched: z15.boolean(),
4630
- settled: z15.boolean().nullable(),
4631
- inputMode: z15.string(),
4632
- result: z15.unknown().optional(),
4633
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5493
+ since: z16.number().describe("Cursor \u2014 pass to iris_observe/iris_wait_for/iris_assert to scope reaction queries to this act."),
5494
+ dispatched: z16.boolean(),
5495
+ settled: z16.boolean().nullable(),
5496
+ inputMode: z16.string(),
5497
+ result: z16.unknown().optional(),
5498
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4634
5499
  },
4635
5500
  handler: async (deps, args) => {
4636
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5501
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4637
5502
  const paused = pausedShortCircuit(session);
4638
5503
  if (paused !== void 0)
4639
5504
  return paused;
4640
5505
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4641
5506
  const since = session.elapsed();
4642
5507
  session.markActCursor(since);
4643
- const ref = asString4(args["ref"]) ?? "";
4644
- const action = asString4(args["action"]) ?? "";
5508
+ const ref = asString(args["ref"]) ?? "";
5509
+ const action = asString(args["action"]) ?? "";
4645
5510
  const real = await tryRealInput(deps, session, ref, action, args);
4646
5511
  if (real.result !== void 0) {
4647
5512
  if (deps.recordings.active().length > 0) {
@@ -4667,7 +5532,7 @@ var TOOLS = [
4667
5532
  if (deps.recordings.active().length > 0) {
4668
5533
  deps.recordings.capture(compileActStep(args, result.result));
4669
5534
  }
4670
- const r = asRecord3(result.result);
5535
+ const r = asRecord(result.result);
4671
5536
  return withControl(session, {
4672
5537
  since,
4673
5538
  inputMode: InputMode.SYNTHETIC,
@@ -4686,17 +5551,17 @@ var TOOLS = [
4686
5551
  name: IrisTool.ACT_SEQUENCE,
4687
5552
  description: "Run multiple actions in order (fill -> fill -> submit) in one round-trip. Returns per-step effects[] (see iris_act).",
4688
5553
  inputSchema: {
4689
- steps: z15.array(z15.record(z15.unknown())).describe("Ordered list of { ref, action, args? } objects. Each step is equivalent to one iris_act call."),
5554
+ 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."),
4690
5555
  ...sessionIdShape6
4691
5556
  },
4692
5557
  outputSchema: {
4693
- since: z15.number(),
4694
- dispatched: z15.boolean(),
4695
- result: z15.unknown().optional(),
4696
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5558
+ since: z16.number(),
5559
+ dispatched: z16.boolean(),
5560
+ result: z16.unknown().optional(),
5561
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4697
5562
  },
4698
5563
  handler: async (deps, args) => {
4699
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5564
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4700
5565
  const paused = pausedShortCircuit(session);
4701
5566
  if (paused !== void 0)
4702
5567
  return paused;
@@ -4708,7 +5573,7 @@ var TOOLS = [
4708
5573
  if (deps.recordings.active().length > 0) {
4709
5574
  deps.recordings.capture(compileSequenceStep(args, result.result));
4710
5575
  }
4711
- const r = asRecord3(result.result);
5576
+ const r = asRecord(result.result);
4712
5577
  return withControl(session, {
4713
5578
  since,
4714
5579
  dispatched: r["count"] !== void 0,
@@ -4719,34 +5584,34 @@ var TOOLS = [
4719
5584
  },
4720
5585
  {
4721
5586
  name: IrisTool.ACT_AND_WAIT,
4722
- 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.",
5587
+ 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.",
4723
5588
  inputSchema: {
4724
- ref: z15.string().describe("Element ref from iris_snapshot or iris_query."),
4725
- action: z15.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
4726
- args: z15.record(z15.unknown()).optional().describe("Action-specific arguments: { value } for fill/select, { text } for type/press."),
4727
- until: PredicateSchema.describe("Predicate to wait for after the action completes. Same shape accepted by iris_assert."),
4728
- timeout_ms: z15.number().optional().describe("Maximum wait time in milliseconds. 0 = evaluate once without waiting. Default: 4000."),
4729
- refuseWhenThrottled: z15.boolean().optional().describe("Throw if the tab is throttled. Default: false."),
5589
+ ref: z16.string().describe("Element ref from iris_snapshot or iris_query."),
5590
+ action: z16.string().describe("Action to perform: click | dblclick | hover | focus | fill | type | clear | select | check | uncheck | submit | press | scrollIntoView"),
5591
+ 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."),
5592
+ 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" }] }.'),
5593
+ timeout_ms: z16.number().optional().describe("Maximum wait time in milliseconds. 0 = evaluate once without waiting. Default: 4000."),
5594
+ refuseWhenThrottled: z16.boolean().optional().describe("Throw if the tab is throttled. Default: false."),
4730
5595
  ...sessionIdShape6
4731
5596
  },
4732
5597
  outputSchema: {
4733
- effect: z15.unknown().describe("The iris_act result (dispatched, settled, inputMode, etc.)."),
4734
- verdict: z15.object({
4735
- pass: z15.boolean(),
4736
- evidence: z15.unknown().optional(),
4737
- failureReason: z15.string().optional()
5598
+ effect: z16.unknown().describe("The iris_act result (dispatched, settled, inputMode, etc.)."),
5599
+ verdict: z16.object({
5600
+ pass: z16.boolean(),
5601
+ evidence: z16.unknown().optional(),
5602
+ failureReason: z16.string().optional()
4738
5603
  }),
4739
- trace: z15.unknown().describe("Reaction report (same shape as iris_observe summary)."),
4740
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5604
+ trace: z16.unknown().describe("Reaction report (same shape as iris_observe summary)."),
5605
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4741
5606
  },
4742
5607
  handler: async (deps, args) => {
4743
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5608
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4744
5609
  const paused = pausedShortCircuit(session);
4745
5610
  if (paused !== void 0)
4746
5611
  return paused;
4747
5612
  refuseIfThrottled(session, args["refuseWhenThrottled"]);
4748
- const until = PredicateSchema.parse(args["until"]);
4749
- const timeout = asNumber2(args["timeout_ms"]) ?? 4e3;
5613
+ const until = args["until"] !== void 0 ? PredicateSchema.parse(args["until"]) : { kind: "settled" };
5614
+ const timeout = asNumber(args["timeout_ms"]) ?? 4e3;
4750
5615
  const since = session.elapsed();
4751
5616
  session.markActCursor(since);
4752
5617
  const actResult = await session.command(IrisCommand.ACT, {
@@ -4770,44 +5635,45 @@ var TOOLS = [
4770
5635
  name: IrisTool.OBSERVE,
4771
5636
  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.",
4772
5637
  inputSchema: {
4773
- window_ms: z15.number().optional().describe("Time window to look back. Default: 2000ms. Ignored when `since` is provided."),
4774
- since: z15.number().optional().describe("Cursor from a prior iris_act or iris_observe call. Scopes the event window to exactly that span."),
4775
- filters: z15.array(z15.string()).optional().describe("Event type allowlist: dom | net | route | console | animation | signal. Omit to return all types."),
4776
- max_events: z15.number().optional().describe("Cap the timeline to the most recent N events. Older events are counted in cost.droppedOldest."),
5638
+ window_ms: z16.number().optional().describe("Time window to look back. Default: 2000ms. Ignored when `since` is provided."),
5639
+ since: z16.number().optional().describe("Cursor from a prior iris_act or iris_observe call. Scopes the event window to exactly that span."),
5640
+ filters: z16.array(z16.string()).optional().describe("Event type allowlist: dom | net | route | console | animation | signal. Omit to return all types."),
5641
+ max_events: z16.number().optional().describe("Cap the timeline to the most recent N events. Older events are counted in cost.droppedOldest."),
4777
5642
  ...sessionIdShape6
4778
5643
  },
4779
5644
  outputSchema: {
4780
- events: z15.array(z15.unknown()),
4781
- summary: z15.object({
4782
- total: z15.number(),
4783
- network: z15.number(),
4784
- domAdded: z15.number(),
4785
- domRemoved: z15.number(),
4786
- domChanged: z15.number(),
4787
- routeChanges: z15.number(),
4788
- consoleErrors: z15.number(),
4789
- animations: z15.number(),
4790
- signals: z15.number()
5645
+ events: z16.array(z16.unknown()),
5646
+ summary: z16.object({
5647
+ total: z16.number(),
5648
+ network: z16.number(),
5649
+ domAdded: z16.number(),
5650
+ domRemoved: z16.number(),
5651
+ domChanged: z16.number(),
5652
+ routeChanges: z16.number(),
5653
+ consoleErrors: z16.number(),
5654
+ animations: z16.number(),
5655
+ signals: z16.number()
4791
5656
  }),
4792
- cost: z15.object({
4793
- events: z15.number(),
4794
- bytes: z15.number(),
4795
- droppedOldest: z15.number().optional()
5657
+ cost: z16.object({
5658
+ events: z16.number(),
5659
+ bytes: z16.number(),
5660
+ droppedOldest: z16.number().optional(),
5661
+ recommendation: z16.string().optional().describe("Present when the timeline is large \u2014 scope your next call (filters/max_events).")
4796
5662
  }),
4797
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5663
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4798
5664
  },
4799
5665
  handler: (deps, args) => {
4800
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4801
- const since = asNumber2(args["since"]);
4802
- const windowMs = asNumber2(args["window_ms"]) ?? 2e3;
5666
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5667
+ const since = asNumber(args["since"]);
5668
+ const windowMs = asNumber(args["window_ms"]) ?? 2e3;
4803
5669
  const events = since !== void 0 ? session.eventsSince(since) : session.eventsInWindow(windowMs);
4804
5670
  const filters = Array.isArray(args["filters"]) ? args["filters"] : void 0;
4805
5671
  const filtered = filters === void 0 ? events : events.filter((e) => filters.includes(e.type));
4806
- const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber2(args["max_events"]));
4807
- const report = buildReactionReport(budgeted, windowMs);
5672
+ const { events: budgeted, droppedOldest } = applyEventBudget(filtered, asNumber(args["max_events"]));
5673
+ const report2 = buildReactionReport(budgeted, windowMs);
4808
5674
  return Promise.resolve(withControl(session, {
4809
- ...report,
4810
- cost: costHint(report, budgeted.length, droppedOldest),
5675
+ ...report2,
5676
+ cost: costHint(report2, budgeted.length, droppedOldest),
4811
5677
  ...healthEnvelope(session)
4812
5678
  }));
4813
5679
  }
@@ -4816,99 +5682,113 @@ var TOOLS = [
4816
5682
  name: IrisTool.WAIT_FOR,
4817
5683
  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.",
4818
5684
  inputSchema: {
4819
- predicate: PredicateSchema.describe("Predicate to wait for: { signal }, { net }, { element } or a combination."),
4820
- timeout_ms: z15.number().optional().describe("Maximum wait in milliseconds. Default: 4000."),
4821
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the wait to events after that act."),
5685
+ 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.'),
5686
+ timeout_ms: z16.number().optional().describe("Maximum wait in milliseconds. Default: 4000."),
5687
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the wait to events after that act."),
4822
5688
  ...sessionIdShape6
4823
5689
  },
4824
5690
  outputSchema: {
4825
- pass: z15.boolean(),
4826
- evidence: z15.unknown().optional(),
4827
- failureReason: z15.string().optional(),
4828
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5691
+ pass: z16.boolean(),
5692
+ evidence: z16.unknown().optional(),
5693
+ failureReason: z16.string().optional(),
5694
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4829
5695
  },
4830
5696
  handler: async (deps, args) => {
4831
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5697
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4832
5698
  const predicate = PredicateSchema.parse(args["predicate"]);
4833
- const since = asNumber2(args["since"]) ?? session.lastActCursor() ?? 0;
4834
- const verdict = await waitForPredicate(session, predicate, asNumber2(args["timeout_ms"]) ?? 4e3, since);
5699
+ const since = asNumber(args["since"]) ?? session.lastActCursor() ?? 0;
5700
+ const verdict = await waitForPredicate(session, predicate, asNumber(args["timeout_ms"]) ?? 4e3, since);
4835
5701
  return withControl(session, { ...verdict, ...healthEnvelope(session) });
4836
5702
  }
4837
5703
  },
4838
5704
  {
4839
5705
  name: IrisTool.ASSERT,
4840
- 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.",
5706
+ 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.",
4841
5707
  inputSchema: {
4842
5708
  predicate: PredicateSchema.describe("Predicate to evaluate: { signal }, { net }, { element } or a combination."),
4843
- timeout_ms: z15.number().optional().describe("If > 0, wait up to this many milliseconds before failing. Default: 0 (evaluate once)."),
4844
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the assertion to events after that act."),
5709
+ timeout_ms: z16.number().optional().describe("If > 0, wait up to this many milliseconds before failing. Default: 0 (evaluate once)."),
5710
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the assertion to events after that act."),
4845
5711
  ...sessionIdShape6
4846
5712
  },
4847
5713
  outputSchema: {
4848
- pass: z15.boolean(),
4849
- evidence: z15.unknown().optional(),
4850
- failureReason: z15.string().optional(),
4851
- session: z15.object({ lastSeenMs: z15.number(), throttled: z15.boolean(), focused: z15.boolean() }).optional()
5714
+ pass: z16.boolean(),
5715
+ evidence: z16.unknown().optional(),
5716
+ failureReason: z16.string().optional(),
5717
+ advice: z16.string().optional().describe("Present on a PASSING presence-only assertion \u2014 nudges toward a consequence."),
5718
+ session: z16.object({ lastSeenMs: z16.number(), throttled: z16.boolean(), focused: z16.boolean() }).optional()
4852
5719
  },
4853
5720
  handler: async (deps, args) => {
4854
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5721
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
4855
5722
  const predicate = PredicateSchema.parse(args["predicate"]);
4856
- const timeout = asNumber2(args["timeout_ms"]) ?? 0;
4857
- const since = asNumber2(args["since"]) ?? session.lastActCursor() ?? 0;
5723
+ const timeout = asNumber(args["timeout_ms"]) ?? 0;
5724
+ const since = asNumber(args["since"]) ?? session.lastActCursor() ?? 0;
4858
5725
  const verdict = timeout > 0 ? await waitForPredicate(session, predicate, timeout, since) : await evaluatePredicate(session, predicate, since);
4859
- return withControl(session, { ...verdict, ...healthEnvelope(session) });
5726
+ const advice = verdict.pass && isPresenceOnlyAssertion(predicate) ? { advice: PRESENCE_ONLY_ADVICE } : {};
5727
+ return withControl(session, { ...verdict, ...advice, ...healthEnvelope(session) });
4860
5728
  }
4861
5729
  },
4862
5730
  {
4863
5731
  name: IrisTool.NETWORK,
4864
5732
  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.',
4865
5733
  inputSchema: {
4866
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to requests fired after that act."),
4867
- method: z15.string().optional().describe("HTTP method filter: GET | POST | PUT | DELETE | PATCH etc."),
4868
- urlContains: z15.string().optional().describe("Substring that the request URL must contain."),
4869
- status: z15.number().optional().describe("HTTP status code filter (e.g. 200, 404, 500)."),
5734
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to requests fired after that act."),
5735
+ method: z16.string().optional().describe("HTTP method filter: GET | POST | PUT | DELETE | PATCH etc."),
5736
+ urlContains: z16.string().optional().describe("Substring that the request URL must contain."),
5737
+ status: z16.number().optional().describe("HTTP status code filter (e.g. 200, 404, 500)."),
5738
+ 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."),
4870
5739
  ...sessionIdShape6
4871
5740
  },
4872
5741
  outputSchema: {
4873
- calls: z15.array(z15.unknown()),
4874
- hint: z15.object({ totalInWindow: z15.number(), present: z15.array(z15.string()) }).optional()
5742
+ calls: z16.array(z16.unknown()),
5743
+ total: z16.number().optional().describe("Total matches before `limit` \u2014 present only when capped."),
5744
+ droppedOldest: z16.number().optional().describe("How many older matches `limit` dropped."),
5745
+ hint: z16.object({ totalInWindow: z16.number(), present: z16.array(z16.string()) }).optional(),
5746
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional()
4875
5747
  },
4876
5748
  handler: (deps, args) => {
4877
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4878
- const since = asNumber2(args["since"]) ?? 0;
4879
- const method = asString4(args["method"]);
4880
- const urlContains = asString4(args["urlContains"]);
4881
- const status = asNumber2(args["status"]);
5749
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5750
+ const since = asNumber(args["since"]) ?? 0;
5751
+ const method = asString(args["method"]);
5752
+ const urlContains = asString(args["urlContains"]);
5753
+ const status = asNumber(args["status"]);
5754
+ const limit = asNumber(args["limit"]);
4882
5755
  const allNet = session.eventsSince(since).filter((e) => e.type === EventType.NET_REQUEST);
4883
- const calls = allNet.filter((e) => matchNet(e, method, urlContains, status));
4884
- if (calls.length === 0 && allNet.length > 0) {
4885
- return Promise.resolve({ calls, hint: netEmptyHint(allNet) });
5756
+ const matched = allNet.filter((e) => matchNet(e, method, urlContains, status));
5757
+ if (matched.length === 0 && allNet.length > 0) {
5758
+ return Promise.resolve(withSizeCost({ calls: matched, hint: netEmptyHint(allNet) }));
4886
5759
  }
4887
- return Promise.resolve({ calls });
5760
+ const { events: calls, droppedOldest } = applyEventBudget(matched, limit);
5761
+ return Promise.resolve(withSizeCost(droppedOldest > 0 ? { calls, total: matched.length, droppedOldest } : { calls }));
4888
5762
  }
4889
5763
  },
4890
5764
  {
4891
5765
  name: IrisTool.CONSOLE,
4892
5766
  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.',
4893
5767
  inputSchema: {
4894
- level: z15.string().optional().describe("Log level filter: error | warn | info | log. Omit to return all levels."),
4895
- since: z15.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to log entries after that act."),
5768
+ level: z16.string().optional().describe("Log level filter: error | warn | info | log. Omit to return all levels."),
5769
+ since: z16.number().optional().describe("Cursor from a prior iris_act \u2014 scopes the query to log entries after that act."),
5770
+ 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."),
4896
5771
  ...sessionIdShape6
4897
5772
  },
4898
5773
  outputSchema: {
4899
- logs: z15.array(z15.unknown()),
4900
- hint: z15.object({ totalInWindow: z15.number(), byLevel: z15.record(z15.number()) }).optional()
5774
+ logs: z16.array(z16.unknown()),
5775
+ total: z16.number().optional().describe("Total matches before `limit` \u2014 present only when capped."),
5776
+ droppedOldest: z16.number().optional().describe("How many older matches `limit` dropped."),
5777
+ hint: z16.object({ totalInWindow: z16.number(), byLevel: z16.record(z16.number()) }).optional(),
5778
+ cost: z16.object({ bytes: z16.number(), tokens: z16.number() }).optional()
4901
5779
  },
4902
5780
  handler: (deps, args) => {
4903
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4904
- const since = asNumber2(args["since"]) ?? 0;
4905
- const level = asString4(args["level"]);
5781
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5782
+ const since = asNumber(args["since"]) ?? 0;
5783
+ const level = asString(args["level"]);
5784
+ const limit = asNumber(args["limit"]);
4906
5785
  const allConsole = session.eventsSince(since).filter(isConsoleEvent);
4907
- const logs = allConsole.filter((e) => matchConsole(e, level));
4908
- if (logs.length === 0 && allConsole.length > 0) {
4909
- return Promise.resolve({ logs, hint: consoleEmptyHint(allConsole) });
5786
+ const matched = allConsole.filter((e) => matchConsole(e, level));
5787
+ if (matched.length === 0 && allConsole.length > 0) {
5788
+ return Promise.resolve(withSizeCost({ logs: matched, hint: consoleEmptyHint(allConsole) }));
4910
5789
  }
4911
- return Promise.resolve({ logs });
5790
+ const { events: logs, droppedOldest } = applyEventBudget(matched, limit);
5791
+ return Promise.resolve(withSizeCost(droppedOldest > 0 ? { logs, total: matched.length, droppedOldest } : { logs }));
4912
5792
  }
4913
5793
  },
4914
5794
  {
@@ -4916,24 +5796,24 @@ var TOOLS = [
4916
5796
  description: "Currently running + recently completed animations with targets/timing.",
4917
5797
  inputSchema: { ...sessionIdShape6 },
4918
5798
  outputSchema: {
4919
- animations: z15.array(z15.unknown())
5799
+ animations: z16.array(z16.unknown())
4920
5800
  },
4921
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.ANIMATIONS, {})
5801
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.ANIMATIONS, {})
4922
5802
  },
4923
5803
  {
4924
5804
  name: IrisTool.BASELINE_SAVE,
4925
5805
  description: "Snapshot the current semantic state under a name, to diff against later (regression detection).",
4926
5806
  inputSchema: {
4927
- name: z15.string().describe('Label for this baseline snapshot (e.g. "dashboard-initial"). Use the same name in iris_diff to compare.'),
5807
+ name: z16.string().describe('Label for this baseline snapshot (e.g. "dashboard-initial"). Use the same name in iris_diff to compare.'),
4928
5808
  ...sessionIdShape6
4929
5809
  },
4930
5810
  outputSchema: {
4931
- baseline: z15.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
4932
- lineCount: z15.number()
5811
+ baseline: z16.string().describe("Saved baseline name \u2014 pass to iris_diff to compare."),
5812
+ lineCount: z16.number()
4933
5813
  },
4934
5814
  handler: async (deps, args) => {
4935
- const name = asString4(args["name"]) ?? "default";
4936
- const { lines, route } = await snapshotTree(deps, asString4(args["sessionId"]));
5815
+ const name = asString(args["name"]) ?? "default";
5816
+ const { lines, route } = await snapshotTree(deps, asString(args["sessionId"]));
4937
5817
  deps.baselines.save({ name, lines, route });
4938
5818
  return { baseline: name, lineCount: lines.length };
4939
5819
  }
@@ -4943,7 +5823,7 @@ var TOOLS = [
4943
5823
  description: "List saved baseline names.",
4944
5824
  inputSchema: {},
4945
5825
  outputSchema: {
4946
- baselines: z15.array(z15.string())
5826
+ baselines: z16.array(z16.string())
4947
5827
  },
4948
5828
  handler: (deps) => Promise.resolve({ baselines: deps.baselines.list() })
4949
5829
  },
@@ -4951,23 +5831,23 @@ var TOOLS = [
4951
5831
  name: IrisTool.DIFF,
4952
5832
  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?".',
4953
5833
  inputSchema: {
4954
- baseline: z15.string().describe("Baseline name to compare against. Call iris_baseline_list to get available names; names are created by iris_baseline_save."),
5834
+ baseline: z16.string().describe("Baseline name to compare against. Call iris_baseline_list to get available names; names are created by iris_baseline_save."),
4955
5835
  ...sessionIdShape6
4956
5836
  },
4957
5837
  outputSchema: {
4958
- baseline: z15.string(),
4959
- removed: z15.array(z15.string()),
4960
- added: z15.array(z15.string()),
4961
- consoleErrors: z15.number(),
4962
- routeChanged: z15.boolean()
5838
+ baseline: z16.string(),
5839
+ removed: z16.array(z16.string()),
5840
+ added: z16.array(z16.string()),
5841
+ consoleErrors: z16.number(),
5842
+ routeChanged: z16.boolean()
4963
5843
  },
4964
5844
  handler: async (deps, args) => {
4965
- const name = asString4(args["baseline"]) ?? "default";
5845
+ const name = asString(args["baseline"]) ?? "default";
4966
5846
  const base = deps.baselines.get(name);
4967
5847
  if (base === void 0)
4968
5848
  throw new Error(`no baseline named '${name}'`);
4969
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4970
- const { lines, route } = await snapshotTree(deps, asString4(args["sessionId"]));
5849
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5850
+ const { lines, route } = await snapshotTree(deps, asString(args["sessionId"]));
4971
5851
  const { removed, added } = diffLines(base.lines, lines);
4972
5852
  const consoleErrors = session.eventsSince(0).filter((e) => e.type === EventType.CONSOLE_ERROR || e.type === EventType.ERROR_UNCAUGHT).length;
4973
5853
  return { baseline: name, removed, added, consoleErrors, routeChanged: base.route !== route };
@@ -4977,16 +5857,16 @@ var TOOLS = [
4977
5857
  name: IrisTool.RECORD_START,
4978
5858
  description: "Start recording the event timeline under a name (for replay / a flow report).",
4979
5859
  inputSchema: {
4980
- recordingName: z15.string().describe("Identifier for this recording. Pass the same name to iris_record_stop and iris_replay."),
5860
+ recordingName: z16.string().describe("Identifier for this recording. Pass the same name to iris_record_stop and iris_replay."),
4981
5861
  ...sessionIdShape6
4982
5862
  },
4983
5863
  outputSchema: {
4984
- recordingName: z15.string(),
4985
- since: z15.number()
5864
+ recordingName: z16.string(),
5865
+ since: z16.number()
4986
5866
  },
4987
5867
  handler: (deps, args) => {
4988
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
4989
- const name = asString4(args["recordingName"]) ?? "default";
5868
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5869
+ const name = asString(args["recordingName"]) ?? "default";
4990
5870
  const cursor = session.elapsed();
4991
5871
  deps.recordings.start(name, cursor);
4992
5872
  return Promise.resolve({ recordingName: name, since: cursor });
@@ -4996,17 +5876,17 @@ var TOOLS = [
4996
5876
  name: IrisTool.RECORD_STOP,
4997
5877
  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.",
4998
5878
  inputSchema: {
4999
- recordingName: z15.string().describe("Identifier of an active recording started with iris_record_start."),
5879
+ recordingName: z16.string().describe("Identifier of an active recording started with iris_record_start."),
5000
5880
  ...sessionIdShape6
5001
5881
  },
5002
5882
  outputSchema: {
5003
- recordingName: z15.string(),
5004
- program: z15.unknown(),
5005
- warning: z15.string().optional()
5883
+ recordingName: z16.string(),
5884
+ program: z16.unknown(),
5885
+ warning: z16.string().optional()
5006
5886
  },
5007
5887
  handler: (deps, args) => {
5008
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5009
- const name = asString4(args["recordingName"]) ?? "default";
5888
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5889
+ const name = asString(args["recordingName"]) ?? "default";
5010
5890
  const rec = deps.recordings.stop(name);
5011
5891
  if (rec === void 0)
5012
5892
  throw new Error(`no active recording named '${name}'`);
@@ -5018,43 +5898,44 @@ var TOOLS = [
5018
5898
  };
5019
5899
  deps.recordings.saveCompiled(program);
5020
5900
  const unstable = rec.steps.filter((s) => !s.stable).length;
5021
- const report = buildReactionReport(events, session.elapsed() - rec.cursor);
5901
+ const report2 = buildReactionReport(events, session.elapsed() - rec.cursor);
5022
5902
  return Promise.resolve({
5023
5903
  recordingName: name,
5024
5904
  program,
5025
5905
  ...unstable > 0 ? {
5026
5906
  warning: `${String(unstable)} step(s) not bound to a testid; replay may be brittle (in-session only)`
5027
5907
  } : {},
5028
- ...report,
5029
- cost: costHint(report, events.length)
5908
+ ...report2,
5909
+ cost: costHint(report2, events.length)
5030
5910
  });
5031
5911
  }
5032
5912
  },
5033
5913
  {
5034
5914
  name: IrisTool.REPLAY,
5035
- 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?}] }.",
5915
+ 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?}] }.",
5036
5916
  inputSchema: {
5037
- recordingName: z15.string().describe("Name of a compiled recording (from iris_record_stop) to re-execute."),
5917
+ recordingName: z16.string().describe("Name of a compiled recording (from iris_record_stop) to re-execute."),
5918
+ confirmDangerous: z16.boolean().optional().describe("Set true to allow destructive controls during this replay only."),
5038
5919
  ...sessionIdShape6
5039
5920
  },
5040
5921
  outputSchema: {
5041
- recordingName: z15.string(),
5042
- ok: z15.boolean(),
5043
- steps: z15.array(z15.object({
5044
- tool: z15.string(),
5045
- ok: z15.boolean(),
5046
- error: z15.string().optional(),
5047
- note: z15.string().optional()
5922
+ recordingName: z16.string(),
5923
+ ok: z16.boolean(),
5924
+ steps: z16.array(z16.object({
5925
+ tool: z16.string(),
5926
+ ok: z16.boolean(),
5927
+ error: z16.string().optional(),
5928
+ note: z16.string().optional()
5048
5929
  }))
5049
5930
  },
5050
5931
  handler: async (deps, args) => {
5051
- const name = asString4(args["recordingName"]) ?? "default";
5932
+ const name = asString(args["recordingName"]) ?? "default";
5052
5933
  const program = deps.recordings.getCompiled(name);
5053
5934
  if (program === void 0)
5054
5935
  throw new Error(`no compiled recording named '${name}'`);
5055
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
5936
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5056
5937
  const since = session.elapsed();
5057
- const steps = await replayProgram(session, program);
5938
+ const steps = await replayProgram(session, program, args["confirmDangerous"] === true);
5058
5939
  return { recordingName: name, since, steps, ok: steps.every((s) => s.ok) };
5059
5940
  }
5060
5941
  },
@@ -5062,13 +5943,13 @@ var TOOLS = [
5062
5943
  name: IrisTool.NARRATE,
5063
5944
  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.",
5064
5945
  inputSchema: {
5065
- text: z15.string().describe("Short sentence describing your next action, shown on the presenter HUD for the developer watching."),
5066
- level: z15.string().optional().describe("Display severity: info | warn | error. Default: info."),
5946
+ text: z16.string().describe("Short sentence describing your next action, shown on the presenter HUD for the developer watching."),
5947
+ level: z16.string().optional().describe("Display severity: info | warn | error. Default: info."),
5067
5948
  ...sessionIdShape6
5068
5949
  },
5069
- outputSchema: { ok: z15.boolean() },
5950
+ outputSchema: { ok: z16.boolean() },
5070
5951
  handler: async (deps, args) => {
5071
- const result = await commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.NARRATE, {
5952
+ const result = await commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.NARRATE, {
5072
5953
  text: args["text"],
5073
5954
  level: args["level"]
5074
5955
  });
@@ -5079,16 +5960,16 @@ var TOOLS = [
5079
5960
  name: IrisTool.CLOCK,
5080
5961
  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.",
5081
5962
  inputSchema: {
5082
- freeze: z15.boolean().optional().describe("Freeze the fake clock. Time stops advancing until advanceMs or reset."),
5083
- advanceMs: z15.number().optional().describe("Fast-forward time by this many milliseconds \u2014 triggers debounces, toasts, auto-dismiss timers."),
5084
- reset: z15.boolean().optional().describe("Restore the real clock."),
5963
+ freeze: z16.boolean().optional().describe("Freeze the fake clock. Time stops advancing until advanceMs or reset."),
5964
+ advanceMs: z16.number().optional().describe("Fast-forward time by this many milliseconds \u2014 triggers debounces, toasts, auto-dismiss timers."),
5965
+ reset: z16.boolean().optional().describe("Restore the real clock."),
5085
5966
  ...sessionIdShape6
5086
5967
  },
5087
5968
  outputSchema: {
5088
- ok: z15.boolean().optional(),
5089
- elapsed: z15.number().optional()
5969
+ ok: z16.boolean().optional(),
5970
+ elapsed: z16.number().optional()
5090
5971
  },
5091
- handler: (deps, args) => commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.CLOCK, {
5972
+ handler: (deps, args) => commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.CLOCK, {
5092
5973
  freeze: args["freeze"],
5093
5974
  advanceMs: args["advanceMs"],
5094
5975
  reset: args["reset"]
@@ -5098,27 +5979,27 @@ var TOOLS = [
5098
5979
  name: IrisTool.STATE,
5099
5980
  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? }.",
5100
5981
  inputSchema: {
5101
- ref: z15.string().optional().describe("Element ref \u2014 attempts a best-effort read of the nearest React component's hook state."),
5102
- store: z15.string().optional().describe("Registered store name (e.g. 'workspace'). Omit to read all stores."),
5103
- path: z15.string().optional().describe("Dot-path into the store (e.g. 'captionCache.v3'). Numeric array indices are supported."),
5104
- depth: z15.number().optional().describe("Collapse anything deeper than N levels to a size marker \u2014 avoids huge outputs for large stores."),
5982
+ ref: z16.string().optional().describe("Element ref \u2014 attempts a best-effort read of the nearest React component's hook state."),
5983
+ store: z16.string().optional().describe("Registered store name (e.g. 'workspace'). Omit to read all stores."),
5984
+ path: z16.string().optional().describe("Dot-path into the store (e.g. 'captionCache.v3'). Numeric array indices are supported."),
5985
+ depth: z16.number().optional().describe("Collapse anything deeper than N levels to a size marker \u2014 avoids huge outputs for large stores."),
5105
5986
  ...sessionIdShape6
5106
5987
  },
5107
5988
  outputSchema: {
5108
- stores: z15.record(z15.unknown()).optional(),
5109
- storeNames: z15.array(z15.string()).optional(),
5110
- found: z15.boolean().optional(),
5111
- value: z15.unknown().optional(),
5112
- component: z15.object({ ok: z15.boolean(), reason: z15.string().optional(), state: z15.unknown().optional() }).optional()
5989
+ stores: z16.record(z16.unknown()).optional(),
5990
+ storeNames: z16.array(z16.string()).optional(),
5991
+ found: z16.boolean().optional(),
5992
+ value: z16.unknown().optional(),
5993
+ component: z16.object({ ok: z16.boolean(), reason: z16.string().optional(), state: z16.unknown().optional() }).optional()
5113
5994
  },
5114
5995
  handler: async (deps, args) => {
5115
- const store = asString4(args["store"]);
5116
- const result = await commandOrThrow3(deps, asString4(args["sessionId"]), IrisCommand.STATE_READ, {
5996
+ const store = asString(args["store"]);
5997
+ const result = await commandOrThrow3(deps, asString(args["sessionId"]), IrisCommand.STATE_READ, {
5117
5998
  ref: args["ref"],
5118
5999
  store
5119
6000
  });
5120
- const path = asString4(args["path"]);
5121
- const depth = asNumber2(args["depth"]);
6001
+ const path = asString(args["path"]);
6002
+ const depth = asNumber(args["depth"]);
5122
6003
  if (path === void 0 && depth === void 0)
5123
6004
  return result;
5124
6005
  const root = result;
@@ -5138,16 +6019,16 @@ var TOOLS = [
5138
6019
  name: IrisTool.EXPLORE,
5139
6020
  description: "Autonomous-exploration helper: list interactive elements (with refs) + current console-error count, so the agent can drive the app and report anomalies.",
5140
6021
  inputSchema: {
5141
- scope: z15.string().optional().describe("CSS selector or element ref to restrict the interactive element list to a subtree."),
6022
+ scope: z16.string().optional().describe("CSS selector or element ref to restrict the interactive element list to a subtree."),
5142
6023
  ...sessionIdShape6
5143
6024
  },
5144
6025
  outputSchema: {
5145
- interactive: z15.array(z15.unknown()),
5146
- consoleErrors: z15.number(),
5147
- hint: z15.string()
6026
+ interactive: z16.array(z16.unknown()),
6027
+ consoleErrors: z16.number(),
6028
+ hint: z16.string()
5148
6029
  },
5149
6030
  handler: async (deps, args) => {
5150
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6031
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5151
6032
  const result = await session.command(IrisCommand.SNAPSHOT, {
5152
6033
  mode: SnapshotMode.INTERACTIVE,
5153
6034
  scope: args["scope"]
@@ -5165,6 +6046,7 @@ var TOOLS = [
5165
6046
  },
5166
6047
  // iris_capabilities (live | fromDisk) + iris_contract_save. See contract-tools.ts.
5167
6048
  ...CONTRACT_TOOLS,
6049
+ ...DOMAIN_TOOLS,
5168
6050
  // iris_flow_save / iris_flow_list / iris_flow_load. See flow-tools.ts.
5169
6051
  ...FLOW_TOOLS,
5170
6052
  // iris_project (read history + diff-vs-last) / iris_run_record. See project-tools.ts.
@@ -5318,7 +6200,7 @@ async function runTool(tool, deps, args) {
5318
6200
  return result;
5319
6201
  if (!isPlainObject(result) || "session" in result)
5320
6202
  return result;
5321
- const session = deps.sessions.resolve(asString4(args["sessionId"]));
6203
+ const session = deps.sessions.resolve(asString(args["sessionId"]));
5322
6204
  const envelope = { ...healthEnvelope(session) };
5323
6205
  const lease = session.takeSessionLease();
5324
6206
  if (lease !== void 0)
@@ -5483,7 +6365,17 @@ function spawnDaemon(nodeExec, scriptPath, args, port) {
5483
6365
  // ../server/dist/index.js
5484
6366
  async function start(options = {}) {
5485
6367
  const port = options.port ?? IRIS_DEFAULT_PORT;
5486
- const bridge = new Bridge({ port });
6368
+ const envToken = process.env["IRIS_TOKEN"];
6369
+ const envOrigins = process.env["IRIS_ALLOWED_ORIGINS"];
6370
+ const host = options.host ?? process.env["IRIS_HOST"];
6371
+ const token = options.token ?? (envToken !== void 0 && envToken.length > 0 ? envToken : void 0);
6372
+ const allowedOrigins = options.allowedOrigins ?? envOrigins?.split(",").map((origin) => origin.trim()).filter((origin) => origin.length > 0);
6373
+ const bridge = new Bridge({
6374
+ port,
6375
+ ...host === void 0 ? {} : { host },
6376
+ ...token === void 0 ? {} : { token },
6377
+ ...allowedOrigins === void 0 ? {} : { allowedOrigins }
6378
+ });
5487
6379
  const reaper = new SessionReaper(bridge.sessions);
5488
6380
  reaper.start();
5489
6381
  const baselines = new BaselineStore();
@@ -5751,13 +6643,619 @@ ${val}` : val;
5751
6643
  });
5752
6644
  }
5753
6645
 
6646
+ // ../server/dist/init/detect.js
6647
+ var Framework = {
6648
+ NEXT: "next",
6649
+ VITE: "vite",
6650
+ HTML: "html"
6651
+ };
6652
+ var PackageManager = {
6653
+ PNPM: "pnpm",
6654
+ YARN: "yarn",
6655
+ BUN: "bun",
6656
+ NPM: "npm"
6657
+ };
6658
+ var NEXT_CONFIGS = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
6659
+ var VITE_CONFIGS = ["vite.config.js", "vite.config.ts", "vite.config.mjs", "vite.config.mts"];
6660
+ function depVersion(pkg, name) {
6661
+ return pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name];
6662
+ }
6663
+ function hasAnyConfig(files, candidates) {
6664
+ return candidates.some((c) => files.has(c));
6665
+ }
6666
+ function parseMajor(range) {
6667
+ if (range === void 0)
6668
+ return void 0;
6669
+ const match = range.match(/(\d+)/);
6670
+ if (match === null || match[1] === void 0)
6671
+ return void 0;
6672
+ const major = parseInt(match[1], 10);
6673
+ return isNaN(major) ? void 0 : major;
6674
+ }
6675
+ function detectPackageManager(lockfiles) {
6676
+ if (lockfiles.has("pnpm-lock.yaml"))
6677
+ return PackageManager.PNPM;
6678
+ if (lockfiles.has("yarn.lock"))
6679
+ return PackageManager.YARN;
6680
+ if (lockfiles.has("bun.lockb") || lockfiles.has("bun.lock"))
6681
+ return PackageManager.BUN;
6682
+ return PackageManager.NPM;
6683
+ }
6684
+ function detectFramework(input) {
6685
+ const { pkg, configFiles } = input;
6686
+ if (depVersion(pkg, "next") !== void 0 || hasAnyConfig(configFiles, NEXT_CONFIGS)) {
6687
+ return Framework.NEXT;
6688
+ }
6689
+ if (depVersion(pkg, "vite") !== void 0 || hasAnyConfig(configFiles, VITE_CONFIGS)) {
6690
+ return Framework.VITE;
6691
+ }
6692
+ return Framework.HTML;
6693
+ }
6694
+ function detect(input) {
6695
+ const reactMajor = parseMajor(depVersion(input.pkg, "react"));
6696
+ return {
6697
+ framework: detectFramework(input),
6698
+ reactMajor,
6699
+ needsSourceMapping: reactMajor !== void 0 && reactMajor >= 19,
6700
+ packageManager: detectPackageManager(input.lockfiles)
6701
+ };
6702
+ }
6703
+ var INSTALL_ARGS = {
6704
+ [PackageManager.PNPM]: ["add", "-D"],
6705
+ [PackageManager.YARN]: ["add", "-D"],
6706
+ [PackageManager.BUN]: ["add", "-d"],
6707
+ [PackageManager.NPM]: ["i", "-D"]
6708
+ };
6709
+ function installCommandParts(pm, pkg) {
6710
+ return { command: pm, args: [...INSTALL_ARGS[pm], pkg] };
6711
+ }
6712
+ function installCommand(pm, pkg) {
6713
+ const { command, args } = installCommandParts(pm, pkg);
6714
+ return `${command} ${args.join(" ")}`;
6715
+ }
6716
+
6717
+ // ../server/dist/init/mcp.js
6718
+ var MCP_SERVER_NAME = "iris";
6719
+ var NPX = "npx";
6720
+ var IRIS_PACKAGE = "@syrin/iris";
6721
+ var MCP_SUBCOMMAND = "mcp";
6722
+ var PORT_FLAG = "--port";
6723
+ var CLAUDE_CLI = "claude";
6724
+ function npxServerArgs(port) {
6725
+ return port === void 0 ? [IRIS_PACKAGE, MCP_SUBCOMMAND] : [IRIS_PACKAGE, MCP_SUBCOMMAND, PORT_FLAG, String(port)];
6726
+ }
6727
+ function serverInvocation(port) {
6728
+ return [NPX, ...npxServerArgs(port)];
6729
+ }
6730
+ function claudeAddCommand(port) {
6731
+ const tail = serverInvocation(port);
6732
+ const args = [MCP_SUBCOMMAND, "add", MCP_SERVER_NAME, "-s", "user", "--", ...tail];
6733
+ return { command: CLAUDE_CLI, args, display: `${CLAUDE_CLI} ${args.join(" ")}` };
6734
+ }
6735
+ function claudeExistsProbe() {
6736
+ return { command: CLAUDE_CLI, args: [MCP_SUBCOMMAND, "get", MCP_SERVER_NAME] };
6737
+ }
6738
+ function claudeAvailableProbe() {
6739
+ return { command: CLAUDE_CLI, args: ["--version"] };
6740
+ }
6741
+ function mcpManual(port) {
6742
+ const tail = serverInvocation(port).join(" ");
6743
+ return `Register the Iris MCP server ONCE, globally (so every project gets it):
6744
+
6745
+ ${CLAUDE_CLI} ${MCP_SUBCOMMAND} add ${MCP_SERVER_NAME} -s user -- ${tail}
6746
+
6747
+ Or, for another agent, add this to its global MCP config (e.g. Cursor's ~/.cursor/mcp.json):
6748
+
6749
+ "${MCP_SERVER_NAME}": { "command": "${NPX}", "args": ${JSON.stringify(serverInvocation(port).slice(1))} }`;
6750
+ }
6751
+
6752
+ // ../server/dist/init/cursor.js
6753
+ var CURSOR_MCP_RELPATH = ".cursor/mcp.json";
6754
+ var CURSOR_DIR_RELPATH = ".cursor";
6755
+ var CursorMergeStatus = {
6756
+ APPLY: "apply",
6757
+ ALREADY: "already",
6758
+ MANUAL: "manual"
6759
+ };
6760
+ function cursorServerEntry(port) {
6761
+ return { command: NPX, args: npxServerArgs(port) };
6762
+ }
6763
+ function parseConfig(existing) {
6764
+ if (existing === null || existing.trim().length === 0)
6765
+ return { ok: true, config: {} };
6766
+ try {
6767
+ const parsed = JSON.parse(existing);
6768
+ if (typeof parsed !== "object" || parsed === null)
6769
+ return { ok: true, config: {} };
6770
+ return { ok: true, config: parsed };
6771
+ } catch {
6772
+ return { ok: false };
6773
+ }
6774
+ }
6775
+ function mergeCursorConfig(existing, port) {
6776
+ const parsed = parseConfig(existing);
6777
+ if (!parsed.ok) {
6778
+ return { status: CursorMergeStatus.MANUAL, content: existing ?? "" };
6779
+ }
6780
+ const config = parsed.config;
6781
+ const servers = config.mcpServers ?? {};
6782
+ if (Object.prototype.hasOwnProperty.call(servers, MCP_SERVER_NAME)) {
6783
+ return { status: CursorMergeStatus.ALREADY, content: existing ?? "" };
6784
+ }
6785
+ const merged = {
6786
+ ...config,
6787
+ mcpServers: { ...servers, [MCP_SERVER_NAME]: cursorServerEntry(port) }
6788
+ };
6789
+ return { status: CursorMergeStatus.APPLY, content: `${JSON.stringify(merged, null, 2)}
6790
+ ` };
6791
+ }
6792
+
6793
+ // ../server/dist/init/vite-config.js
6794
+ var VITE_IMPORT = "import { iris } from '@syrin/iris/vite';";
6795
+ var IRIS_MARKER = "@syrin/iris/vite";
6796
+ function irisPluginCall(port) {
6797
+ return port === void 0 ? "iris()" : `iris({ port: ${String(port)} })`;
6798
+ }
6799
+ var PLUGINS_ARRAY = /plugins\s*:\s*\[/;
6800
+ var IMPORT_LINE = /^import\s.+from\s+['"][^'"]+['"];?\s*$/gm;
6801
+ var VitePatchKind = {
6802
+ APPLY: "apply",
6803
+ ALREADY: "already",
6804
+ MANUAL: "manual"
6805
+ };
6806
+ var NO_PLUGINS_REASON = "couldn't find a `plugins: [...]` array to extend";
6807
+ function insertImport(source) {
6808
+ const matches = [...source.matchAll(IMPORT_LINE)];
6809
+ const last = matches[matches.length - 1];
6810
+ if (last?.index === void 0) {
6811
+ return `${VITE_IMPORT}
6812
+ ${source}`;
6813
+ }
6814
+ const end = last.index + last[0].length;
6815
+ return `${source.slice(0, end)}
6816
+ ${VITE_IMPORT}${source.slice(end)}`;
6817
+ }
6818
+ function insertPlugin(source, port) {
6819
+ return source.replace(PLUGINS_ARRAY, (match) => `${match}${irisPluginCall(port)}, `);
6820
+ }
6821
+ function patchViteConfig(source, port) {
6822
+ if (source.includes(IRIS_MARKER)) {
6823
+ return { kind: VitePatchKind.ALREADY };
6824
+ }
6825
+ if (!PLUGINS_ARRAY.test(source)) {
6826
+ return { kind: VitePatchKind.MANUAL, reason: NO_PLUGINS_REASON };
6827
+ }
6828
+ return { kind: VitePatchKind.APPLY, code: insertImport(insertPlugin(source, port)) };
6829
+ }
6830
+
6831
+ // ../server/dist/init/snippets.js
6832
+ function connectArg(port) {
6833
+ if (port === void 0 || port === IRIS_DEFAULT_PORT)
6834
+ return "";
6835
+ return `{ url: 'ws://localhost:${String(port)}${IRIS_WS_PATH}' }`;
6836
+ }
6837
+ function viteManual(port) {
6838
+ const call = port === void 0 ? "iris()" : `iris({ port: ${String(port)} })`;
6839
+ return `Add the Iris plugin to your Vite config:
6840
+
6841
+ import { iris } from '@syrin/iris/vite';
6842
+
6843
+ export default defineConfig({
6844
+ plugins: [react(), ${call}],
6845
+ });
6846
+
6847
+ The plugin only applies during \`vite\` (dev) \u2014 it is dropped from \`vite build\`.`;
6848
+ }
6849
+ function nextConfigManual(configFile) {
6850
+ return `Wrap your ${configFile} export with withIris (keeps SWC, dev-only):
6851
+
6852
+ import { withIris } from '@syrin/iris/next';
6853
+
6854
+ export default withIris(nextConfig);`;
6855
+ }
6856
+ function nextIrisDevFile(port) {
6857
+ return `'use client';
6858
+ import { useEffect } from 'react';
6859
+
6860
+ /** Dev-only: connect Iris + install the React adapter, after hydration. */
6861
+ export function IrisDev() {
6862
+ useEffect(() => {
6863
+ if (process.env.NODE_ENV !== 'development') return;
6864
+ void import('@syrin/iris').then(({ iris, install }) => {
6865
+ install();
6866
+ iris.connect(${connectArg(port)});
6867
+ });
6868
+ }, []);
6869
+ return null;
6870
+ }
6871
+ `;
6872
+ }
6873
+ var NEXT_LAYOUT_MANUAL = `Mount <IrisDev /> in your root layout (app/layout.tsx), dev-only:
6874
+
6875
+ import { IrisDev } from './iris-dev';
6876
+ // inside <body>:
6877
+ {process.env.NODE_ENV === 'development' ? <IrisDev /> : null}`;
6878
+ function htmlManual(port) {
6879
+ return `Add a dev-gated module script at app boot:
6880
+
6881
+ <script type="module">
6882
+ if (location.hostname === 'localhost') {
6883
+ const { iris, install } = await import('@syrin/iris');
6884
+ install();
6885
+ iris.connect(${connectArg(port)});
6886
+ }
6887
+ </script>`;
6888
+ }
6889
+ var NEXT_IRIS_DEV_PATH = "app/iris-dev.tsx";
6890
+
6891
+ // ../server/dist/init/plan.js
6892
+ var IRIS_PACKAGE2 = "@syrin/iris";
6893
+ var MCP_TARGET = "global (claude user scope)";
6894
+ var StepStatus = {
6895
+ APPLY: "apply",
6896
+ MANUAL: "manual",
6897
+ ALREADY: "already",
6898
+ SKIP: "skip"
6899
+ };
6900
+ var CLAUDE_MCP_TITLE = "MCP server (Claude, global)";
6901
+ var CURSOR_MCP_TITLE = "MCP server (Cursor, global)";
6902
+ function claudeMcpStep(input) {
6903
+ if (!input.claudeCli)
6904
+ return null;
6905
+ if (input.mcpExists) {
6906
+ return {
6907
+ title: CLAUDE_MCP_TITLE,
6908
+ target: MCP_TARGET,
6909
+ status: StepStatus.ALREADY,
6910
+ detail: "iris already registered (install once, used by every project)"
6911
+ };
6912
+ }
6913
+ const cmd = claudeAddCommand(input.options.port);
6914
+ return {
6915
+ title: CLAUDE_MCP_TITLE,
6916
+ target: MCP_TARGET,
6917
+ status: StepStatus.APPLY,
6918
+ detail: "register iris globally for all projects",
6919
+ exec: { command: cmd.command, args: cmd.args, fallback: cmd.display }
6920
+ };
6921
+ }
6922
+ function cursorMcpStep(input) {
6923
+ if (!input.cursorPresent)
6924
+ return null;
6925
+ const r = mergeCursorConfig(input.cursorConfig, input.options.port);
6926
+ if (r.status === CursorMergeStatus.ALREADY) {
6927
+ return {
6928
+ title: CURSOR_MCP_TITLE,
6929
+ target: input.cursorConfigPath,
6930
+ status: StepStatus.ALREADY,
6931
+ detail: "iris already in Cursor global config"
6932
+ };
6933
+ }
6934
+ if (r.status === CursorMergeStatus.MANUAL) {
6935
+ return {
6936
+ title: CURSOR_MCP_TITLE,
6937
+ target: input.cursorConfigPath,
6938
+ status: StepStatus.MANUAL,
6939
+ detail: `couldn't parse ${input.cursorConfigPath} \u2014 add this server by hand:
6940
+ "iris": ${JSON.stringify(cursorServerEntry(input.options.port))}`
6941
+ };
6942
+ }
6943
+ return {
6944
+ title: CURSOR_MCP_TITLE,
6945
+ target: input.cursorConfigPath,
6946
+ status: StepStatus.APPLY,
6947
+ detail: "register iris in Cursor global config",
6948
+ write: { path: input.cursorConfigPath, content: r.content }
6949
+ };
6950
+ }
6951
+ function mcpSteps(input) {
6952
+ if (!input.options.mcp) {
6953
+ return [
6954
+ {
6955
+ title: "MCP server (global)",
6956
+ target: MCP_TARGET,
6957
+ status: StepStatus.SKIP,
6958
+ detail: "--no-mcp"
6959
+ }
6960
+ ];
6961
+ }
6962
+ const steps = [claudeMcpStep(input), cursorMcpStep(input)].filter((s) => s !== null);
6963
+ if (steps.length > 0)
6964
+ return steps;
6965
+ return [
6966
+ {
6967
+ title: "MCP server (global)",
6968
+ target: MCP_TARGET,
6969
+ status: StepStatus.MANUAL,
6970
+ detail: mcpManual(input.options.port)
6971
+ }
6972
+ ];
6973
+ }
6974
+ function installStep(input) {
6975
+ const pm = input.detection.packageManager;
6976
+ const command = installCommand(pm, IRIS_PACKAGE2);
6977
+ if (!input.options.install) {
6978
+ return {
6979
+ title: "Install dependency",
6980
+ target: "package.json",
6981
+ status: StepStatus.MANUAL,
6982
+ detail: command
6983
+ };
6984
+ }
6985
+ const parts = installCommandParts(pm, IRIS_PACKAGE2);
6986
+ return {
6987
+ title: "Install dependency",
6988
+ target: "package.json",
6989
+ status: StepStatus.APPLY,
6990
+ detail: command,
6991
+ exec: { command: parts.command, args: parts.args, fallback: command }
6992
+ };
6993
+ }
6994
+ function viteSteps(input) {
6995
+ const cfg = input.viteConfig;
6996
+ const port = input.options.port;
6997
+ if (cfg === null) {
6998
+ return [
6999
+ {
7000
+ title: "Vite plugin",
7001
+ target: "vite.config",
7002
+ status: StepStatus.MANUAL,
7003
+ detail: viteManual(port)
7004
+ }
7005
+ ];
7006
+ }
7007
+ const patch = patchViteConfig(cfg.source, port);
7008
+ if (patch.kind === VitePatchKind.ALREADY) {
7009
+ return [
7010
+ {
7011
+ title: "Vite plugin",
7012
+ target: cfg.path,
7013
+ status: StepStatus.ALREADY,
7014
+ detail: "iris() already in plugins"
7015
+ }
7016
+ ];
7017
+ }
7018
+ if (patch.kind === VitePatchKind.MANUAL) {
7019
+ return [
7020
+ {
7021
+ title: "Vite plugin",
7022
+ target: cfg.path,
7023
+ status: StepStatus.MANUAL,
7024
+ detail: `${patch.reason}
7025
+
7026
+ ${viteManual(port)}`
7027
+ }
7028
+ ];
7029
+ }
7030
+ return [
7031
+ {
7032
+ title: "Vite plugin",
7033
+ target: cfg.path,
7034
+ status: StepStatus.APPLY,
7035
+ detail: "add iris() to plugins (also injects connect())",
7036
+ write: { path: cfg.path, content: patch.code }
7037
+ }
7038
+ ];
7039
+ }
7040
+ function nextSteps(input) {
7041
+ const configFile = input.nextConfigFile ?? "next.config.mjs";
7042
+ const devFile = input.nextIrisDevExists ? {
7043
+ title: "IrisDev component",
7044
+ target: NEXT_IRIS_DEV_PATH,
7045
+ status: StepStatus.ALREADY,
7046
+ detail: "file exists"
7047
+ } : {
7048
+ title: "IrisDev component",
7049
+ target: NEXT_IRIS_DEV_PATH,
7050
+ status: StepStatus.APPLY,
7051
+ detail: "create dev-only connect component",
7052
+ write: { path: NEXT_IRIS_DEV_PATH, content: nextIrisDevFile(input.options.port) }
7053
+ };
7054
+ return [
7055
+ devFile,
7056
+ {
7057
+ title: "Next config (withIris)",
7058
+ target: configFile,
7059
+ status: StepStatus.MANUAL,
7060
+ detail: nextConfigManual(configFile)
7061
+ },
7062
+ {
7063
+ title: "Mount IrisDev",
7064
+ target: "app/layout.tsx",
7065
+ status: StepStatus.MANUAL,
7066
+ detail: NEXT_LAYOUT_MANUAL
7067
+ }
7068
+ ];
7069
+ }
7070
+ function buildPlan(input) {
7071
+ const steps = [...mcpSteps(input), installStep(input)];
7072
+ if (input.detection.framework === Framework.VITE) {
7073
+ steps.push(...viteSteps(input));
7074
+ } else if (input.detection.framework === Framework.NEXT) {
7075
+ steps.push(...nextSteps(input));
7076
+ } else {
7077
+ steps.push({
7078
+ title: "Connect snippet",
7079
+ target: "index.html",
7080
+ status: StepStatus.MANUAL,
7081
+ detail: htmlManual(input.options.port)
7082
+ });
7083
+ }
7084
+ return { framework: input.detection.framework, steps };
7085
+ }
7086
+
7087
+ // ../server/dist/init/run.js
7088
+ var PACKAGE_JSON = "package.json";
7089
+ var NEXT_IRIS_DEV = "app/iris-dev.tsx";
7090
+ var VITE_CONFIG_CANDIDATES = [
7091
+ "vite.config.ts",
7092
+ "vite.config.js",
7093
+ "vite.config.mjs",
7094
+ "vite.config.mts"
7095
+ ];
7096
+ var NEXT_CONFIG_CANDIDATES = [
7097
+ "next.config.mjs",
7098
+ "next.config.js",
7099
+ "next.config.ts",
7100
+ "next.config.cjs"
7101
+ ];
7102
+ var STATUS_SYMBOL = {
7103
+ [StepStatus.APPLY]: "\u2713",
7104
+ [StepStatus.MANUAL]: "\u26A0",
7105
+ [StepStatus.ALREADY]: "\xB7",
7106
+ [StepStatus.SKIP]: "\u2013"
7107
+ };
7108
+ function firstPresent(files, candidates) {
7109
+ for (const c of candidates)
7110
+ if (files.has(c))
7111
+ return c;
7112
+ return null;
7113
+ }
7114
+ function gatherPlanInput(options, io, pkgRaw) {
7115
+ const pkg = JSON.parse(pkgRaw);
7116
+ const rootFiles = new Set(io.rootFiles());
7117
+ const detectInput = {
7118
+ pkg: typeof pkg === "object" && pkg !== null ? pkg : {},
7119
+ configFiles: rootFiles,
7120
+ lockfiles: rootFiles
7121
+ };
7122
+ const detection = detect(detectInput);
7123
+ const vitePath = firstPresent(rootFiles, VITE_CONFIG_CANDIDATES);
7124
+ const viteSource = vitePath === null ? null : io.readFile(vitePath);
7125
+ const viteConfig = vitePath !== null && viteSource !== null ? { path: vitePath, source: viteSource } : null;
7126
+ const availableProbe = claudeAvailableProbe();
7127
+ const claudeCli = options.mcp ? io.probe(availableProbe.command, availableProbe.args) : false;
7128
+ const existsProbe = claudeExistsProbe();
7129
+ const mcpExists = claudeCli ? io.probe(existsProbe.command, existsProbe.args) : false;
7130
+ const cursorDir = `${io.homeDir()}/${CURSOR_DIR_RELPATH}`;
7131
+ const cursorConfigPath = `${io.homeDir()}/${CURSOR_MCP_RELPATH}`;
7132
+ const cursorPresent = options.mcp && io.exists(cursorDir);
7133
+ const cursorConfig = cursorPresent ? io.readFile(cursorConfigPath) : null;
7134
+ return {
7135
+ detection,
7136
+ claudeCli,
7137
+ mcpExists,
7138
+ cursorPresent,
7139
+ cursorConfig,
7140
+ cursorConfigPath,
7141
+ viteConfig,
7142
+ nextConfigFile: firstPresent(rootFiles, NEXT_CONFIG_CANDIDATES),
7143
+ nextIrisDevExists: io.exists(NEXT_IRIS_DEV),
7144
+ options: { port: options.port, mcp: options.mcp, install: options.install }
7145
+ };
7146
+ }
7147
+ function restartHint(framework) {
7148
+ if (framework === Framework.NEXT)
7149
+ return 'Restart `next dev`, then ask your agent: "List Iris sessions".';
7150
+ if (framework === Framework.VITE)
7151
+ return 'Restart `vite`, then ask your agent: "List Iris sessions".';
7152
+ return 'Reload your app on localhost, then ask your agent: "List Iris sessions".';
7153
+ }
7154
+ function report(plan, dryRun, failed, io) {
7155
+ io.print(dryRun ? "iris init (dry run \u2014 no files written)" : "iris init");
7156
+ io.print("");
7157
+ let applied = 0;
7158
+ let manual = 0;
7159
+ for (const s of plan.steps) {
7160
+ const downgraded = failed.has(s.target);
7161
+ const status = downgraded ? StepStatus.MANUAL : s.status;
7162
+ const detail = downgraded && s.exec !== void 0 ? `step failed \u2014 run manually: ${s.exec.fallback}` : s.detail;
7163
+ io.print(` [${STATUS_SYMBOL[status]}] ${s.title} \u2192 ${s.target}`);
7164
+ if (status === StepStatus.APPLY)
7165
+ applied++;
7166
+ if (status === StepStatus.MANUAL) {
7167
+ manual++;
7168
+ for (const line of detail.split("\n"))
7169
+ io.print(` ${line}`);
7170
+ } else if (detail.length > 0) {
7171
+ io.print(` ${detail}`);
7172
+ }
7173
+ }
7174
+ io.print("");
7175
+ io.print(restartHint(plan.framework));
7176
+ return { ok: true, applied, manual };
7177
+ }
7178
+ function applyEffects(plan, io) {
7179
+ const failed = /* @__PURE__ */ new Set();
7180
+ for (const s of plan.steps) {
7181
+ if (s.status !== StepStatus.APPLY)
7182
+ continue;
7183
+ if (s.write !== void 0)
7184
+ io.writeFile(s.write.path, s.write.content);
7185
+ if (s.exec !== void 0 && !io.exec(s.exec.command, s.exec.args))
7186
+ failed.add(s.target);
7187
+ }
7188
+ return failed;
7189
+ }
7190
+ function runInit(options, io) {
7191
+ const pkgRaw = io.readFile(PACKAGE_JSON);
7192
+ if (pkgRaw === null) {
7193
+ io.print("No package.json found. Run `iris init` from your project root.");
7194
+ return { ok: false, applied: 0, manual: 0 };
7195
+ }
7196
+ const plan = buildPlan(gatherPlanInput(options, io, pkgRaw));
7197
+ const failed = options.dryRun ? /* @__PURE__ */ new Set() : applyEffects(plan, io);
7198
+ return report(plan, options.dryRun, failed, io);
7199
+ }
7200
+
7201
+ // ../server/dist/init/node-io.js
7202
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync, statSync } from "fs";
7203
+ import { join as join6, dirname as dirname2, isAbsolute } from "path";
7204
+ import { homedir as homedir3 } from "os";
7205
+ import { spawnSync } from "child_process";
7206
+ function buildNodeIo(cwd) {
7207
+ const abs = (rel) => isAbsolute(rel) ? rel : join6(cwd, rel);
7208
+ return {
7209
+ readFile(rel) {
7210
+ const path = abs(rel);
7211
+ if (!existsSync4(path))
7212
+ return null;
7213
+ return readFileSync3(path, "utf8");
7214
+ },
7215
+ writeFile(rel, content) {
7216
+ const path = abs(rel);
7217
+ mkdirSync3(dirname2(path), { recursive: true });
7218
+ writeFileSync3(path, content, "utf8");
7219
+ },
7220
+ exists(rel) {
7221
+ return existsSync4(abs(rel));
7222
+ },
7223
+ homeDir() {
7224
+ return homedir3();
7225
+ },
7226
+ rootFiles() {
7227
+ return readdirSync(cwd).filter((name) => {
7228
+ try {
7229
+ return statSync(join6(cwd, name)).isFile();
7230
+ } catch {
7231
+ return false;
7232
+ }
7233
+ });
7234
+ },
7235
+ exec(command, args) {
7236
+ const result = spawnSync(command, [...args], { cwd, stdio: "inherit", shell: true });
7237
+ return result.status === 0;
7238
+ },
7239
+ probe(command, args) {
7240
+ const result = spawnSync(command, [...args], { cwd, stdio: "ignore", shell: true });
7241
+ return result.status === 0;
7242
+ },
7243
+ print(line) {
7244
+ process.stdout.write(`${line}
7245
+ `);
7246
+ }
7247
+ };
7248
+ }
7249
+
5754
7250
  // ../server/dist/cli.js
5755
7251
  var CLI_USAGE = `usage:
7252
+ iris init [--yes] [--dry-run] [--port N] [--no-mcp] [--no-install] (wire Iris into the project in this directory)
5756
7253
  iris serve [--port N] [--drive <url>] [--headed]
5757
7254
  iris stop [--port N] [--quiet]
5758
7255
  iris status [--port N]
5759
7256
  iris drive <url> [--headed] (foreground mode \u2014 for debugging)
5760
7257
  iris mcp [--port N] [--drive <url>] [--headed] (MCP stdio proxy \u2014 auto-starts daemon if needed)`;
7258
+ var INIT_COMMAND = "init";
5761
7259
  var SERVE_COMMAND = "serve";
5762
7260
  var STOP_COMMAND = "stop";
5763
7261
  var STATUS_COMMAND = "status";
@@ -5765,9 +7263,13 @@ var DRIVE_COMMAND = "drive";
5765
7263
  var MCP_COMMAND = "mcp";
5766
7264
  var DAEMON_INNER_COMMAND = "_daemon";
5767
7265
  var HEADED_FLAG = "--headed";
5768
- var PORT_FLAG = "--port";
7266
+ var PORT_FLAG2 = "--port";
5769
7267
  var DRIVE_FLAG = "--drive";
5770
7268
  var QUIET_FLAG = "--quiet";
7269
+ var DRY_RUN_FLAG = "--dry-run";
7270
+ var YES_FLAG = "--yes";
7271
+ var NO_MCP_FLAG = "--no-mcp";
7272
+ var NO_INSTALL_FLAG = "--no-install";
5771
7273
  function parseServeFlags(args, defaultPort) {
5772
7274
  let port = defaultPort;
5773
7275
  let driveUrl;
@@ -5775,7 +7277,7 @@ function parseServeFlags(args, defaultPort) {
5775
7277
  let i = 0;
5776
7278
  while (i < args.length) {
5777
7279
  const arg = args[i];
5778
- if (arg === PORT_FLAG) {
7280
+ if (arg === PORT_FLAG2) {
5779
7281
  i++;
5780
7282
  const n = args[i];
5781
7283
  if (n === void 0)
@@ -5799,7 +7301,7 @@ function parseServeFlags(args, defaultPort) {
5799
7301
  return { kind: "ok", port, headless, ...driveUrl !== void 0 ? { driveUrl } : {} };
5800
7302
  }
5801
7303
  function parsePortFlag(args, defaultPort) {
5802
- const idx = args.indexOf(PORT_FLAG);
7304
+ const idx = args.indexOf(PORT_FLAG2);
5803
7305
  if (idx === -1)
5804
7306
  return defaultPort;
5805
7307
  const n = args[idx + 1];
@@ -5826,11 +7328,48 @@ function parseDriveSuffix(args, port) {
5826
7328
  return { kind: "error", message: CLI_USAGE };
5827
7329
  return { kind: "ok", port, driveUrl, headless };
5828
7330
  }
7331
+ function parseInitFlags(args) {
7332
+ let port;
7333
+ let mcp = true;
7334
+ let dryRun = false;
7335
+ let install = true;
7336
+ let i = 0;
7337
+ while (i < args.length) {
7338
+ const arg = args[i];
7339
+ if (arg === PORT_FLAG2) {
7340
+ i++;
7341
+ const n = args[i];
7342
+ if (n === void 0)
7343
+ return { kind: "error", message: CLI_USAGE };
7344
+ const parsed = parseInt(n, 10);
7345
+ if (isNaN(parsed))
7346
+ return { kind: "error", message: CLI_USAGE };
7347
+ port = parsed;
7348
+ } else if (arg === NO_MCP_FLAG) {
7349
+ mcp = false;
7350
+ } else if (arg === NO_INSTALL_FLAG) {
7351
+ install = false;
7352
+ } else if (arg === DRY_RUN_FLAG) {
7353
+ dryRun = true;
7354
+ } else if (arg === YES_FLAG) {
7355
+ } else {
7356
+ return { kind: "error", message: CLI_USAGE };
7357
+ }
7358
+ i++;
7359
+ }
7360
+ return { kind: "ok", port, mcp, dryRun, install };
7361
+ }
5829
7362
  function parseCliArgs(argv, defaultPort) {
5830
7363
  if (argv.length === 0)
5831
7364
  return { kind: "serve", port: defaultPort, headless: true };
5832
7365
  const [cmd, ...rest] = argv;
5833
7366
  switch (cmd) {
7367
+ case INIT_COMMAND: {
7368
+ const r = parseInitFlags(rest);
7369
+ if (r.kind === "error")
7370
+ return r;
7371
+ return { kind: "init", port: r.port, mcp: r.mcp, dryRun: r.dryRun, install: r.install };
7372
+ }
5834
7373
  case SERVE_COMMAND: {
5835
7374
  const r = parseServeFlags(rest, defaultPort);
5836
7375
  if (r.kind === "error")
@@ -5883,6 +7422,12 @@ function parseCliArgs(argv, defaultPort) {
5883
7422
  return { kind: "error", message: CLI_USAGE };
5884
7423
  }
5885
7424
  }
7425
+ function handleInit(parsed) {
7426
+ const cwd = process.cwd();
7427
+ const result = runInit({ cwd, port: parsed.port, mcp: parsed.mcp, dryRun: parsed.dryRun, install: parsed.install }, buildNodeIo(cwd));
7428
+ if (!result.ok)
7429
+ process.exit(1);
7430
+ }
5886
7431
  function handleServe(parsed) {
5887
7432
  if (isRunning(parsed.port)) {
5888
7433
  log("iris_daemon_already_running", { port: parsed.port });
@@ -5894,7 +7439,7 @@ function handleServe(parsed) {
5894
7439
  process.exit(1);
5895
7440
  return;
5896
7441
  }
5897
- const daemonArgs = [DAEMON_INNER_COMMAND, PORT_FLAG, String(parsed.port)];
7442
+ const daemonArgs = [DAEMON_INNER_COMMAND, PORT_FLAG2, String(parsed.port)];
5898
7443
  if (parsed.driveUrl !== void 0) {
5899
7444
  daemonArgs.push(DRIVE_FLAG, parsed.driveUrl);
5900
7445
  if (!parsed.headless)
@@ -5974,7 +7519,7 @@ function handleMcp(opts) {
5974
7519
  process.exit(1);
5975
7520
  return;
5976
7521
  }
5977
- const daemonArgs = [DAEMON_INNER_COMMAND, PORT_FLAG, String(port)];
7522
+ const daemonArgs = [DAEMON_INNER_COMMAND, PORT_FLAG2, String(port)];
5978
7523
  if (driveUrl !== void 0) {
5979
7524
  daemonArgs.push(DRIVE_FLAG, driveUrl);
5980
7525
  if (!headless)
@@ -6013,6 +7558,9 @@ function main() {
6013
7558
  log("iris_usage_error", { message: parsed.message });
6014
7559
  process.exit(1);
6015
7560
  break;
7561
+ case "init":
7562
+ handleInit(parsed);
7563
+ break;
6016
7564
  case "serve":
6017
7565
  handleServe(parsed);
6018
7566
  break;
@@ -6033,6 +7581,18 @@ function main() {
6033
7581
  break;
6034
7582
  }
6035
7583
  }
6036
- if (process.argv[1] !== void 0 && import.meta.url === pathToFileURL(process.argv[1]).href) {
7584
+ function isEntryPoint() {
7585
+ const argv1 = process.argv[1];
7586
+ if (argv1 === void 0)
7587
+ return false;
7588
+ if (import.meta.url === pathToFileURL(argv1).href)
7589
+ return true;
7590
+ try {
7591
+ return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
7592
+ } catch {
7593
+ return false;
7594
+ }
7595
+ }
7596
+ if (isEntryPoint()) {
6037
7597
  main();
6038
7598
  }