yichus 0.3.2 → 0.3.3

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/README.md CHANGED
@@ -6,7 +6,7 @@ shipped in `@yichus/api-mcp`; installing the release requires Node 20+,
6
6
  without Java, sbt, Nix, or a browser.
7
7
 
8
8
  ```sh
9
- npm install --save-dev yichus@0.3.2
9
+ npm install --save-dev yichus@0.3.3
10
10
  npx yichus tools
11
11
  npx yichus call api_guide
12
12
  ```
@@ -29,7 +29,7 @@ and the studio tells the agent what to do next after every change:
29
29
  ```json
30
30
  {
31
31
  "mcpServers": {
32
- "yichus": { "command": "npx", "args": ["-y", "yichus@0.3.2", "studio"] }
32
+ "yichus": { "command": "npx", "args": ["-y", "yichus@0.3.3", "studio"] }
33
33
  }
34
34
  }
35
35
  ```
@@ -219,6 +219,9 @@ different folders are named by the folders that tell them apart
219
219
  Use `--args options.json` (or `--args -` for stdin) for the remaining
220
220
  arguments. Values retain their JSON types. You can supply a complete
221
221
  `sources` array there instead of `--source`; mixing the two is rejected.
222
+ `--arg-file NAME=FILE` sets one argument to the JSON in a file, such as
223
+ a saved snapshot for `api_snapshot` (`--arg-file snapshot=ledger.snap.json`);
224
+ naming the same argument in `--args` too is rejected.
222
225
 
223
226
  ```json
224
227
  {"instances":"1"}
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../package.json","../src/node-version.ts","../src/cli.ts"],"sourcesContent":["{\n \"name\": \"yichus\",\n \"version\": \"0.3.2\",\n \"description\": \"Local Bosatsu compilation, Yichus analysis, and generated apps using the WebMCP engine\",\n \"license\": \"MIT\",\n \"homepage\": \"https://yich.us\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/snoble/yichus.git\",\n \"directory\": \"packages/yichus-cli\"\n },\n \"type\": \"module\",\n \"bin\": {\n \"yichus\": \"./dist/cli.js\"\n },\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"scripts\": {\n \"build\": \"tsup && node ./scripts/copy-studio-assets.mjs\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --coverage\",\n \"prepack\": \"npm run build\"\n },\n \"dependencies\": {\n \"@yichus/api-mcp\": \"workspace:*\",\n \"zod\": \"^4.3.6\",\n \"@modelcontextprotocol/sdk\": \"^1.26.0\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^22.0.0\",\n \"tsup\": \"^8.3.0\",\n \"typescript\": \"^5.6.0\",\n \"vitest\": \"^2.1.0\",\n \"@vitest/coverage-v8\": \"2.1.9\"\n },\n \"engines\": {\n \"node\": \">=20\"\n },\n \"publishConfig\": {\n \"access\": \"public\"\n }\n}\n","/**\n * The sentence that stops the command on a Node older than the package's\n * `engines.node` floor, or undefined when the running Node meets it.\n */\nexport function nodeVersionProblem(running: string, required: string): string | undefined {\n const floor = /^>=(\\d+)$/.exec(required);\n if (!floor) throw new Error(`engines.node must read >=N, not ${required}`);\n const needed = Number(floor[1]);\n if (Number(running.split('.')[0]) >= needed) return undefined;\n return `yichus needs Node ${needed} or newer; this is Node ${running}. Install Node ${needed} or newer and run the command again.`;\n}\n","#!/usr/bin/env node\n// The Node check runs before the rest of the command loads, so an older\n// Node gets a sentence naming what to install rather than an error from\n// inside the engine.\nimport { engines } from '../package.json';\nimport { nodeVersionProblem } from './node-version.js';\n\nconst problem = nodeVersionProblem(process.versions.node, engines.node);\nif (problem) {\n process.stderr.write(`${problem}\\n`);\n process.exitCode = 1;\n} else {\n await import('./main.js');\n}\n"],"mappings":";;;AAqCE,cAAW;AAAA,EACT,MAAQ;AACV;;;ACnCK,SAAS,mBAAmB,SAAiB,UAAsC;AACxF,QAAM,QAAQ,YAAY,KAAK,QAAQ;AACvC,MAAI,CAAC,MAAO,OAAM,IAAI,MAAM,mCAAmC,QAAQ,EAAE;AACzE,QAAM,SAAS,OAAO,MAAM,CAAC,CAAC;AAC9B,MAAI,OAAO,QAAQ,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK,OAAQ,QAAO;AACpD,SAAO,qBAAqB,MAAM,2BAA2B,OAAO,kBAAkB,MAAM;AAC9F;;;ACHA,IAAM,UAAU,mBAAmB,QAAQ,SAAS,MAAM,QAAQ,IAAI;AACtE,IAAI,SAAS;AACX,UAAQ,OAAO,MAAM,GAAG,OAAO;AAAA,CAAI;AACnC,UAAQ,WAAW;AACrB,OAAO;AACL,QAAM,OAAO,WAAW;AAC1B;","names":[]}
1
+ {"version":3,"sources":["../package.json","../src/node-version.ts","../src/cli.ts"],"sourcesContent":["{\n \"name\": \"yichus\",\n \"version\": \"0.3.3\",\n \"description\": \"Local Bosatsu compilation, Yichus analysis, and generated apps using the WebMCP engine\",\n \"license\": \"MIT\",\n \"homepage\": \"https://yich.us\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/snoble/yichus.git\",\n \"directory\": \"packages/yichus-cli\"\n },\n \"type\": \"module\",\n \"bin\": {\n \"yichus\": \"./dist/cli.js\"\n },\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"scripts\": {\n \"build\": \"tsup && node ./scripts/copy-studio-assets.mjs\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --coverage\",\n \"prepack\": \"npm run build\"\n },\n \"dependencies\": {\n \"@yichus/api-mcp\": \"workspace:*\",\n \"zod\": \"^4.3.6\",\n \"@modelcontextprotocol/sdk\": \"^1.26.0\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^22.0.0\",\n \"tsup\": \"^8.3.0\",\n \"typescript\": \"^5.6.0\",\n \"vitest\": \"^2.1.0\",\n \"@vitest/coverage-v8\": \"2.1.9\"\n },\n \"engines\": {\n \"node\": \">=20\"\n },\n \"publishConfig\": {\n \"access\": \"public\"\n }\n}\n","/**\n * The sentence that stops the command on a Node older than the package's\n * `engines.node` floor, or undefined when the running Node meets it.\n */\nexport function nodeVersionProblem(running: string, required: string): string | undefined {\n const floor = /^>=(\\d+)$/.exec(required);\n if (!floor) throw new Error(`engines.node must read >=N, not ${required}`);\n const needed = Number(floor[1]);\n if (Number(running.split('.')[0]) >= needed) return undefined;\n return `yichus needs Node ${needed} or newer; this is Node ${running}. Install Node ${needed} or newer and run the command again.`;\n}\n","#!/usr/bin/env node\n// The Node check runs before the rest of the command loads, so an older\n// Node gets a sentence naming what to install rather than an error from\n// inside the engine.\nimport { engines } from '../package.json';\nimport { nodeVersionProblem } from './node-version.js';\n\nconst problem = nodeVersionProblem(process.versions.node, engines.node);\nif (problem) {\n process.stderr.write(`${problem}\\n`);\n process.exitCode = 1;\n} else {\n await import('./main.js');\n}\n"],"mappings":";;;AAqCE,cAAW;AAAA,EACT,MAAQ;AACV;;;ACnCK,SAAS,mBAAmB,SAAiB,UAAsC;AACxF,QAAM,QAAQ,YAAY,KAAK,QAAQ;AACvC,MAAI,CAAC,MAAO,OAAM,IAAI,MAAM,mCAAmC,QAAQ,EAAE;AACzE,QAAM,SAAS,OAAO,MAAM,CAAC,CAAC;AAC9B,MAAI,OAAO,QAAQ,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK,OAAQ,QAAO;AACpD,SAAO,qBAAqB,MAAM,2BAA2B,OAAO,kBAAkB,MAAM;AAC9F;;;ACHA,IAAM,UAAU,mBAAmB,QAAQ,SAAS,MAAM,QAAQ,IAAI;AACtE,IAAI,SAAS;AACX,UAAQ,OAAO,MAAM,GAAG,OAAO;AAAA,CAAI;AACnC,UAAQ,WAAW;AACrB,OAAO;AACL,QAAM,OAAO,WAAW;AAC1B;","names":[]}
package/dist/main.js CHANGED
@@ -301,15 +301,16 @@ var VOCABULARY = {
301
301
  modelRing: "violet ring: the entry reaches the model <- entries[].model",
302
302
  reachedBy: "under each resource in the rack: how many routes reach it, the resource drawn at that share of the routes (never smaller than its name) <- entries[].db, model, net, beside.resources",
303
303
  netRing: "orange ring: the entry reaches the network <- entries[].net",
304
- guard: "red diamond on the path where the check happens, its name above, failure stem below <- entries[].guards, wrappers[].guards, entries[].passed[].guards, entries[].beside.guards",
304
+ guard: 'red diamond on the path where the route can refuse, the check named above it (the program\u2019s definition it calls, "<table> row found", "<table> row\u2019s <field>" for a branch on a field of a read row, or "a test in <definition>" for a comparison or library answer that definition branches on), failure stem below; a branch no path refuses on is not drawn here <- entries[].guards (structure.scenarios[]: branches where one arm ends only in refusals), wrappers[].guards, entries[].passed[].guards, entries[].beside.guards',
305
+ policy: "under the db, each table with its access policy as the access check proves it: own = each caller reads and writes only their own row; no row rule = the access check holds no rule over its rows, so who reads or writes which row is decided only by the code drawn on the routes; guarded = rows pass the named guard; role \u2026 only = only callers with that role reach its rows; unchecked = the access check gave this table no verdict; a policy ending \u201Cbroken\u201D is one the code breaks, and \u201Cnot proven\u201D one the check could not settle; counted when the tables are too many to list <- resources[kind=db].policies",
305
306
  hull: "translucent region in the hull band: paths merge into it, run one shared stretch, and split; everything inside it runs inside the wrapper; its right wall, drawn in the hull ink, is where the wrapper returns <- wrappers[]",
306
307
  socket: "broken-rim ring: a function the caller hands in runs here; its centre fills like a station <- entries[].passed[]",
307
308
  token: "small grey word under a path: the value the caller fixes for the wrapper <- entries[].policy",
308
309
  afterDot: "dot in the after band: a call the entry makes after its wrapper returns <- entries[].beside.calls",
309
310
  builder: "plus-disc under a station with the call that builds the handle there <- uses[how=local].builtBy",
310
311
  unknown: 'a ? in a circle on the rail, with "partly traced" beside it and a sentence on hover: the engine traced the handle only on some paths <- uses[how=mixed]',
311
- fork: "three prongs and a type: a choice keyed on a plain String or Int; under the mark that makes it, else on the quiet line <- keyed[]",
312
- bundle: 'one cable with how many routes it carries ("4 routes"): entries with the same wrapper and identical marks; its title names every route; opening it draws each one <- bundles[]',
312
+ fork: 'three prongs and a type: a choice keyed on a plain String or Int; under the mark that makes it (under a list of checks, named by the check it is inside: "can_see on String"), else on the quiet line <- keyed[]',
313
+ bundle: 'one cable with how many routes it carries ("4 routes"): entries with the same wrapper and identical marks; its title names every route: by the one part of the path that differs, among at most six ("/{tags, tasks}/sync"), or each by name when there are at most four; else a pattern with *; opening it draws each one <- bundles[]',
313
314
  anchor: "where the rail starts: platform when no definition of the program can make the handle; ? when the trace stops and the program could <- uses[].anchor",
314
315
  step: 'loop arc with a type on the quiet line: functions of the package take that type and return it; gathered, a count in words ("8 steps"), as are choices, db uses and generic parts <- steps[]',
315
316
  loop: "loop arc with package names: packages whose types lead back to themselves <- phases.cycles",
@@ -330,6 +331,7 @@ var VOCABULARY = {
330
331
  replyLoop: `one solid arc from a lane's foot back into the program, with one loop node naming what repeats the step within one call, and "each call of \u2026 runs it" naming the routes that run the rounds <- programs[].engines[].replies, loop, starters`,
331
332
  replaced: 'a check named "(in place of \u2026)": in the compared base record the same route had that other check at the same place, and it is on that route no longer; says nothing about whether the two do the same <- compare.replaced[]'
332
333
  },
334
+ names: "every check, call and table name is a label the code's author (often an AI) chose; the picture proves where a check runs, what it is given and what happens when it fails, never what it decides: a reader who doubts the author reads that rule itself.",
333
335
  rule: "every guard, wrapper, socket, fork, handle, station, step and resource the engine records is drawn at its fixed place, in every view; where nothing is drawn, the engine checked and found none."
334
336
  };
335
337
  var bare = (id) => String(id).split("::").pop();
@@ -449,9 +451,51 @@ function programsOf(st, layers = null) {
449
451
  });
450
452
  }
451
453
  var outsideResourcesOf = (steps) => uniq((steps || []).map((e) => e.resource).filter((r) => r === "model" || r === "net"));
454
+ var leavesUnder = (t) => t.kind === "leaf" ? [t] : t.arms.flatMap((a) => leavesUnder(a.next));
455
+ function refusingForks(tree) {
456
+ const out = [];
457
+ const go = (t) => {
458
+ if (!t || t.kind !== "node") return;
459
+ const refused = t.arms.map((a) => leavesUnder(a.next).every((l) => l.outcome.kind === "refused"));
460
+ if (t.concern?.layer !== "platform" && refused.some(Boolean) && !refused.every(Boolean)) out.push(t);
461
+ t.arms.forEach((a) => go(a.next));
462
+ };
463
+ go(tree);
464
+ return out;
465
+ }
466
+ var forkId = (n, own) => n.from.kind === "call" && own.has(n.from.callee) ? n.from.callee : n.id;
467
+ var forkParts = (n, own) => n.from.kind === "call" && own.has(n.from.callee) ? { definition: n.from.callee } : n.from.kind === "read" && n.from.table ? { table: n.from.table, ...n.from.path?.length ? { field: n.from.path.join(".") } : {} } : n.from.site ? { definition: n.from.site } : {};
468
+ var forkKey = (own) => (n) => n.from.kind === "call" && own.has(n.from.callee) ? n.from.callee : `${forkLabel(n, own)} ${n.from.site}`;
469
+ var forkLabel = (n, own) => n.from.kind === "call" ? own.has(n.from.callee) ? bare(n.from.callee) : `a test in ${bare(n.from.site)}` : n.from.kind === "read" && n.from.table ? n.from.path?.length ? `${n.from.table} row\u2019s ${n.from.path.join(".")}` : `${n.from.table} row found` : n.from.site ? `a test in ${bare(n.from.site)}` : "a computed branch";
470
+ var policyWord = (r) => {
471
+ const rule = policyRule(r);
472
+ if (rule === "unchecked") return rule;
473
+ return r.status === "violated" ? `${rule}, broken` : r.proven ? rule : `${rule}, not proven`;
474
+ };
475
+ var policyRule = (r) => {
476
+ const roles = () => r.roles.join(", "), guards = () => r.guards.join(", ");
477
+ switch (r.policyKind) {
478
+ case "OwnerScoped":
479
+ return "own";
480
+ case "Public":
481
+ return "no row rule";
482
+ case "Guarded":
483
+ return `guarded by ${guards()}`;
484
+ // with no guard, only the roles reach its rows
485
+ case "GuardedOrRoles":
486
+ return r.guards.length ? `guarded by ${guards()} or role ${roles()}` : `role ${roles()} only`;
487
+ case "PublicReadRoleWrite":
488
+ return `anyone reads; role ${roles()} writes`;
489
+ default:
490
+ return "unchecked";
491
+ }
492
+ };
452
493
  function buildSystemMap(engine, { program }) {
453
494
  for (const [k, v] of Object.entries(engine)) if (!v.ok) throw new Error(`${k}: ${v.error}`);
454
495
  const report = engine.report, st = engine.structure, abs = engine.abstractions.abstractions, layers = engine.layers.layers;
496
+ const whole = (s) => s.tree && !s.truncated && !s.unsupported;
497
+ const trees = new Map((st.scenarios || []).filter(whole).map((s) => [s.entry, refusingForks(s.tree)]));
498
+ const ownDefs = new Set(layers.packages.flatMap((p) => p.definitions.map((d) => `${p.name}::${d.name}`)));
455
499
  const routes = (report.surface.routes || []).slice().sort((a, b) => a.path.localeCompare(b.path));
456
500
  const engEntry = new Map(st.entries.map((e) => [e.entry, e]));
457
501
  const shapes = new Map(abs.routeShapes.map((r) => [r.path, r]));
@@ -466,11 +510,17 @@ function buildSystemMap(engine, { program }) {
466
510
  resources: outsideResourcesOf(inst.besideEffectSteps),
467
511
  from: "abstractions.instantiations[].beside*"
468
512
  });
513
+ const resultOf = (sig) => sig.includes("->") ? sig.slice(sig.lastIndexOf("->") + 2).trim() : null;
514
+ const returnsCallers = (g) => {
515
+ const result = resultOf(g.signature);
516
+ return result !== null && (/^[a-z]\w*$/.test(result) || (g.parameterGroups || []).flat().some((sl) => sl.role === "function" && sl.returnType === result));
517
+ };
518
+ const wraps = (g) => !!g && (/\bIO\[/.test(g.signature) || g.envelope.effects.length > 0 || returnsCallers(g) && (g.envelope.guards.length > 0 || g.envelope.dispatchesOn.length > 0));
469
519
  const entries = routes.map((r) => {
470
520
  const key = `route:${r.path}`;
471
521
  const ee = engEntry.get(key);
472
522
  const rs = shapes.get(r.path);
473
- const wrapper = rs.shapedByGenerator;
523
+ const wrapper = wraps(gens.get(rs.shapedByGenerator)) ? rs.shapedByGenerator : null;
474
524
  const inst = wrapper ? insts.find((i) => i.callerId === rs.handlerId && i.generatorId === wrapper) : null;
475
525
  const passed = (inst ? inst.slotBindings : []).filter((sb) => ["function", "effect"].includes(sb.slotRole)).map((sb) => ({
476
526
  slot: sb.slotName,
@@ -507,8 +557,9 @@ function buildSystemMap(engine, { program }) {
507
557
  model: ee.resources.includes("model"),
508
558
  net: ee.resources.includes("net"),
509
559
  writes: reach.some((e) => isWrite(e.op)),
510
- guards: rs.handlerGuards.map(bare),
511
- guardIds: rs.handlerGuards,
560
+ // every branch of the entry's own where a path refuses (a wrapper's are
561
+ // its own, and a function handed to it keeps its own slot)
562
+ ...trees.has(key) ? ((forks) => ({ guards: forks.map((n) => forkLabel(n, ownDefs)), guardIds: forks.map((n) => forkId(n, ownDefs)), guardParts: forks.map((n) => forkParts(n, ownDefs)) }))(uniqBy(trees.get(key).filter((n) => n.concern?.layer !== "cross" && !(inst ? inst.slotBindings : []).some((sb) => (sb.guards || []).includes(forkId(n, ownDefs)))), forkKey(ownDefs))) : { guards: rs.handlerGuards.map(bare), guardIds: rs.handlerGuards },
512
563
  wrapper,
513
564
  via: rs.viaSpecialization ? bare(rs.viaSpecialization) : null,
514
565
  policy: (inst ? inst.slotBindings : []).filter((s) => s.slotRole === "semantic-object").map((s) => s.value).filter(Boolean),
@@ -598,7 +649,12 @@ function buildSystemMap(engine, { program }) {
598
649
  }
599
650
  const present = uniq(st.ioKinds.flatMap((b) => b.resources));
600
651
  const tables = uniq(entries.flatMap((e) => [...e.reach, ...e.beside ? e.beside.effects : []].map((r) => r.table))).sort();
601
- const resources = ["db", "model", "net"].filter((k) => present.includes(k)).map((k) => ({ kind: k, ...k === "db" ? { tables } : {}, from: "structure.ioKinds[].resources" }));
652
+ const policies = engine.access ? Object.fromEntries(engine.access.report.resources.filter((r) => tables.includes(r.resource)).map((r) => [r.resource, policyWord(r)])) : null;
653
+ const resources = ["db", "model", "net"].filter((k) => present.includes(k)).map((k) => ({
654
+ kind: k,
655
+ ...k === "db" ? { tables, ...policies ? { policies, policyFrom: "access.report.resources[].policyKind, guards, roles" } : {} } : {},
656
+ from: "structure.ioKinds[].resources"
657
+ }));
602
658
  const ghostResources = ["db", "model", "net"].filter((k) => !present.includes(k)).map((k) => ({ kind: k, from: "structure.ioKinds[] checked: no site of this kind" }));
603
659
  const transports = uniq(st.entries.map((e) => e.transport));
604
660
  const callers = [];
@@ -712,13 +768,22 @@ function bundlesOf(entries, uses, keyed) {
712
768
  }
713
769
  function pathPattern(paths) {
714
770
  if (paths.length === 1) return paths[0];
771
+ const sorted = [...paths].sort();
715
772
  const segs = paths.map((p) => p.split("/"));
716
773
  const n = Math.min(...segs.map((s) => s.length));
717
774
  const out = [];
775
+ const vary = [];
718
776
  for (let i = 0; i < n; i++) {
719
777
  const v = uniq(segs.map((s) => s[i]));
720
778
  out.push(v.length === 1 ? v[0] : "*");
779
+ if (v.length > 1) vary.push([i, v]);
780
+ }
781
+ if (segs.every((s) => s.length === n) && vary.length === 1 && vary[0][1].length <= 6) {
782
+ const [[i, v]] = vary;
783
+ out[i] = `{${[...v].sort().join(", ")}}`;
721
784
  }
785
+ if (segs.every((s) => s.length === n) && vary.length <= 1) return out.join("/");
786
+ if (paths.length <= 4) return sorted.join(", ");
722
787
  if (segs.every((s) => s.length === n)) return out.join("/");
723
788
  let k = 0;
724
789
  while (k < n && out[k] !== "*") k++;
@@ -750,6 +815,7 @@ var STRUCTURAL = /* @__PURE__ */ new Set([
750
815
  "vars",
751
816
  "id"
752
817
  ]);
818
+ var DESCRIBED = /* @__PURE__ */ new Set(["guards", "policies", "policyFrom"]);
753
819
  var WHAT = {
754
820
  path: "an action the app answers",
755
821
  routes: "an action the app answers",
@@ -758,7 +824,8 @@ var WHAT = {
758
824
  roles: "a permission an action requires, held by a person's role or by the app's own server",
759
825
  policy: "a setting an action passes to shared code",
760
826
  tokens: "a setting an action passes to shared code",
761
- fields: "an operation a pluggable part offers"
827
+ fields: "an operation a pluggable part offers",
828
+ field: "a field of a kind of record the app keeps"
762
829
  };
763
830
  var PACKAGE = "a section of the program";
764
831
  var DEFINITION = "a piece of the program";
@@ -781,7 +848,7 @@ var leaves = (record, visit) => {
781
848
  if (typeof v === "string") visit(v, key);
782
849
  else if (Array.isArray(v)) v.forEach((x) => walk3(x, key));
783
850
  else if (v && typeof v === "object") {
784
- for (const [k, x] of Object.entries(v)) if (!skipped(k, x)) walk3(x, k);
851
+ for (const [k, x] of Object.entries(v)) if (!skipped(k, x) && !DESCRIBED.has(k)) walk3(x, k);
785
852
  }
786
853
  };
787
854
  walk3(record, null);
@@ -799,6 +866,7 @@ function nameableParts(map, own) {
799
866
  add(id.slice(i + 2), DEFINITION);
800
867
  };
801
868
  leaves(map, (s, key) => {
869
+ if (key === "guardIds" && s.startsWith("route:")) return;
802
870
  if (s.startsWith("route:")) return add(s.slice(6), WHAT.path);
803
871
  if (TYPES.has(key)) {
804
872
  for (const w of s.match(WORD) ?? []) if (w.includes("::")) idParts(w);
@@ -863,19 +931,22 @@ var VOCABULARY2 = {
863
931
  version: MARKS_VERSION,
864
932
  added: ["outcome"],
865
933
  marks: {
866
- entryHead: "arrowhead where a request comes in; the rack above the tree has one per described entry, then one dashed line counting the entries no scenario describes, which opens to draw each of them struck <- entries[], ghosts[]",
934
+ entryHead: 'arrowhead where a request comes in; the rack above the tree has one per described entry, then one dashed line counting the entries no scenario describes, which opens to draw each of them struck (when no entry is described, that one line is all the rack draws, and it only counts them: "8 entries"; a scenario is a requirement someone wrote, so its absence says nothing about tests); under the head, "same steps as \u2026" names the entries whose trees take the same steps on their own tables and checks <- entries[], ghosts[], entries[].sameShape',
867
935
  guard: "red diamond: the declared sign-in or role check, the first branch of every entry that has one; its failure stem ends in a refusal <- tree node with from.kind=guard",
868
- fork: "three prongs: a branch the code takes on the request, the caller, a value read from a table, or a call result; the arm each line takes is written where it leaves <- tree nodes",
869
- station: "ring on a line: an effect on that path, its verb and table beside it; filled = it writes <- arms[].effects, entry.effects",
936
+ fork: 'three prongs: a branch the code takes on the request, the caller, a value read from a table, or a call result, with "\u2190 request", "\u2190 caller" or "\u2190 <table> as read" saying where what it tests comes from; the arm each line takes is written where it leaves <- tree nodes, nodes[].from.sources',
937
+ docs: "under the names line, in italic, the comment the author wrote above each named check, marked unchecked: what the author says it decides, which the engine does not confirm <- docs (structure.docs)",
938
+ rows: `under the tree, for each table its stations touch (and those of the entries named in "same steps as"), what one row holds, by its checked type: "posts holds List[Post(id: String, \u2026, edited: Bool)]"; a field that is a record gives only its type's name, a choice of bare cases its cases ("visibility: Open | Members") <- rows (structure.rows)`,
939
+ station: "ring on a line: an effect on that path, its verb and table beside it; filled = it writes; mint = the platform hands out a fresh row id. Under it, in grey, the row it touches (the caller's row; the row named by a request field; one fixed row; a new row; every row, and the guard that keeps them) and, for a write, whether the row is replaced whole or updated from what it read, then where what it writes comes from, field by field (\u2190 caller, \u2190 request.<field>, \u2190 a literal, \u2190 new id, \u2190 a call and what it was computed from) <- arms[].effects[].key, value, kept (access report.findings[operation=keep_visible].provenBy)",
870
940
  modelRing: "violet ring: the path reaches the model <- effects[resource=model]",
871
941
  netRing: "orange ring: the path reaches the network <- effects[resource=net]",
872
942
  outcome: "the end of a path: a cap with what the request gets back (a constructor, or a refusal with its status and code); red when a claim fails there or a check reports a witness that ends there, green when every obligation that failed there at the base is checked here and holds, hollow and dashed (unchecked) when a claim there is inconclusive or an obligation that failed at the base is no longer checked, grey otherwise <- leaves",
873
943
  unchecked: "a hollow dashed cap and a ? tick: the engine cannot say whether the claim holds on this path (a step it could not open, a table it could not name, an answer it could not decide), or what failed here at the base is not checked here; never counted as holding <- claims[verdict=inconclusive], leaves[].gaps, diff.unresolved",
874
944
  struck: "a struck ghost: something checked and found absent (an entry nobody described, a write a claim says a path must not make) <- ghosts[], claims holding with not",
875
945
  bundle: "a cable with a count: a subtree too large for this view; opening it draws that part with a breadcrumb <- derived from the tree",
876
- compared: 'a line under the rack, always there: "compared with <base> \xB7 N fixed \xB7 M still failing", or "no base compared" in faint text <- diff',
946
+ compared: 'a line under the rack when a base was compared: "compared with <base> \xB7 N fixed \xB7 M still failing"; with no base, no line <- diff',
877
947
  hull: `a shaded box on the line: a step that is not the handler's own work, folded (a wrapper's guard or short-circuit, the platform's declared check or the transaction it runs the plan in, work beside the wrapper, a wrapper's work after the handler); it keeps what the step answers instead of running the handler: the effects it makes before its test above the arms, then one row per arm read as a side stub (the arm, the effects that arm makes, its struck writes, what the request gets, ticks), then "continue"; opening it draws the step's own tree; drawn inline, the same hull is a light band behind the step and its exits, with its role and where it comes from above its test, so either way it reads as not the handler's own work <- tree nodes[concern.role], entries[].steps`
878
948
  },
949
+ names: "every check, call and table name is a label the code's author (often an AI) chose; the picture proves where a check runs, what it is given and what happens when it fails, never what it decides: a reader who doubts the author reads that rule itself.",
879
950
  rule: "every entry, branch, arm, effect and outcome the engine computes is drawn, a folded step's inside its box and an undescribed entry behind the rack's one line; the author's words appear only on ids the engine computed, in italic, with the code name beside them.",
880
951
  concerns: "where each branch and effect comes from in value flow (concern.layer, effects[].layer): core = the handler's own work, a function it hands its wrapper or its body when it has none; cross = the wrapper its route shape names, shared with other entries; beside = the handler's code outside the wrapper call; platform = the route's declared authority, or the transaction the platform runs a plan in (its arms that give up or cannot confirm skip the handler's work). Roles come from which arms reach the handler, never from names."
881
952
  };
@@ -953,6 +1024,11 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
953
1024
  const claims = st.claims || { scenarios: [], undescribed: (st.scenarios || []).map((s) => s.entry) };
954
1025
  const byEntry = new Map(claims.scenarios.filter((s) => s.status === "matched").map((s) => [s.entry, s]));
955
1026
  const routes = new Map((st.entries || []).map((e) => [e.entry, e]));
1027
+ const kept = new Map((engine.access?.report?.findings || []).filter((f) => f.operation === "keep_visible" && f.kind === "GuardProven" && f.status === "holds").map((f) => [`${f.package}::${f.binding}\0${f.resource}`, `${(f.provenBy || []).join(" and ")} passes for the caller and the row`]));
1028
+ const withKept = (es) => es.map((e) => {
1029
+ const by = e.kind === "DbQuery" && e.tables.length === 1 ? kept.get(`${e.site}\0${e.tables[0]}`) : null;
1030
+ return by ? { ...e, kept: by } : e;
1031
+ });
956
1032
  const entries = st.scenarios.map((s) => {
957
1033
  const meta = routes.get(s.entry) || {};
958
1034
  const sc = byEntry.get(s.entry);
@@ -960,7 +1036,7 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
960
1036
  const copy = (t) => {
961
1037
  if (!t) return null;
962
1038
  if (t.kind === "leaf") return { ...t, words: names.get(t.id) || null, claims: [], witnesses: [], status: "grey" };
963
- return { ...t, words: names.get(t.id) || null, arms: t.arms.map((a) => ({ ...a, words: names.get(a.id) || null, next: copy(a.next) })) };
1039
+ return { ...t, words: names.get(t.id) || null, arms: t.arms.map((a) => ({ ...a, effects: withKept(a.effects), words: names.get(a.id) || null, next: copy(a.next) })) };
964
1040
  };
965
1041
  return {
966
1042
  entry: s.entry,
@@ -970,7 +1046,7 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
970
1046
  transport: meta.transport || null,
971
1047
  title: sc?.title || null,
972
1048
  described: !!sc,
973
- effects: s.effects,
1049
+ effects: withKept(s.effects),
974
1050
  tree: copy(s.tree),
975
1051
  leafCount: Number(s.leafCount),
976
1052
  truncated: s.truncated,
@@ -980,6 +1056,17 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
980
1056
  from: "structure.scenarios[]"
981
1057
  };
982
1058
  });
1059
+ const shapeOf = (e) => {
1060
+ const eff = (x) => `${x.op}${x.key ? `@${x.key.kind}` : ""}${x.value ? `<${x.value.kind}` : ""}${x.kept ? "|kept" : ""}`;
1061
+ const go = (t) => !t ? "" : t.kind === "leaf" ? `(${t.outcome.kind}:${(t.outcome.statuses || (t.outcome.status ? [t.outcome.status] : [])).join("/")})` : `[${t.from.kind}${t.arms.map((a) => `{${a.effects.map(eff).join(",")}${go(a.next)}}`).join("")}]`;
1062
+ return `${(e.effects || []).map(eff).join(",")}${go(e.tree)}`;
1063
+ };
1064
+ const byShape = /* @__PURE__ */ new Map();
1065
+ for (const e of entries) byShape.set(shapeOf(e), [...byShape.get(shapeOf(e)) || [], e.entry]);
1066
+ for (const e of entries) {
1067
+ const same = byShape.get(shapeOf(e)).filter((x) => x !== e.entry);
1068
+ if (same.length) e.sameShape = { entries: same, from: "derived: entries[] trees equal in node kinds, arms, effect verbs, row keys and value kinds, and outcomes" };
1069
+ }
983
1070
  const entryOf = new Map(entries.map((e) => [e.entry, e]));
984
1071
  const leafById = new Map(entries.flatMap((e) => leavesOf(e.tree).map((l) => [l.id, l])));
985
1072
  const checked = [], notes = [], refused = [], mismatches = [];
@@ -1085,6 +1172,18 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
1085
1172
  refused,
1086
1173
  mismatches,
1087
1174
  diff,
1175
+ // the comment above each definition, as its author wrote it (structure.docs); never checked
1176
+ docs: ((callees) => Object.fromEntries(Object.entries(st.docs || {}).filter(([id]) => callees.has(id))))(
1177
+ new Set(entries.flatMap((e) => {
1178
+ const ids = [];
1179
+ walk(e.tree, (n) => {
1180
+ if (n.kind === "node" && n.from?.kind === "call") ids.push(n.from.callee);
1181
+ });
1182
+ return ids;
1183
+ }))
1184
+ ),
1185
+ // what each table holds, by its checked row type (structure.rows)
1186
+ rows: Object.fromEntries(Object.entries(st.rows || {}).map(([table, schema]) => [table, rowShape(schema)])),
1088
1187
  counts: {
1089
1188
  entries: entries.length,
1090
1189
  described: entries.length - ghosts.length,
@@ -1098,6 +1197,14 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
1098
1197
  evidence: { tools: ["api_organize:structure", ...engine.access ? ["api_access_check"] : []], derived: ["diff: base red leaves matched by id, their failed obligations by identity"] }
1099
1198
  };
1100
1199
  }
1200
+ var typeName = (s) => s.type ? s.type.split("::").pop() : null;
1201
+ var fieldType = (s) => ({ string: "String", int: "Int", float64: "Float64", bool: "Bool", unit: "Unit", json: "Json" })[s.kind] || (s.kind === "list" ? `List[${fieldType(s.element)}]` : s.kind === "unsupported" ? s.rendered : s.kind === "union" && s.variants.length && s.variants.every((v) => !v.fields.length) ? s.variants.map((v) => v.tag).join(" | ") : typeName(s));
1202
+ function rowShape(s) {
1203
+ if (s.kind === "list") return `List[${rowShape(s.element)}]`;
1204
+ if (s.kind === "struct") return `${typeName(s)}(${s.fields.map((f) => `${f.name}: ${fieldType(f)}`).join(", ")})`;
1205
+ if (s.kind === "union") return s.variants.map((v) => v.fields.length ? `${v.tag}(${v.fields.map((f) => `${f.name}: ${fieldType(f)}`).join(", ")})` : v.tag).join(" | ");
1206
+ return fieldType(s);
1207
+ }
1101
1208
  function stationLabel(e) {
1102
1209
  const verb = e.op === "mint id" ? "mint" : e.op;
1103
1210
  if (e.tables.length) return `${verb} \xB7 ${e.tables.join(", ")}`;
@@ -2004,9 +2111,10 @@ async function direct({ root, engine, requirements, withMap = true, previous = n
2004
2111
  base.lawsError = checked.error;
2005
2112
  base.rules = (requirements.rules ?? []).map((r) => judgeRule(r, base.laws));
2006
2113
  base.hasActions = report.surface.routes.length > 0;
2114
+ let access = null;
2007
2115
  if (base.hasActions || requirements.scenarios?.length) {
2008
2116
  try {
2009
- const access = await call(engine, "api_access_check", { sources });
2117
+ access = await call(engine, "api_access_check", { sources });
2010
2118
  const record = WhatHappens.parse(buildWhatHappens({ structure: structureRaw, access }, { program: base.project }));
2011
2119
  base.whatHappens = base.hasActions ? record : { ...record, entries: [], ghosts: [], claims: [], witnesses: [], refused: [], notes: [] };
2012
2120
  } catch (error) {
@@ -2023,7 +2131,7 @@ async function direct({ root, engine, requirements, withMap = true, previous = n
2023
2131
  await call(engine, "api_organize", { sources, lens: "abstractions" }),
2024
2132
  await call(engine, "api_organize", { sources, lens: "layers" })
2025
2133
  ];
2026
- base.map = buildSystemMap({ report, structure: structureRaw, abstractions, layers }, { program: base.project });
2134
+ base.map = buildSystemMap({ report, structure: structureRaw, abstractions, layers, ...access ? { access } : {} }, { program: base.project });
2027
2135
  base.layers = layers;
2028
2136
  } catch (error) {
2029
2137
  base.mapError = error instanceof Error ? error.message : String(error);
@@ -2419,7 +2527,8 @@ async function refresh() {
2419
2527
  const { state, requirements } = await (await fetch('/state')).json();
2420
2528
  const savedOf = id => requirements.answers.find(a => a.question === id);
2421
2529
  if (requirements.description) document.getElementById('description').textContent = requirements.description;
2422
- if (!state) return;
2530
+ // Before the first check there is nothing drawn, but the person may already be looking.
2531
+ if (!state) { viewing(null); return; }
2423
2532
  document.getElementById('project').textContent = ': ' + state.project;
2424
2533
  document.getElementById('updated').textContent = 'Last checked ' + new Date(state.updatedAt).toLocaleTimeString() + '.';
2425
2534
  document.getElementById('summary').textContent = state.summary;
@@ -2488,7 +2597,14 @@ var Report2 = z9.object({
2488
2597
  /** The drawing the anchors belong to (person.ts), or null before one is drawn. */
2489
2598
  drawing: z9.string().max(64).nullable()
2490
2599
  }).strict();
2491
- var Viewing = Report2.extend({ schemaVersion: z9.literal("yichus-viewing-1"), at: z9.string() }).strict();
2600
+ var Viewing = Report2.extend({
2601
+ schemaVersion: z9.literal("yichus-viewing-1"),
2602
+ at: z9.string(),
2603
+ /** The page server the report came through: only it can tell that its page went. */
2604
+ server: z9.string().optional(),
2605
+ /** When the last copy of the page open on that server went away, until a page connects again. */
2606
+ closed: z9.string().optional()
2607
+ }).strict();
2492
2608
  var viewingPath = (root) => path5.join(studioDir(root), "viewing.json");
2493
2609
  function loadViewing(root) {
2494
2610
  const file = viewingPath(root);
@@ -2497,14 +2613,28 @@ function loadViewing(root) {
2497
2613
  if (!parsed.success) throw new Error(`${file} is not a yichus-viewing-1 file: ${z9.prettifyError(parsed.error)}`);
2498
2614
  return parsed.data;
2499
2615
  }
2500
- function recordViewing(root, report, now) {
2501
- const parsed = Report2.safeParse(report);
2502
- if (!parsed.success) return { ok: false, error: `The page's report is not one this studio reads. ${z9.prettifyError(parsed.error)}` };
2616
+ function writeViewing(root, viewing) {
2503
2617
  ensureStudioDir(root);
2504
2618
  const file = viewingPath(root), written = `${file}.${process.pid}.tmp`;
2505
- fs5.writeFileSync(written, `${JSON.stringify(Viewing.parse({ schemaVersion: "yichus-viewing-1", at: now.toISOString(), ...parsed.data }), null, 2)}
2619
+ fs5.writeFileSync(written, `${JSON.stringify(Viewing.parse(viewing), null, 2)}
2506
2620
  `);
2507
2621
  fs5.renameSync(written, file);
2622
+ }
2623
+ function recordClosed(root, server, now) {
2624
+ const last = loadViewing(root);
2625
+ if (last?.server === server) writeViewing(root, { ...last, closed: now.toISOString() });
2626
+ }
2627
+ function recordOpened(root, server) {
2628
+ const last = loadViewing(root);
2629
+ if (last?.server === server && last.closed) {
2630
+ const { closed: _, ...open } = last;
2631
+ writeViewing(root, open);
2632
+ }
2633
+ }
2634
+ function recordViewing(root, server, report, now) {
2635
+ const parsed = Report2.safeParse(report);
2636
+ if (!parsed.success) return { ok: false, error: `The page's report is not one this studio reads. ${z9.prettifyError(parsed.error)}` };
2637
+ writeViewing(root, { schemaVersion: "yichus-viewing-1", at: now.toISOString(), server, ...parsed.data });
2508
2638
  return { ok: true };
2509
2639
  }
2510
2640
 
@@ -2611,7 +2741,10 @@ function pageText(root, pageUrl, now, surface) {
2611
2741
  `;
2612
2742
  }
2613
2743
  function inViewLines(viewing, codeAt, said2, now, pageUrl) {
2614
- if (!viewing) return [`The page has not said what is on screen: it may not be open. ${pageUrl ? `Give the person its link: ${pageUrl}` : "The page is not running: start `yichus studio --page` in the background in this folder, then run this again for its link."}`];
2744
+ const link = pageUrl ? `Give the person its link: ${pageUrl}` : "The page is not running: start `yichus studio --page` in the background in this folder, then run this again for its link.";
2745
+ if (!viewing) return [`The page has not said what is on screen: it may not be open. ${link}`];
2746
+ if (viewing.closed) return [`The person closed the page ${ago(viewing.closed, now)}, so it is not open now. ${pageUrl ? `If you want them to look at it, give them its link again: ${pageUrl}` : link}`];
2747
+ if (!pageUrl) return [`The page is not open: its server stopped after it last said what was on screen. ${link}`];
2615
2748
  const parts = (anchors) => !viewing.current ? [] : anchors.flatMap((a) => codeAt.has(a) ? [`[${codeAt.get(a)}] ${said2.get(codeAt.get(a))}`] : []);
2616
2749
  const list = (what, items) => items.length ? [`${what}:`, ...items.map((i) => ` ${i}`)] : [];
2617
2750
  const { touched } = viewing;
@@ -2953,7 +3086,7 @@ function pageNote(pageUrl, say2) {
2953
3086
  ].join("\n");
2954
3087
  }
2955
3088
  function createStudioMcpServer({ root, engine, catalog, pageUrl, now = () => /* @__PURE__ */ new Date(), locate = builtCopy }) {
2956
- const server = new Server({ name: "yichus-studio", version: "0.3.2" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
3089
+ const server = new Server({ name: "yichus-studio", version: "0.3.3" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
2957
3090
  const active = /* @__PURE__ */ new Set();
2958
3091
  const track = (task) => {
2959
3092
  active.add(task);
@@ -3222,7 +3355,7 @@ ${said2}
3222
3355
  var HELP = `yichus \u2014 the Yichus WebMCP analysis engine, locally
3223
3356
 
3224
3357
  yichus tools
3225
- yichus call TOOL [--args JSON_FILE|-] [--source FILE ... | --project DIR]
3358
+ yichus call TOOL [--args JSON_FILE|-] [--arg-file NAME=JSON_FILE ...] [--source FILE ... | --project DIR]
3226
3359
  yichus verify FILE ... [--instances 1|many] [--block-on-warning ID ...]
3227
3360
  yichus lean FILE ... --lean-wasm-root DIRECTORY [--timeout-ms N]
3228
3361
  ${STUDIO_HELP}
@@ -3234,6 +3367,8 @@ Options:
3234
3367
  (default the current folder), as the studio reads
3235
3368
  it (call only).
3236
3369
  --args FILE|- Tool arguments as a JSON object; - reads stdin.
3370
+ --arg-file NAME=FILE Set one argument to the JSON in FILE, such as a
3371
+ saved snapshot (repeatable; call only).
3237
3372
  --block-on-warning ID A property id whose warning blocks the verdict, as a
3238
3373
  violation would (repeatable; all names every property;
3239
3374
  verify only). An id the verdict lacks exits 2.
@@ -3297,6 +3432,7 @@ var objectJson = (raw, label2) => objectValue(JSON.parse(raw), label2);
3297
3432
  var FLAGS = {
3298
3433
  source: { type: "string", multiple: true },
3299
3434
  args: { type: "string" },
3435
+ "arg-file": { type: "string", multiple: true },
3300
3436
  project: { type: "string" },
3301
3437
  instances: { type: "string" },
3302
3438
  "block-on-warning": { type: "string", multiple: true },
@@ -3317,7 +3453,7 @@ function parse(argv2) {
3317
3453
  if (command !== "tools" && command !== "call" && command !== "verify" && command !== "lean") {
3318
3454
  throw new Error("Expected tools, call TOOL, verify FILE, or lean FILE; use --help for usage.");
3319
3455
  }
3320
- const opts = { command, sources: [], expects: [], blockOnWarnings: values["block-on-warning"] ?? [] };
3456
+ const opts = { command, sources: [], argFiles: [], expects: [], blockOnWarnings: values["block-on-warning"] ?? [] };
3321
3457
  if (command === "call") {
3322
3458
  opts.tool = rest.shift();
3323
3459
  if (!opts.tool) throw new Error("call requires a tool name");
@@ -3325,6 +3461,14 @@ function parse(argv2) {
3325
3461
  if ((command === "tools" || command === "call") && rest.length) throw new Error(`Unexpected argument: ${rest[0]}`);
3326
3462
  opts.sources = [...rest, ...values.source ?? []];
3327
3463
  opts.args = values.args;
3464
+ for (const value of values["arg-file"] ?? []) {
3465
+ const equal = value.indexOf("=");
3466
+ if (equal <= 0 || equal === value.length - 1) throw new Error("--arg-file requires NAME=FILE");
3467
+ const name = value.slice(0, equal);
3468
+ if (opts.argFiles.some((a) => a.name === name)) throw new Error(`--arg-file names ${name} twice`);
3469
+ opts.argFiles.push({ name, file: value.slice(equal + 1) });
3470
+ }
3471
+ if (opts.argFiles.length && command !== "call") throw new Error("--arg-file is for call");
3328
3472
  opts.project = values.project;
3329
3473
  if (opts.project !== void 0 && command !== "call") throw new Error("--project is for call");
3330
3474
  if (opts.project !== void 0 && opts.sources.length) throw new Error("Use either --source files or --project, not both");
@@ -3430,6 +3574,15 @@ function sourceNames(paths) {
3430
3574
  }
3431
3575
  async function argumentsFor(opts, io) {
3432
3576
  const args = opts.args ? objectJson(opts.args === "-" ? await io.readStdin() : readInput(opts.args), "Arguments") : {};
3577
+ for (const { name, file } of opts.argFiles) {
3578
+ if (Object.hasOwn(args, name)) throw new Error(`Use either --arg-file ${name}=... or args.${name}, not both`);
3579
+ try {
3580
+ args[name] = JSON.parse(readInput(file));
3581
+ } catch (error) {
3582
+ if (error instanceof SyntaxError) throw new Error(`--arg-file ${name}: ${file} is not JSON (${error.message})`);
3583
+ throw error;
3584
+ }
3585
+ }
3433
3586
  if (opts.sources.length) {
3434
3587
  if (Object.hasOwn(args, "sources")) throw new Error("Use either --source files or args.sources, not both");
3435
3588
  const names = sourceNames(opts.sources);
@@ -3457,7 +3610,7 @@ function clearOutput(opts) {
3457
3610
  if (!opts.output) return;
3458
3611
  const output = path9.resolve(opts.output);
3459
3612
  const outputStat = fs9.existsSync(output) ? fs9.statSync(output) : void 0;
3460
- for (const input of [...opts.sources, opts.args, opts.bundle].filter((p) => !!p && p !== "-")) {
3613
+ for (const input of [...opts.sources, ...opts.argFiles.map((a) => a.file), opts.args, opts.bundle].filter((p) => !!p && p !== "-")) {
3461
3614
  const inputStat = fs9.existsSync(input) ? fs9.statSync(input) : void 0;
3462
3615
  if (path9.resolve(input) === output || outputStat && inputStat && inputStat.dev === outputStat.dev && inputStat.ino === outputStat.ino) {
3463
3616
  throw new Error("--output must not overwrite an input file");
@@ -3601,9 +3754,10 @@ async function startPageServer(root, assetsDir, port = null, now = () => /* @__P
3601
3754
  };
3602
3755
  for (const file of watched) fs10.watchFile(file, { interval: 250 }, changed);
3603
3756
  const hosts = /* @__PURE__ */ new Set();
3757
+ let self = "", closing = false;
3604
3758
  const takes = /* @__PURE__ */ new Map([
3605
3759
  ["/answer", (args) => recordAnswer(root, args, now())],
3606
- ["/viewing", (args) => recordViewing(root, args, now())]
3760
+ ["/viewing", (args) => recordViewing(root, self, args, now())]
3607
3761
  ]);
3608
3762
  const server = http.createServer((req, res) => {
3609
3763
  const send = (status, type, body) => {
@@ -3624,7 +3778,11 @@ async function startPageServer(root, assetsDir, port = null, now = () => /* @__P
3624
3778
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
3625
3779
  res.write(": connected\n\n");
3626
3780
  clients.add(res);
3627
- req.on("close", () => clients.delete(res));
3781
+ recordOpened(root, self);
3782
+ req.on("close", () => {
3783
+ clients.delete(res);
3784
+ if (!clients.size && !closing) recordClosed(root, self, now());
3785
+ });
3628
3786
  return;
3629
3787
  }
3630
3788
  const asset = PAGE_ASSETS.find((a) => url.pathname === `/${a}`);
@@ -3667,9 +3825,11 @@ async function startPageServer(root, assetsDir, port = null, now = () => /* @__P
3667
3825
  }
3668
3826
  const bound = server.address().port;
3669
3827
  hosts.add(`127.0.0.1:${bound}`).add(`localhost:${bound}`);
3828
+ self = `http://127.0.0.1:${bound}/`;
3670
3829
  return {
3671
- url: `http://127.0.0.1:${bound}/`,
3830
+ url: self,
3672
3831
  async close() {
3832
+ closing = true;
3673
3833
  for (const file of watched) fs10.unwatchFile(file, changed);
3674
3834
  const closed = new Promise((resolve) => server.close(() => resolve()));
3675
3835
  server.closeAllConnections();