@cychien/ezdesign 0.9.2 → 0.11.0

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.
Files changed (34) hide show
  1. package/README.md +209 -550
  2. package/dist/cli.mjs +2640 -655
  3. package/dist/constants.d.ts +8 -4
  4. package/dist/extension/g/0.11.0-75c3fbebc3/dialog.js +1 -0
  5. package/dist/extension/g/0.11.0-75c3fbebc3/media.js +1 -0
  6. package/dist/extension/g/0.11.0-75c3fbebc3/page.js +44 -0
  7. package/dist/extension/g/0.11.0-75c3fbebc3/worker.js +57 -0
  8. package/dist/extension/manifest.json +15 -12
  9. package/dist/home.css +4706 -0
  10. package/dist/home.js +440 -0
  11. package/dist/native-host.mjs +33804 -115
  12. package/dist/overlay.css +5 -1
  13. package/dist/overlay.js +73 -22
  14. package/dist/shell.css +297 -1
  15. package/dist/shell.js +1230 -358
  16. package/dist/video-digest.js +16388 -0
  17. package/dist/vite.mjs +5 -1
  18. package/package.json +4 -3
  19. package/skills/ezdesign/SKILL.md +7 -3
  20. package/skills/ezdesign/reference/start.md +2 -1
  21. package/dist/extension/g/0.9.2-d9aee83dae/dialog.js +0 -1
  22. package/dist/extension/g/0.9.2-d9aee83dae/media.js +0 -1
  23. package/dist/extension/g/0.9.2-d9aee83dae/page.js +0 -39
  24. package/dist/extension/g/0.9.2-d9aee83dae/worker.js +0 -57
  25. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/dialog.css +0 -0
  26. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/dialog.html +0 -0
  27. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/icon-128.png +0 -0
  28. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/icon-16.png +0 -0
  29. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/icon-32.png +0 -0
  30. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/icon-48.png +0 -0
  31. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/media.html +0 -0
  32. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/setup.css +0 -0
  33. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/setup.html +0 -0
  34. /package/dist/extension/g/{0.9.2-d9aee83dae → 0.11.0-75c3fbebc3}/setup.js +0 -0
package/dist/cli.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync as readFileSync16 } from "node:fs";
5
- import { dirname as dirname11, join as join22, resolve as resolve5 } from "node:path";
4
+ import { spawnSync as spawnSync2 } from "node:child_process";
5
+ import { readFileSync as readFileSync21 } from "node:fs";
6
+ import { dirname as dirname11, join as join27, resolve as resolve6 } from "node:path";
6
7
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7
8
  import open2, { apps } from "open";
8
9
 
@@ -30,6 +31,10 @@ var NO_UPDATE_CHECK_ENV = `${ENV_PREFIX}_NO_UPDATE_CHECK`;
30
31
  var DESIGN_HARNESS_ENV = `${ENV_PREFIX}_DESIGN_HARNESS`;
31
32
  var API_URL_ENV = `${ENV_PREFIX}_API_URL`;
32
33
  var NO_IDLE_STOP_ENV = `${ENV_PREFIX}_NO_IDLE_STOP`;
34
+ var PROJECTS_FILE = join(DATA_DIR, "projects.json");
35
+ var HOME_PATH = `${URL_PREFIX}/home`;
36
+ var LOGIN_ENV_VAR = `${ENV_PREFIX}_LOGIN_ENV`;
37
+ var LOGIN_ENV_CACHE = join(DATA_DIR, "login-env.json");
33
38
  function envFlagOn(raw) {
34
39
  return raw !== void 0 && raw !== "" && raw !== "0" && raw.toLowerCase() !== "false";
35
40
  }
@@ -138,7 +143,6 @@ function controlPortRange() {
138
143
  function assertControlPortEnv() {
139
144
  controlPortRange();
140
145
  }
141
- var MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
142
146
  var ATTACHMENT_GRACE_MS = 24 * 60 * 6e4;
143
147
  var UPDATE_CHECK_TTL_MS = 4 * 60 * 6e4;
144
148
  var POLL_TIMEOUT_MS = 5e4;
@@ -147,6 +151,10 @@ var IDLE_STOP_MS = 30 * 6e4;
147
151
  var IDLE_CHECK_MS = 6e4;
148
152
  var SHELL_RETRY_MS = 1e3;
149
153
  var SHELL_RETURN_MS = 2e3;
154
+ var DEV_SERVER_READY_MS = 6e4;
155
+ var DEV_SERVER_PORT_SCAN_AFTER_MS = 3e3;
156
+ var DEV_SERVER_STOP_GRACE_MS = 3e3;
157
+ var DEV_SERVER_LOG_LINES = 2e3;
150
158
 
151
159
  // src/shell-quote.ts
152
160
  function shellQuote(arg) {
@@ -233,6 +241,7 @@ var Request = z.discriminatedUnion("t", [
233
241
  capture: CaptureId,
234
242
  kind: z.enum(["image", "video"]),
235
243
  url: HttpUrl,
244
+ as: z.enum(["file", "hls"]).optional(),
236
245
  referer: z.string().max(16384).nullable(),
237
246
  userAgent: z.string().max(1024),
238
247
  context: CaptureContext
@@ -244,7 +253,7 @@ var Request = z.discriminatedUnion("t", [
244
253
  t: z.literal("upload"),
245
254
  id: RequestId,
246
255
  capture: CaptureId,
247
- kind: z.enum(["image", "screenshot", "frame"]),
256
+ kind: z.enum(["image", "screenshot", "frame", "video"]),
248
257
  size: z.number().int().nonnegative(),
249
258
  context: CaptureContext
250
259
  }),
@@ -290,6 +299,13 @@ var Request = z.discriminatedUnion("t", [
290
299
  exclude: z.array(z.string().max(256)).max(200),
291
300
  limit: z.number().int().min(1).max(50)
292
301
  }),
302
+ z.object({
303
+ t: z.literal("probe"),
304
+ id: RequestId,
305
+ sources: z.array(z.object({ url: HttpUrl, as: z.enum(["file", "hls"]) })).min(1).max(20),
306
+ referer: z.string().max(16384).nullable(),
307
+ userAgent: z.string().max(1024)
308
+ }),
293
309
  /** Aborts the request `ref` (a `download` or `read`), which then fails `cancelled`. */
294
310
  z.object({ t: z.literal("cancel"), ref: RequestId })
295
311
  ]);
@@ -324,7 +340,11 @@ var ReplyValue = {
324
340
  read: z.object({ bytes: z.number().int().nonnegative() }),
325
341
  commit: z.object({ item: WireItem, merged: z.boolean() }),
326
342
  discard: Done,
327
- suggest: z.object({ tags: z.array(z.object({ name: z.string(), uses: z.number().int() })).max(50) })
343
+ suggest: z.object({ tags: z.array(z.object({ name: z.string(), uses: z.number().int() })).max(50) }),
344
+ probe: z.object({
345
+ durations: z.array(z.number().nonnegative().nullable()),
346
+ masters: z.array(z.boolean())
347
+ })
328
348
  };
329
349
  var WireError = z.discriminatedUnion("code", [
330
350
  /** The ranges do not meet. `host` lets the extension say which side is old. */
@@ -369,6 +389,24 @@ function writeAtomic(path, data, mode) {
369
389
  renameSync(temp, path);
370
390
  }
371
391
 
392
+ // src/self-node.ts
393
+ var RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
394
+ var onElectron = Boolean(process.versions.electron) || process.env[RUN_AS_NODE] === "1";
395
+ var SELF_NODE = {
396
+ execPath: process.execPath,
397
+ env: onElectron ? { [RUN_AS_NODE]: "1" } : {}
398
+ };
399
+ function scrubRunAsNode(env = process.env) {
400
+ delete env[RUN_AS_NODE];
401
+ }
402
+ function selfNodeCommandLine(args, runtime = SELF_NODE) {
403
+ const env = Object.entries(runtime.env).map(([k, v]) => `${k}=${shellQuote(v)}`);
404
+ return [...env, ...[runtime.execPath, ...args].map(shellQuote)].join(" ");
405
+ }
406
+ function selfNodeEnv(base = process.env, runtime = SELF_NODE) {
407
+ return { ...base, ...runtime.env };
408
+ }
409
+
372
410
  // src/update-check.ts
373
411
  import { mkdirSync, readFileSync, writeFileSync as writeFileSync2 } from "node:fs";
374
412
  import { dirname } from "node:path";
@@ -538,9 +576,10 @@ function chooseHostNode(current, recorded) {
538
576
  }
539
577
  function hostWrapperScript(options) {
540
578
  const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
579
+ const env = Object.entries(options.nodeEnv ?? {}).map(([k, v]) => `${k}=${q(v)} `).join("");
541
580
  return [
542
581
  "#!/bin/sh",
543
- `EZDESIGN_DATA_DIR=${q(options.dataDir)} exec ${q(options.node)} ${q(options.host)} "$@"`,
582
+ `${env}EZDESIGN_DATA_DIR=${q(options.dataDir)} exec ${q(options.node)} ${q(options.host)} "$@"`,
544
583
  ""
545
584
  ].join("\n");
546
585
  }
@@ -567,8 +606,8 @@ function resolvePlacement(options) {
567
606
  }
568
607
  function probeNode(path) {
569
608
  if (path === process.execPath) return { path, version: process.version };
570
- const run2 = spawnSync(path, ["-p", "process.version"], { encoding: "utf8", timeout: 1e4, env: {} });
571
- const version = run2.status === 0 ? run2.stdout.trim() : "";
609
+ const run4 = spawnSync(path, ["-p", "process.version"], { encoding: "utf8", timeout: 1e4, env: { [RUN_AS_NODE]: "1" } });
610
+ const version = run4.status === 0 ? run4.stdout.trim() : "";
572
611
  return { path, version: /^v\d+\.\d+\.\d+/.test(version) ? version : null };
573
612
  }
574
613
  async function installCapture(options) {
@@ -593,7 +632,8 @@ async function installCapture(options) {
593
632
  mkdirSync3(at.hostDir, { recursive: true });
594
633
  const host = join4(at.hostDir, HOST_BUNDLE);
595
634
  writeAtomic(host, readFileSync3(bundle));
596
- writeAtomic(at.wrapper, hostWrapperScript({ node: choice.node.path, host, dataDir: at.dataDir }), 493);
635
+ const nodeEnv = choice.node.path === SELF_NODE.execPath ? SELF_NODE.env : {};
636
+ writeAtomic(at.wrapper, hostWrapperScript({ node: choice.node.path, host, dataDir: at.dataDir, nodeEnv }), 493);
597
637
  const extensionGeneration = installExtension(join4(options.distDir, "extension"), at.extensionDir);
598
638
  const manifest = `${JSON.stringify(hostManifest(at.wrapper, ALLOWED_ORIGINS), null, 2)}
599
639
  `;
@@ -783,13 +823,13 @@ function forget(key, file) {
783
823
  }
784
824
 
785
825
  // src/daemon.ts
786
- import { createHash as createHash8, randomBytes as randomBytes8, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
826
+ import { createHash as createHash8, randomBytes as randomBytes10, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
787
827
  import { EventEmitter } from "node:events";
788
- import { accessSync, constants, mkdirSync as mkdirSync16, readFileSync as readFileSync14, statSync as statSync4 } from "node:fs";
828
+ import { mkdirSync as mkdirSync17, readFileSync as readFileSync19, statSync as statSync8 } from "node:fs";
789
829
  import { createServer } from "node:http";
790
- import { delimiter, dirname as dirname8, join as join19 } from "node:path";
830
+ import { dirname as dirname9, join as join25 } from "node:path";
791
831
  import { fileURLToPath } from "node:url";
792
- import express3, { Router as Router6 } from "express";
832
+ import express4, { Router as Router8 } from "express";
793
833
 
794
834
  // src/app-proxy.ts
795
835
  import {
@@ -818,6 +858,23 @@ var MAX_FRAME_TITLE = 80;
818
858
  var DEFAULT_FRAME_WIDTH = 1280;
819
859
  var MIN_FRAME_SIZE = { width: 240, height: 240 };
820
860
  var MAX_FRAME_SIZE = { width: 3840, height: 12e3 };
861
+ var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
862
+ var IMAGE_TYPES = {
863
+ "image/png": "png",
864
+ "image/jpeg": "jpg",
865
+ "image/gif": "gif",
866
+ "image/webp": "webp",
867
+ "image/avif": "avif",
868
+ "image/svg+xml": "svg"
869
+ };
870
+ var ASSET_SHAPE = new RegExp(`^[0-9a-f]{12}\\.(${Object.values(IMAGE_TYPES).join("|")})$`);
871
+ function asAssetName(raw) {
872
+ return typeof raw === "string" && ASSET_SHAPE.test(raw) ? raw : null;
873
+ }
874
+ function mimeOfAsset(name) {
875
+ const ext = name.slice(name.indexOf(".") + 1);
876
+ return Object.keys(IMAGE_TYPES).find((mime) => IMAGE_TYPES[mime] === ext);
877
+ }
821
878
 
822
879
  // src/canvas/frame-document.ts
823
880
  var FRAME_HOST = "localhost";
@@ -825,6 +882,24 @@ var FRAME_ROUTE = "/frames";
825
882
  function frameUrl(frameOrigin, canvasId2, frameId2, hash) {
826
883
  return `${frameOrigin}${URL_PREFIX}${FRAME_ROUTE}/${canvasId2}/${frameId2}?rev=${hash}`;
827
884
  }
885
+ var ASSETS_DIR = "frames/assets";
886
+ function imageFrameHtml(title, asset2) {
887
+ const text = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
888
+ return [
889
+ "<!doctype html>",
890
+ "<html>",
891
+ "<head>",
892
+ '<meta charset="utf-8">',
893
+ `<title>${text}</title>`,
894
+ "<style>html,body{margin:0;height:100%}img{display:block;width:100%;height:100%;object-fit:contain}</style>",
895
+ "</head>",
896
+ "<body>",
897
+ `<img src="assets/${asset2}" alt="${text}">`,
898
+ "</body>",
899
+ "</html>",
900
+ ""
901
+ ].join("\n");
902
+ }
828
903
  function isFrameHost(host) {
829
904
  if (!host) return false;
830
905
  return host.replace(/:\d+$/, "").toLowerCase() === FRAME_HOST;
@@ -946,16 +1021,47 @@ function wantsHtml(accept) {
946
1021
  // src/app-proxy.ts
947
1022
  var NO_APP = `<!doctype html><meta charset="utf-8"><title>ezdesign</title>
948
1023
  <p>No dev server is attached to this review. Run <code>ezdesign &lt;url&gt;</code> in the project to attach one.</p>`;
1024
+ var UNREACHABLE_HEADER = "x-ezdesign-unreachable";
1025
+ var unreachable = (origin) => `<!doctype html><meta charset="utf-8"><title>\u9023\u4E0D\u4E0A App</title>
1026
+ <style>
1027
+ /* The shell's dev-server prompt, in its tokens' values: this page is served without the shell's CSS. */
1028
+ body { margin: 0; background: #faf9f6; -webkit-font-smoothing: antialiased;
1029
+ color: #171717; font: 13.5px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans TC', sans-serif; }
1030
+ main { display: grid; width: min(520px, calc(100% - 48px)); margin: max(48px, 12vh) auto 48px; }
1031
+ h1 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.4; }
1032
+ p { margin: 6px 0 18px; color: #676767; }
1033
+ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; }
1034
+ button { justify-self: start; height: 32px; padding: 0 14px; border: 1px solid #171717; border-radius: 7px;
1035
+ background: #171717; color: #fff; box-shadow: inset 0 1px 0 0 rgb(255 255 255 / 0.14);
1036
+ font: 600 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans TC', sans-serif; cursor: pointer; }
1037
+ button:hover { opacity: 0.88; }
1038
+ </style>
1039
+ <main>
1040
+ <h1>\u9023\u4E0D\u4E0A App</h1>
1041
+ <p><code>${origin.replace(/^https?:\/\//, "")}</code> \u6C92\u6709\u56DE\u61C9\uFF0Cdev server \u53EF\u80FD\u505C\u4E86\u3002</p>
1042
+ <button type="button" onclick="location.reload()">\u91CD\u65B0\u6574\u7406</button>
1043
+ </main>
1044
+ <script>
1045
+ setInterval(() => fetch(location.href, { method: 'HEAD', cache: 'no-store' })
1046
+ .then((r) => { if (!r.headers.has('${UNREACHABLE_HEADER}')) location.reload() }, () => {}), 1500)
1047
+ </script>`;
949
1048
  var AppProxy = class {
950
1049
  constructor(shellPath, onRequest) {
951
1050
  this.shellPath = shellPath;
952
1051
  this.onRequest = onRequest;
953
1052
  const router = () => this.current.kind === "attached" ? this.current.origin : void 0;
1053
+ const error = (_err, req, res) => {
1054
+ if (!("writeHead" in res) || res.headersSent) return void res.end();
1055
+ const origin = this.current.kind === "attached" ? this.current.origin : "";
1056
+ res.writeHead(502, { "content-type": wantsHtml(req.headers.accept) ? "text/html; charset=utf-8" : "text/plain", [UNREACHABLE_HEADER]: "1" });
1057
+ res.end(wantsHtml(req.headers.accept) ? unreachable(origin) : "");
1058
+ };
954
1059
  this.htmlProxy = createProxyMiddleware({
955
1060
  router,
956
1061
  changeOrigin: true,
957
1062
  selfHandleResponse: true,
958
1063
  on: {
1064
+ error,
959
1065
  proxyRes: responseInterceptor(async (buffer, proxyRes, _req, res) => {
960
1066
  res.removeHeader("content-security-policy");
961
1067
  res.removeHeader("x-frame-options");
@@ -965,7 +1071,7 @@ var AppProxy = class {
965
1071
  })
966
1072
  }
967
1073
  });
968
- this.rawProxy = createProxyMiddleware({ router, changeOrigin: true, ws: true });
1074
+ this.rawProxy = createProxyMiddleware({ router, changeOrigin: true, ws: true, on: { error } });
969
1075
  }
970
1076
  current = { kind: "none" };
971
1077
  upgraded = /* @__PURE__ */ new Set();
@@ -1101,7 +1207,7 @@ var CANVAS_TOOLS = {
1101
1207
  "Add one frame to an open canvas: a complete, self-contained HTML document (<!doctype html>, its own <head> and styles) rendered in its own iframe at a real viewport width.",
1102
1208
  "Scripts and CDN assets are allowed. No CSS is shared between frames.",
1103
1209
  "width is the viewport width the page is designed for (390 phone, 768 tablet, 1280 desktop; default 1280); height defaults to a common screen for that width.",
1104
- "Leave position out unless the user asked for a particular arrangement: the frame is then placed where it overlaps nothing, the frames of one turn side by side in the order you add them, in a row under the earlier ones. After creation a frame's position is the user's, and its size never changes: pick width and height here.",
1210
+ "Leave position out unless the user asked for a particular arrangement: the frame is then placed where it overlaps nothing, the frames of one turn side by side in the order you add them, in a row under the earlier ones. After creation a frame's position is the user's and its width is fixed; its height can follow its content through update_frame.",
1105
1211
  "Call once per frame, as soon as it is ready: the user watches frames appear."
1106
1212
  ].join("\n"),
1107
1213
  input: {
@@ -1128,16 +1234,18 @@ var CANVAS_TOOLS = {
1128
1234
  }),
1129
1235
  update_frame: tool({
1130
1236
  description: [
1131
- "Replace a frame's HTML document and/or retitle it.",
1237
+ "Replace a frame's HTML document, retitle it, and/or change its height.",
1132
1238
  "Pass expectedHash - the hash read_canvas, add_frame or your last update_frame returned - so an edit you have not seen is never overwritten: if the file changed since, nothing is written and the error names its current hash.",
1133
1239
  "For a small change you may instead edit the frame's file directly; the user's canvas updates either way.",
1134
- "A frame's position belongs to the user and its size is fixed when it is added; neither can be changed here."
1240
+ "Pass height whenever the content needs a taller or shorter frame; frames below it move down to make room. Never remove a frame and add it again to resize it: that loses its place and the annotations made on it.",
1241
+ "A frame's position belongs to the user and its width is the viewport it was designed for; neither can be changed here."
1135
1242
  ].join("\n"),
1136
1243
  input: {
1137
1244
  canvasId,
1138
1245
  frameId,
1139
1246
  html: z2.string().optional().describe("The new complete HTML document."),
1140
1247
  title: z2.string().optional().describe("A new label for the frame."),
1248
+ height: px("A new viewport height in CSS px, to fit the content."),
1141
1249
  expectedHash: z2.string().optional().describe("The hash of the version you are replacing.")
1142
1250
  },
1143
1251
  run: (port, args) => map(
@@ -1146,9 +1254,10 @@ var CANVAS_TOOLS = {
1146
1254
  frameId: args.frameId,
1147
1255
  ...args.html !== void 0 ? { html: args.html } : {},
1148
1256
  ...args.title !== void 0 ? { title: args.title } : {},
1257
+ ...args.height !== void 0 ? { height: args.height } : {},
1149
1258
  ...args.expectedHash !== void 0 ? { expectedHash: args.expectedHash } : {}
1150
1259
  }),
1151
- (f) => ({ frameId: f.id, file: f.file, hash: f.hash })
1260
+ listing
1152
1261
  )
1153
1262
  }),
1154
1263
  remove_frame: tool({
@@ -1163,8 +1272,8 @@ function isCanvasTool(permissionName) {
1163
1272
  return permissionName.startsWith(prefix) && CANVAS_TOOL_NAMES.includes(permissionName.slice(prefix.length));
1164
1273
  }
1165
1274
  function runCanvasTool(port, name, args) {
1166
- const run2 = CANVAS_TOOLS[name].run;
1167
- const outcome = run2(port, args);
1275
+ const run4 = CANVAS_TOOLS[name].run;
1276
+ const outcome = run4(port, args);
1168
1277
  if (!outcome.ok) return { isError: true, content: [{ type: "text", text: canvasProblemMessage(outcome.problem) }] };
1169
1278
  const value = outcome.value;
1170
1279
  return { content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value };
@@ -1220,7 +1329,7 @@ var CanvasMcp = class {
1220
1329
  server.registerTool(
1221
1330
  name,
1222
1331
  // Strict, so `x` or `width` on an existing frame is refused rather than
1223
- // silently ignored: a frame's position is the user's, its size fixed.
1332
+ // silently ignored: a frame's position is the user's, its width fixed.
1224
1333
  { description: spec.description, inputSchema: z2.object(spec.input).strict() },
1225
1334
  (args) => runCanvasTool(this.port, name, args)
1226
1335
  );
@@ -1258,6 +1367,11 @@ function canvasProblemMessage(problem) {
1258
1367
  return `BAD_NAME: give a non-empty name of at most ${problem.max} characters.`;
1259
1368
  case "BAD_RECT":
1260
1369
  return `BAD_RECT: ${problem.reason}`;
1370
+ case "IMAGE_UNSUPPORTED":
1371
+ case "IMAGE_TOO_LARGE":
1372
+ case "IMAGE_EMPTY":
1373
+ case "ASSET_NOT_FOUND":
1374
+ return `${problem.code}: only the user pastes images onto a canvas.`;
1261
1375
  }
1262
1376
  }
1263
1377
 
@@ -1290,7 +1404,7 @@ function canvasBrief(input) {
1290
1404
  `The review shell has these tabs: ${tabs.join(", ") || "none"}.` + (shownArchived.length ? ` Archived: ${shownArchived.map((c) => `"${c.name}" (${c.id})`).join(", ")}.` : "") + (more > 0 ? ` ${more} more: call list_canvases.` : ""),
1291
1405
  ...where ? [where] : [],
1292
1406
  `Items with anchor.frame were made inside a canvas frame; anchor.source is then a line of that frame's file under ${CANVASES_DIR}/.`,
1293
- "A frame's HTML is yours: change it with update_frame or by editing that file. Its position is the user's, and its size is fixed when it is added. Never edit canvas.json or layout.json.",
1407
+ "A frame's HTML is yours: change it with update_frame or by editing that file. Its position is the user's and its width is fixed; when the content needs a different height, pass height to update_frame rather than adding the frame again. Never edit canvas.json or layout.json.",
1294
1408
  `To show the user designs, mockups or alternatives side by side, put each one in a frame on a canvas. Canvas tools (${CANVAS_SERVER_NAME}): ${CANVAS_TOOL_NAMES.join(", ")}.`,
1295
1409
  target ? `Add new frames to canvas "${target.name}" (${target.id}). Call create_canvas only when the user asks for a new canvas or this one is full.` : "No canvas is open: create one with create_canvas for new frames.",
1296
1410
  ""
@@ -1298,7 +1412,7 @@ function canvasBrief(input) {
1298
1412
  }
1299
1413
 
1300
1414
  // src/canvas/routes.ts
1301
- import { Router } from "express";
1415
+ import express2, { Router } from "express";
1302
1416
  function problemText(problem) {
1303
1417
  switch (problem.code) {
1304
1418
  case "CANVAS_NOT_FOUND":
@@ -1321,19 +1435,32 @@ function problemText(problem) {
1321
1435
  return `a name is 1 to ${problem.max} characters`;
1322
1436
  case "BAD_RECT":
1323
1437
  return `bad frame geometry: ${problem.reason}`;
1438
+ case "IMAGE_UNSUPPORTED":
1439
+ return `${problem.mime || "this file"} is not an image a frame can show`;
1440
+ case "IMAGE_TOO_LARGE":
1441
+ return `the image is ${problem.bytes} bytes; the limit is ${problem.limit}`;
1442
+ case "IMAGE_EMPTY":
1443
+ return "the image is empty";
1444
+ case "ASSET_NOT_FOUND":
1445
+ return `no image ${problem.asset} on canvas ${problem.canvasId}`;
1324
1446
  }
1325
1447
  }
1326
1448
  function statusOf(problem) {
1327
1449
  switch (problem.code) {
1328
1450
  case "CANVAS_NOT_FOUND":
1329
1451
  case "FRAME_NOT_FOUND":
1452
+ case "ASSET_NOT_FOUND":
1330
1453
  return 404;
1331
1454
  case "CANVAS_ARCHIVED":
1332
1455
  case "FRAME_CONFLICT":
1333
1456
  return 409;
1334
1457
  case "HTML_TOO_LARGE":
1458
+ case "IMAGE_TOO_LARGE":
1335
1459
  return 413;
1460
+ case "IMAGE_UNSUPPORTED":
1461
+ return 415;
1336
1462
  case "HTML_EMPTY":
1463
+ case "IMAGE_EMPTY":
1337
1464
  case "NOT_A_DOCUMENT":
1338
1465
  case "TOO_MANY_FRAMES":
1339
1466
  case "BAD_NAME":
@@ -1411,6 +1538,31 @@ function parseDuplicate(raw) {
1411
1538
  if (!source || !copy || at === null) return null;
1412
1539
  return { from: source, id: copy, ...at ? { at } : {} };
1413
1540
  }
1541
+ function rect(raw) {
1542
+ const at = point(raw);
1543
+ const { width, height } = raw ?? {};
1544
+ return at && finite(width) && finite(height) ? { ...at, width, height } : null;
1545
+ }
1546
+ function parseImageFrame(raw) {
1547
+ if (typeof raw !== "object" || raw === null) return null;
1548
+ const { id: rawId, asset: rawAsset, title, rect: rawRect } = raw;
1549
+ const id = asFrameId(rawId);
1550
+ const asset2 = asAssetName(rawAsset);
1551
+ const at = rect(rawRect);
1552
+ if (!id || !asset2 || !at || typeof title !== "string") return null;
1553
+ return { id, asset: asset2, title, rect: at };
1554
+ }
1555
+ var rawImage = express2.raw({ type: () => true, limit: MAX_IMAGE_BYTES });
1556
+ function receiveImage(req, res, then) {
1557
+ if (Number(req.get("content-length")) > MAX_IMAGE_BYTES) {
1558
+ return void res.status(413).json({ error: "the image is too large", code: "IMAGE_TOO_LARGE" });
1559
+ }
1560
+ rawImage(req, res, (err) => {
1561
+ if (!err) return then(Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0));
1562
+ const status = err.status === 413 ? 413 : 400;
1563
+ res.status(status).json({ error: status === 413 ? "the image is too large" : "unreadable image body" });
1564
+ });
1565
+ }
1414
1566
  function canvasApiRouter(host) {
1415
1567
  const api = Router();
1416
1568
  const { user, read: read4 } = host;
@@ -1440,15 +1592,28 @@ function canvasApiRouter(host) {
1440
1592
  answer(res, user.delete(req.params.canvasId), () => ({ ok: true }));
1441
1593
  });
1442
1594
  api.post("/canvases/:canvasId/ops", (req, res) => {
1443
- const step = parseLayoutStep(req.body);
1444
- if (!step) return res.status(400).json({ error: "not a layout step" });
1445
- answer(res, user.applyLayoutOps(req.params.canvasId, step), (wire) => wire);
1595
+ const step2 = parseLayoutStep(req.body);
1596
+ if (!step2) return res.status(400).json({ error: "not a layout step" });
1597
+ answer(res, user.applyLayoutOps(req.params.canvasId, step2), (wire) => wire);
1446
1598
  });
1447
1599
  api.post("/canvases/:canvasId/frames", (req, res) => {
1448
1600
  const input = parseDuplicate(req.body);
1449
1601
  if (!input) return res.status(400).json({ error: "not a duplicate" });
1450
1602
  answer(res, user.duplicate(req.params.canvasId, input), (wire) => wire);
1451
1603
  });
1604
+ api.post("/canvases/:canvasId/assets", (req, res) => {
1605
+ const mime = (req.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
1606
+ receiveImage(
1607
+ req,
1608
+ res,
1609
+ (bytes) => answer(res, user.addAsset(req.params.canvasId, mime, bytes), (asset2) => ({ asset: asset2 }))
1610
+ );
1611
+ });
1612
+ api.post("/canvases/:canvasId/image-frames", (req, res) => {
1613
+ const input = parseImageFrame(req.body);
1614
+ if (!input) return res.status(400).json({ error: "not an image frame" });
1615
+ answer(res, user.addImageFrame(req.params.canvasId, input), (wire) => wire);
1616
+ });
1452
1617
  api.put("/view", (req, res) => {
1453
1618
  const view = parseView(req.body);
1454
1619
  if (!view) return res.status(400).json({ error: "not a view" });
@@ -1467,6 +1632,20 @@ function frameHost(read4, shellOrigin) {
1467
1632
  }
1468
1633
  const [canvasRaw, frameRaw, extra] = framePath ? req.path.slice(FRAMES_PATH.length).split("/") : [];
1469
1634
  const canvasId2 = asCanvasId(canvasRaw);
1635
+ const assetName = frameRaw === "assets" ? asAssetName(extra) : null;
1636
+ const image = req.method === "GET" && canvasId2 && assetName ? read4.asset(canvasId2, assetName) : null;
1637
+ if (image) {
1638
+ res.sendFile(image.path, {
1639
+ // The project's `.ezdesign/` is a dot directory.
1640
+ dotfiles: "allow",
1641
+ headers: {
1642
+ "content-type": image.mime,
1643
+ "cache-control": "public, max-age=31536000, immutable",
1644
+ "x-content-type-options": "nosniff"
1645
+ }
1646
+ });
1647
+ return;
1648
+ }
1470
1649
  const frameId2 = asFrameId(frameRaw);
1471
1650
  const doc = req.method === "GET" && canvasId2 && frameId2 && extra === void 0 ? read4.frameDocument(canvasId2, frameId2) : null;
1472
1651
  if (!doc) {
@@ -1526,12 +1705,27 @@ function normalizeRect(raw) {
1526
1705
  height: clamp(Math.round(raw.height), MIN_FRAME_SIZE.height, MAX_FRAME_SIZE.height)
1527
1706
  };
1528
1707
  }
1708
+ function growDown(rect3, height, others) {
1709
+ const next = normalizeRect({ ...rect3, height });
1710
+ const growth = next.height - rect3.height;
1711
+ if (growth <= 0) return { rect: next, moved: [] };
1712
+ const spanned = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width;
1713
+ const pushers = [rect3];
1714
+ const moved = [];
1715
+ const below = others.filter(([, r]) => r.y >= rect3.y + rect3.height).sort((a, b) => a[1].y - b[1].y);
1716
+ for (const [id, r] of below) {
1717
+ if (!pushers.some((p) => spanned(p, r))) continue;
1718
+ pushers.push(r);
1719
+ moved.push([id, { ...r, y: r.y + growth }]);
1720
+ }
1721
+ return { rect: next, moved };
1722
+ }
1529
1723
  function freeAlongRow(from, size, existing, limit) {
1530
1724
  let x = from.x;
1531
1725
  for (; ; ) {
1532
1726
  if (x + size.width > limit) return null;
1533
- const rect2 = { x, y: from.y, width: size.width, height: size.height };
1534
- const hits = existing.filter((r) => overlaps(rect2, r));
1727
+ const rect3 = { x, y: from.y, width: size.width, height: size.height };
1728
+ const hits = existing.filter((r) => overlaps(rect3, r));
1535
1729
  if (!hits.length) return { x, y: from.y };
1536
1730
  x = Math.max(...hits.map((r) => r.x + r.width + GAP));
1537
1731
  }
@@ -1582,12 +1776,12 @@ var FrameFileWatcher = class {
1582
1776
  }
1583
1777
  /** `<canvasId>/frames/<frameId>.html`, both ids through `asCanvasId` /
1584
1778
  * `asFrameId`; anything else - temp files, manifests, dotfiles - ignored. */
1585
- schedule(relative) {
1586
- if (relative === null) return;
1587
- const parts = String(relative).split(/[\\/]/);
1588
- if (parts.length !== 3 || parts[1] !== "frames") return;
1589
- const canvasId2 = asCanvasId(parts[0]);
1590
- const file = /^(.*)\.html$/.exec(parts[2]);
1779
+ schedule(relative3) {
1780
+ if (relative3 === null) return;
1781
+ const parts2 = String(relative3).split(/[\\/]/);
1782
+ if (parts2.length !== 3 || parts2[1] !== "frames") return;
1783
+ const canvasId2 = asCanvasId(parts2[0]);
1784
+ const file = /^(.*)\.html$/.exec(parts2[2]);
1591
1785
  const frameId2 = file ? asFrameId(file[1]) : null;
1592
1786
  if (!canvasId2 || !frameId2) return;
1593
1787
  const key = `${canvasId2}/${frameId2}`;
@@ -1682,8 +1876,10 @@ var Workspace = class {
1682
1876
  archive: (canvasId2) => this.archive(canvasId2),
1683
1877
  reopen: (canvasId2) => this.reopen(canvasId2),
1684
1878
  delete: (canvasId2) => this.delete(canvasId2),
1685
- applyLayoutOps: (canvasId2, step) => this.applyLayoutOps(canvasId2, step),
1879
+ applyLayoutOps: (canvasId2, step2) => this.applyLayoutOps(canvasId2, step2),
1686
1880
  duplicate: (canvasId2, input) => this.duplicate(canvasId2, input),
1881
+ addAsset: (canvasId2, mime, bytes) => this.addAsset(canvasId2, mime, bytes),
1882
+ addImageFrame: (canvasId2, input) => this.addImageFrame(canvasId2, input),
1687
1883
  setView: (view) => this.setView(view)
1688
1884
  };
1689
1885
  }
@@ -1703,7 +1899,11 @@ var Workspace = class {
1703
1899
  const frame = this.liveFrame(ref.canvasId, ref.frameId);
1704
1900
  return held && frame ? { canvas: held.manifest.name, frame: frame.title } : null;
1705
1901
  },
1706
- frameDocument: (canvasId2, frameId2) => this.frameDocument(canvasId2, frameId2)
1902
+ frameDocument: (canvasId2, frameId2) => this.frameDocument(canvasId2, frameId2),
1903
+ asset: (canvasId2, name) => {
1904
+ const path = join6(this.dir(canvasId2), ASSETS_DIR, name);
1905
+ return this.canvases.has(canvasId2) && existsSync5(path) ? { path, mime: mimeOfAsset(name) } : null;
1906
+ }
1707
1907
  };
1708
1908
  }
1709
1909
  // ---------------------------------------------------------------- lifecycle
@@ -1899,14 +2099,30 @@ var Workspace = class {
1899
2099
  return;
1900
2100
  }
1901
2101
  const old = this.now() - UNNAMED_FILE_GRACE_MS;
1902
- for (const name of files) {
1903
- if (named.has(`frames/${name}`)) continue;
1904
- const path = join6(framesDir, name);
2102
+ const unlinkOld = (path) => {
1905
2103
  try {
1906
2104
  if (statSync(path).mtimeMs < old) unlinkSync(path);
1907
2105
  } catch {
1908
2106
  }
2107
+ };
2108
+ for (const name of files) {
2109
+ if (named.has(`frames/${name}`) || `frames/${name}` === ASSETS_DIR) continue;
2110
+ unlinkOld(join6(framesDir, name));
2111
+ }
2112
+ const shown = /* @__PURE__ */ new Set();
2113
+ for (const frame of held.manifest.frames) {
2114
+ try {
2115
+ const html = readFileSync5(join6(this.dir(held.manifest.id), frame.file), "utf8");
2116
+ for (const m of html.matchAll(/assets\/([0-9a-f]{12}\.\w+)/g)) shown.add(m[1]);
2117
+ } catch {
2118
+ }
2119
+ }
2120
+ let assets = [];
2121
+ try {
2122
+ assets = readdirSync2(join6(this.dir(held.manifest.id), ASSETS_DIR));
2123
+ } catch {
1909
2124
  }
2125
+ for (const name of assets) if (!shown.has(name)) unlinkOld(join6(this.dir(held.manifest.id), ASSETS_DIR, name));
1910
2126
  }
1911
2127
  frameDocument(canvasId2, frameId2) {
1912
2128
  const held = this.canvases.get(canvasId2);
@@ -2064,16 +2280,16 @@ var Workspace = class {
2064
2280
  return ok(void 0);
2065
2281
  }
2066
2282
  // ----------------------------------------------------------- board edits
2067
- applyLayoutOps(canvasId2, step) {
2283
+ applyLayoutOps(canvasId2, step2) {
2068
2284
  const writable = this.writable(canvasId2);
2069
2285
  if (!writable.ok) return writable;
2070
2286
  const held = writable.value;
2071
2287
  const id = held.manifest.id;
2072
- if (held.recentOps.includes(step.opId)) return ok(this.wire(id));
2288
+ if (held.recentOps.includes(step2.opId)) return ok(this.wire(id));
2073
2289
  let movedRects = false;
2074
2290
  let movedFrames = false;
2075
2291
  const live3 = (frameId2) => held.manifest.frames.find((f) => f.id === frameId2 && f.deletedAt === void 0);
2076
- for (const op of step.ops) {
2292
+ for (const op of step2.ops) {
2077
2293
  if (op.t === "move") {
2078
2294
  for (const { id: frameId2, x, y } of op.frames) {
2079
2295
  const was = held.layout.rects[frameId2];
@@ -2101,7 +2317,7 @@ var Workspace = class {
2101
2317
  }
2102
2318
  if (movedRects) this.writeLayout(held);
2103
2319
  if (movedFrames) this.writeManifest(held);
2104
- held.recentOps = [...held.recentOps, step.opId].slice(-RECENT_OPS);
2320
+ held.recentOps = [...held.recentOps, step2.opId].slice(-RECENT_OPS);
2105
2321
  if (movedRects || movedFrames) this.opts.onChange({ kind: "updated", canvasId: id });
2106
2322
  return ok(this.wire(id));
2107
2323
  }
@@ -2125,10 +2341,10 @@ var Workspace = class {
2125
2341
  }
2126
2342
  const sourceRect = held.layout.rects[source.id] ?? NO_RECT;
2127
2343
  const at = input.at ?? placeBeside(sourceRect, live3.map((f) => held.layout.rects[f.id] ?? NO_RECT));
2128
- const rect2 = normalizeRect({ ...at, width: sourceRect.width, height: sourceRect.height });
2344
+ const rect3 = normalizeRect({ ...at, width: sourceRect.width, height: sourceRect.height });
2129
2345
  const file = `frames/${input.id}.html`;
2130
2346
  writeAtomic(join6(this.dir(id), file), bytes.toString("utf8"));
2131
- held.layout.rects[input.id] = rect2;
2347
+ held.layout.rects[input.id] = rect3;
2132
2348
  this.writeLayout(held);
2133
2349
  const title = cleanName(`${source.title} \u526F\u672C`, MAX_FRAME_TITLE) ?? source.title;
2134
2350
  held.manifest.frames.push({ id: input.id, title, file, hash: sha1(bytes), createdAt: this.now() });
@@ -2136,6 +2352,47 @@ var Workspace = class {
2136
2352
  this.opts.onChange({ kind: "updated", canvasId: id });
2137
2353
  return ok(this.wire(id));
2138
2354
  }
2355
+ addAsset(canvasId2, mime, bytes) {
2356
+ const writable = this.writable(canvasId2);
2357
+ if (!writable.ok) return writable;
2358
+ const ext = IMAGE_TYPES[mime];
2359
+ if (!ext) return { ok: false, problem: { code: "IMAGE_UNSUPPORTED", mime } };
2360
+ if (!bytes.length) return { ok: false, problem: { code: "IMAGE_EMPTY" } };
2361
+ if (bytes.length > MAX_IMAGE_BYTES) {
2362
+ return { ok: false, problem: { code: "IMAGE_TOO_LARGE", bytes: bytes.length, limit: MAX_IMAGE_BYTES } };
2363
+ }
2364
+ const name = `${sha1(bytes)}.${ext}`;
2365
+ const path = join6(this.dir(writable.value.manifest.id), ASSETS_DIR, name);
2366
+ if (!existsSync5(path)) {
2367
+ mkdirSync5(join6(this.dir(writable.value.manifest.id), ASSETS_DIR), { recursive: true });
2368
+ writeAtomic(path, bytes);
2369
+ }
2370
+ return ok(name);
2371
+ }
2372
+ addImageFrame(canvasId2, input) {
2373
+ const writable = this.writable(canvasId2);
2374
+ if (!writable.ok) return writable;
2375
+ const held = writable.value;
2376
+ const id = held.manifest.id;
2377
+ if (held.manifest.frames.some((f) => f.id === input.id)) return ok(this.wire(id));
2378
+ const title = cleanName(input.title, MAX_FRAME_TITLE);
2379
+ if (title === null) return { ok: false, problem: { code: "BAD_NAME", max: MAX_FRAME_TITLE } };
2380
+ if (!asAssetName(input.asset) || !existsSync5(join6(this.dir(id), ASSETS_DIR, input.asset))) {
2381
+ return { ok: false, problem: { code: "ASSET_NOT_FOUND", canvasId: id, asset: input.asset } };
2382
+ }
2383
+ if (held.manifest.frames.filter((f) => f.deletedAt === void 0).length >= MAX_FRAMES_PER_CANVAS) {
2384
+ return { ok: false, problem: { code: "TOO_MANY_FRAMES", limit: MAX_FRAMES_PER_CANVAS } };
2385
+ }
2386
+ const html = imageFrameHtml(title, input.asset);
2387
+ const file = `frames/${input.id}.html`;
2388
+ writeAtomic(join6(this.dir(id), file), html);
2389
+ held.layout.rects[input.id] = normalizeRect(input.rect);
2390
+ this.writeLayout(held);
2391
+ held.manifest.frames.push({ id: input.id, title, file, hash: sha1(html), createdAt: this.now() });
2392
+ this.writeManifest(held);
2393
+ this.opts.onChange({ kind: "updated", canvasId: id });
2394
+ return ok(this.wire(id));
2395
+ }
2139
2396
  // ------------------------------------------------------------ agent edits
2140
2397
  frameOf(held, frame) {
2141
2398
  return this.project(held).frames.find((f) => f.id === frame.id);
@@ -2157,7 +2414,7 @@ var Workspace = class {
2157
2414
  const rectOf = (frameId2) => held.layout.rects[frameId2] ?? NO_RECT;
2158
2415
  const turn = this.turnFrames.filter((frameId2) => live3.some((f) => f.id === frameId2)).map(rectOf);
2159
2416
  const at = input.position ?? autoPlace(live3.map((f) => rectOf(f.id)), turn, normalizeRect({ x: 0, y: 0, ...size }));
2160
- const rect2 = normalizeRect({ ...at, ...size });
2417
+ const rect3 = normalizeRect({ ...at, ...size });
2161
2418
  let id;
2162
2419
  do
2163
2420
  id = randomBytes3(6).toString("hex");
@@ -2165,7 +2422,7 @@ var Workspace = class {
2165
2422
  const file = `frames/${id}.html`;
2166
2423
  const frame = { id, title, file, hash: sha1(input.html), createdAt: this.now() };
2167
2424
  writeAtomic(join6(this.dir(held.manifest.id), file), input.html);
2168
- held.layout.rects[id] = rect2;
2425
+ held.layout.rects[id] = rect3;
2169
2426
  this.writeLayout(held);
2170
2427
  held.manifest.frames.push(frame);
2171
2428
  this.turnFrames.push(id);
@@ -2202,15 +2459,29 @@ var Workspace = class {
2202
2459
  writeAtomic(path, input.html);
2203
2460
  hash = sha1(input.html);
2204
2461
  }
2205
- if (hash !== frame.hash || title !== void 0 && title !== frame.title) {
2462
+ const resized = input.height === void 0 ? false : this.resizeFrame(held, frame.id, input.height);
2463
+ const rewritten = hash !== frame.hash || title !== void 0 && title !== frame.title;
2464
+ if (rewritten) {
2206
2465
  frame.hash = hash;
2207
2466
  if (title !== void 0) frame.title = title;
2208
2467
  this.writeManifest(held);
2209
2468
  this.served.delete(`${held.manifest.id}/${frame.id}`);
2210
- this.opts.onChange({ kind: "updated", canvasId: held.manifest.id });
2211
2469
  }
2470
+ if (rewritten || resized) this.opts.onChange({ kind: "updated", canvasId: held.manifest.id });
2212
2471
  return ok(this.frameOf(held, frame));
2213
2472
  }
2473
+ /** True if anything moved. */
2474
+ resizeFrame(held, frameId2, height) {
2475
+ const rects = held.layout.rects;
2476
+ const rect3 = rects[frameId2] ?? NO_RECT;
2477
+ const others = held.manifest.frames.filter((f) => f.deletedAt === void 0 && f.id !== frameId2).map((f) => [f.id, rects[f.id] ?? NO_RECT]);
2478
+ const grown = growDown(rect3, height, others);
2479
+ if (grown.rect.height === rect3.height) return false;
2480
+ rects[frameId2] = grown.rect;
2481
+ for (const [id, moved] of grown.moved) rects[id] = moved;
2482
+ this.writeLayout(held);
2483
+ return true;
2484
+ }
2214
2485
  removeFrame(canvasId2, frameId2) {
2215
2486
  const writable = this.writable(canvasId2);
2216
2487
  if (!writable.ok) return writable;
@@ -2267,6 +2538,119 @@ function refuseCrossSite(sessionOrigin) {
2267
2538
  };
2268
2539
  }
2269
2540
 
2541
+ // src/library/policy.ts
2542
+ var VIDEO_CAP_BYTES = 2e8;
2543
+ var THUMBNAIL_LONG_SIDE = 400;
2544
+ var TAG_MAX_LENGTH = 64;
2545
+ function normalizeTag(raw) {
2546
+ const name = raw.normalize("NFC").trim().replace(/\s+/g, " ");
2547
+ if (name === "" || name.length > TAG_MAX_LENGTH || /[,,]/.test(name)) return null;
2548
+ return lowercaseInitial(name);
2549
+ }
2550
+ function lowercaseInitial(name) {
2551
+ const [first = "", second = ""] = name;
2552
+ if (second !== second.toLowerCase()) return name;
2553
+ return first.toLowerCase() + name.slice(first.length);
2554
+ }
2555
+ function tagKey(name) {
2556
+ return name.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
2557
+ }
2558
+
2559
+ // src/attachment-policy.ts
2560
+ var MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
2561
+ function isVideoMime(mime) {
2562
+ return mime.startsWith("video/");
2563
+ }
2564
+ function attachmentByteLimit(mime) {
2565
+ return isVideoMime(mime) ? VIDEO_CAP_BYTES : MAX_ATTACHMENT_BYTES;
2566
+ }
2567
+
2568
+ // src/attachment-wire.ts
2569
+ var MAX_SHEETS = 6;
2570
+ var MAX_SHEET_BYTES = 4 * 1024 * 1024;
2571
+ var MAX_DIGEST_BYTES = 10 * 1024 * 1024;
2572
+ var SETTLE_BODY_LIMIT = Math.ceil(MAX_DIGEST_BYTES * 4 / 3) + 256 * 1024;
2573
+ var FAILURES = {
2574
+ "unsupported-browser": true,
2575
+ "unsupported-codec": true,
2576
+ unreadable: true,
2577
+ crashed: true
2578
+ };
2579
+ var BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
2580
+ var positive = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
2581
+ var count = (n) => Number.isInteger(n) && n >= 0;
2582
+ function parseVideoSettle(raw) {
2583
+ if (typeof raw !== "object" || raw === null) return "a settle is an object";
2584
+ const r = raw;
2585
+ if (r.state === "failed") {
2586
+ if (typeof r.reason !== "string" || !Object.hasOwn(FAILURES, r.reason)) return "unknown failure reason";
2587
+ if (typeof r.detail !== "string") return "detail must be a string";
2588
+ return { state: "failed", reason: r.reason, detail: r.detail.slice(0, 500) };
2589
+ }
2590
+ if (r.state !== "ready") return "state must be ready or failed";
2591
+ const { durationMs, width, height, fps, audio } = r;
2592
+ if (![durationMs, width, height, fps].every(positive)) return "durationMs, width, height and fps must be positive";
2593
+ if (typeof audio !== "boolean") return "audio must be a boolean";
2594
+ const w = width;
2595
+ const h = height;
2596
+ if (!Array.isArray(r.sheets) || r.sheets.length < 1 || r.sheets.length > MAX_SHEETS) {
2597
+ return `sheets must hold 1 to ${MAX_SHEETS} images`;
2598
+ }
2599
+ const sheets = [];
2600
+ let total = 0;
2601
+ for (const sheet of r.sheets) {
2602
+ if (typeof sheet !== "string" || !BASE64.test(sheet)) return "a sheet must be base64";
2603
+ const bytes = new Uint8Array(Buffer.from(sheet, "base64"));
2604
+ if (bytes.byteLength > MAX_SHEET_BYTES) return "a sheet is too large";
2605
+ if (bytes[0] !== 255 || bytes[1] !== 216 || bytes[2] !== 255) return "a sheet must be a JPEG";
2606
+ total += bytes.byteLength;
2607
+ sheets.push(bytes);
2608
+ }
2609
+ if (total > MAX_DIGEST_BYTES) return "the sheets together are too large";
2610
+ if (!Array.isArray(r.motion)) return "motion must be a list";
2611
+ const motion = [];
2612
+ const used = /* @__PURE__ */ new Set();
2613
+ let previous = -1;
2614
+ for (const raw2 of r.motion) {
2615
+ if (typeof raw2 !== "object" || raw2 === null) return "a stretch is an object";
2616
+ const m = raw2;
2617
+ if (!count(m.fromMs) || !count(m.toMs) || m.toMs < m.fromMs) {
2618
+ return "a stretch needs fromMs <= toMs";
2619
+ }
2620
+ if (m.fromMs < previous) return "stretches must be in time order";
2621
+ previous = m.fromMs;
2622
+ if (!insideFrame(m.region, w, h)) return "a stretch region must lie inside the frame";
2623
+ if (!count(m.frames) || !count(m.shown) || m.shown > m.frames) {
2624
+ return "a stretch needs shown <= frames";
2625
+ }
2626
+ if (m.sheet === null) {
2627
+ if (m.shown !== 0) return "a stretch without a sheet shows nothing";
2628
+ } else {
2629
+ if (!Number.isInteger(m.sheet) || m.sheet < 2 || m.sheet > sheets.length) {
2630
+ return "a stretch names a sheet that is not there";
2631
+ }
2632
+ if (used.has(m.sheet)) return "two stretches name one sheet";
2633
+ used.add(m.sheet);
2634
+ }
2635
+ const { x, y, width: rw, height: rh } = m.region;
2636
+ motion.push({
2637
+ fromMs: m.fromMs,
2638
+ toMs: m.toMs,
2639
+ region: { x, y, width: rw, height: rh },
2640
+ frames: m.frames,
2641
+ shown: m.shown,
2642
+ sheet: m.sheet
2643
+ });
2644
+ }
2645
+ if (used.size !== sheets.length - 1) return "every sheet past the first belongs to a stretch";
2646
+ return { state: "ready", durationMs, width: w, height: h, fps, audio, motion, sheets };
2647
+ }
2648
+ function insideFrame(raw, width, height) {
2649
+ if (typeof raw !== "object" || raw === null) return false;
2650
+ const { x, y, width: w, height: h } = raw;
2651
+ return count(x) && count(y) && Number.isInteger(w) && Number.isInteger(h) && w > 0 && h > 0 && x + w <= width && y + h <= height;
2652
+ }
2653
+
2270
2654
  // src/session-directory.ts
2271
2655
  var SessionDirectory = class {
2272
2656
  constructor(make, records, log = () => {
@@ -2289,16 +2673,15 @@ var SessionDirectory = class {
2289
2673
  if (runtime.endedByUser && !req.reopen) return { ok: false, reason: "ended-by-user" };
2290
2674
  runtime.reopen();
2291
2675
  }
2292
- if (req.origin) {
2293
- const holder = this.byOrigin(req.origin);
2294
- if (holder && holder !== runtime) holder.detachApp();
2295
- runtime.attachApp(
2296
- { kind: "attached", origin: req.origin, attachedAt: req.now },
2297
- { userAttached: true }
2298
- );
2299
- }
2676
+ if (req.origin) this.attachOrigin(runtime, req.origin, { userAttached: true, now: req.now });
2677
+ this.records.used?.(req.project, req.now);
2300
2678
  return { ok: true, runtime };
2301
2679
  }
2680
+ attachOrigin(runtime, origin, opts) {
2681
+ const holder = this.byOrigin(origin);
2682
+ if (holder && holder !== runtime) holder.detachApp();
2683
+ runtime.attachApp({ kind: "attached", origin, attachedAt: opts.now }, { userAttached: opts.userAttached });
2684
+ }
2302
2685
  /** `GET /control/sessions/find?project=`: the live session, or the persisted
2303
2686
  * active one woken up - a poll that reconnects after an idle exit still finds
2304
2687
  * its queued feedback. Null for an unknown or ended project. */
@@ -2381,15 +2764,28 @@ import {
2381
2764
  mkdirSync as mkdirSync8,
2382
2765
  readFileSync as readFileSync8,
2383
2766
  readdirSync as readdirSync4,
2384
- renameSync as renameSync3,
2385
- rmSync as rmSync5,
2767
+ renameSync as renameSync4,
2768
+ rmSync as rmSync6,
2386
2769
  writeFileSync as writeFileSync6
2387
2770
  } from "node:fs";
2388
2771
  import { join as join9 } from "node:path";
2389
2772
 
2390
2773
  // src/store.ts
2391
2774
  import { createHash as createHash2, randomBytes as randomBytes4 } from "node:crypto";
2392
- import { constants as fsConstants, copyFileSync, mkdirSync as mkdirSync7, readFileSync as readFileSync7, readdirSync as readdirSync3, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "node:fs";
2775
+ import { once } from "node:events";
2776
+ import {
2777
+ constants as fsConstants,
2778
+ copyFileSync,
2779
+ createWriteStream,
2780
+ mkdirSync as mkdirSync7,
2781
+ readFileSync as readFileSync7,
2782
+ readdirSync as readdirSync3,
2783
+ renameSync as renameSync3,
2784
+ rmSync as rmSync5,
2785
+ statSync as statSync2,
2786
+ unlinkSync as unlinkSync2,
2787
+ writeFileSync as writeFileSync5
2788
+ } from "node:fs";
2393
2789
  import { join as join8 } from "node:path";
2394
2790
 
2395
2791
  // src/preferences.ts
@@ -2428,6 +2824,13 @@ function rememberAnnouncementSeen(id, file = PREFERENCES_FILE) {
2428
2824
  write(file, { ...prefs, announcementsSeen: next });
2429
2825
  return next;
2430
2826
  }
2827
+ function rememberedLastAgent(file = PREFERENCES_FILE) {
2828
+ return read(file).lastAgent;
2829
+ }
2830
+ function rememberLastAgent(command, file = PREFERENCES_FILE) {
2831
+ const prefs = read(file);
2832
+ if (prefs.lastAgent !== command) write(file, { ...prefs, lastAgent: command });
2833
+ }
2431
2834
  function write(file, prefs) {
2432
2835
  mkdirSync6(dirname4(file), { recursive: true });
2433
2836
  writeFileSync4(file, JSON.stringify(prefs, null, 2));
@@ -2482,6 +2885,30 @@ function dedupeName(name, taken) {
2482
2885
  if (!taken.has(candidate)) return candidate;
2483
2886
  }
2484
2887
  }
2888
+ function isPendingVideo(a) {
2889
+ return a.video?.state === "pending";
2890
+ }
2891
+ function sheetFileName(id, sheet) {
2892
+ return `${id}.sheet-${sheet}.jpg`;
2893
+ }
2894
+ function sheetNumbers(video) {
2895
+ return [1, ...video.motion.flatMap((m) => m.sheet === null ? [] : [m.sheet])];
2896
+ }
2897
+ function attachmentFiles(a) {
2898
+ const own = `${a.id}-${a.name}`;
2899
+ return a.video?.state === "ready" ? [own, ...sheetNumbers(a.video).map((n) => sheetFileName(a.id, n))] : [own];
2900
+ }
2901
+ function statusOf2(a) {
2902
+ const video = a.video;
2903
+ if (!video || video.state === "ready") return { state: "ready" };
2904
+ if (video.state === "failed") return { state: "failed", reason: video.reason };
2905
+ return { state: "pending" };
2906
+ }
2907
+ function digestOf(settle) {
2908
+ if (settle.state === "failed") return settle;
2909
+ const { sheets: _, ...frames } = settle;
2910
+ return frames;
2911
+ }
2485
2912
  function isProjectSession(value) {
2486
2913
  const record = value;
2487
2914
  return typeof record?.project === "string" && typeof record.app?.kind === "string" && record.targetOrigin === void 0;
@@ -2623,6 +3050,7 @@ var SessionStore = class {
2623
3050
  * the one the user just asked for. Rounds on *other* elements are left alone,
2624
3051
  * which is what lets a button and a heading be explored at once. */
2625
3052
  startExplore(round) {
3053
+ if (round.attachments?.some(isPendingVideo)) throw new Error("a video still being digested cannot start an explore");
2626
3054
  const fresh = {
2627
3055
  ...round,
2628
3056
  status: "generating",
@@ -2736,6 +3164,7 @@ var SessionStore = class {
2736
3164
  /** Move the queue (+ optional note and its files) into a sealed batch awaiting
2737
3165
  * agent pickup. Attachment bytes stay put - the batch now references them. */
2738
3166
  sendBatch(note, attachments = [], references = [], skills = [], view) {
3167
+ if (this.holdsPendingVideo(attachments)) throw new Error("a video still being digested cannot be sent");
2739
3168
  const items = this.annotations;
2740
3169
  if (items.length === 0 && !note?.trim() && attachments.length === 0 && references.length === 0 && skills.length === 0) {
2741
3170
  return null;
@@ -2809,13 +3238,42 @@ var SessionStore = class {
2809
3238
  attachmentPath(a) {
2810
3239
  return join8(this.attachmentsDir, `${a.id}-${a.name}`);
2811
3240
  }
3241
+ sheetPath(a, sheet) {
3242
+ return join8(this.attachmentsDir, sheetFileName(a.id, sheet));
3243
+ }
2812
3244
  addAttachment(rawName, mime, bytes) {
2813
- return this.storeAttachment(rawName, mime, bytes.byteLength, (path) => writeFileSync5(path, bytes));
3245
+ return this.storeAttachment(newId(), rawName, mime, bytes.byteLength, (path) => writeFileSync5(path, bytes));
3246
+ }
3247
+ async receiveAttachment(rawName, mime, body, limit) {
3248
+ const id = newId();
3249
+ mkdirSync7(this.attachmentsDir, { recursive: true });
3250
+ const part = join8(this.attachmentsDir, `${id}.part`);
3251
+ const out = createWriteStream(part);
3252
+ let size = 0;
3253
+ try {
3254
+ for await (const chunk of body) {
3255
+ size += chunk.byteLength;
3256
+ if (size > limit) break;
3257
+ if (!out.write(chunk)) await once(out, "drain");
3258
+ }
3259
+ out.end();
3260
+ await once(out, "close");
3261
+ } catch (error) {
3262
+ out.destroy();
3263
+ rmSync5(part, { force: true });
3264
+ throw error;
3265
+ }
3266
+ if (size > limit || size === 0) {
3267
+ rmSync5(part, { force: true });
3268
+ return size === 0 ? "empty" : "too-large";
3269
+ }
3270
+ return this.storeAttachment(id, rawName, mime, size, (path) => renameSync3(part, path));
2814
3271
  }
2815
3272
  /** A copy, not a link to the library's file: a session owns its attachments
2816
3273
  * outright, and the sweep deletes them. A clone where the filesystem has one. */
2817
3274
  copyAttachment(title, mime, from, origin) {
2818
3275
  return this.storeAttachment(
3276
+ newId(),
2819
3277
  libraryAttachmentName(title, mime),
2820
3278
  mime,
2821
3279
  statSync2(from).size,
@@ -2823,16 +3281,17 @@ var SessionStore = class {
2823
3281
  origin
2824
3282
  );
2825
3283
  }
2826
- storeAttachment(rawName, mime, size, write3, origin) {
3284
+ storeAttachment(id, rawName, mime, size, write3, origin) {
2827
3285
  const index = this.attachmentIndex;
2828
3286
  const taken = new Set(Object.values(index).map((a) => a.name));
2829
3287
  const attachment = {
2830
- id: newId(),
3288
+ id,
2831
3289
  name: dedupeName(sanitizeAttachmentName(rawName, mime), taken),
2832
3290
  mime,
2833
3291
  size,
2834
3292
  createdAt: Date.now(),
2835
- ...origin ? { origin } : {}
3293
+ ...origin ? { origin } : {},
3294
+ ...isVideoMime(mime) ? { video: { state: "pending" } } : {}
2836
3295
  };
2837
3296
  mkdirSync7(this.attachmentsDir, { recursive: true });
2838
3297
  write3(this.attachmentPath(attachment));
@@ -2851,6 +3310,31 @@ var SessionStore = class {
2851
3310
  }
2852
3311
  return found;
2853
3312
  }
3313
+ get pendingVideoIds() {
3314
+ return Object.values(this.attachmentIndex).flatMap((a) => isPendingVideo(a) ? [a.id] : []);
3315
+ }
3316
+ holdsPendingVideo(extra = []) {
3317
+ return [...extra, ...this.annotations.flatMap((a) => a.attachments ?? [])].some(isPendingVideo);
3318
+ }
3319
+ settleVideo(id, settle) {
3320
+ const index = this.attachmentIndex;
3321
+ const entry = index[id];
3322
+ if (!entry) return { state: "gone" };
3323
+ if (!isPendingVideo(entry)) return statusOf2(entry);
3324
+ if (settle.state === "ready") {
3325
+ settle.sheets.forEach((bytes, i) => writeFileSync5(this.sheetPath(entry, i + 1), bytes));
3326
+ }
3327
+ const settled = { ...entry, video: digestOf(settle) };
3328
+ this.writeJson("attachments.json", { ...index, [id]: settled });
3329
+ const queue = this.annotations;
3330
+ if (queue.some((a) => a.attachments?.some((x) => x.id === id))) {
3331
+ this.writeJson(
3332
+ "queue.json",
3333
+ queue.map((a) => a.attachments ? { ...a, attachments: a.attachments.map((x) => x.id === id ? settled : x) } : a)
3334
+ );
3335
+ }
3336
+ return statusOf2(settled);
3337
+ }
2854
3338
  removeAttachment(id) {
2855
3339
  const index = this.attachmentIndex;
2856
3340
  const target = index[id];
@@ -2865,9 +3349,11 @@ var SessionStore = class {
2865
3349
  if (!list2?.length) return;
2866
3350
  const index = this.attachmentIndex;
2867
3351
  for (const a of list2) {
2868
- try {
2869
- unlinkSync2(this.attachmentPath(a));
2870
- } catch {
3352
+ for (const name of attachmentFiles(a)) {
3353
+ try {
3354
+ unlinkSync2(join8(this.attachmentsDir, name));
3355
+ } catch {
3356
+ }
2871
3357
  }
2872
3358
  delete index[a.id];
2873
3359
  }
@@ -2896,7 +3382,7 @@ var SessionStore = class {
2896
3382
  const index = this.attachmentIndex;
2897
3383
  const doomed = Object.values(index).filter((a) => !referenced.has(a.id) && a.createdAt < cutoff);
2898
3384
  this.dropAttachments(doomed);
2899
- const kept = new Set(Object.values(this.attachmentIndex).map((a) => `${a.id}-${a.name}`));
3385
+ const kept = new Set(Object.values(this.attachmentIndex).flatMap(attachmentFiles));
2900
3386
  let entries3;
2901
3387
  try {
2902
3388
  entries3 = readdirSync3(this.attachmentsDir);
@@ -2957,6 +3443,18 @@ var SessionStore = class {
2957
3443
  setAgent(command) {
2958
3444
  this.patchSession({ agent: command });
2959
3445
  }
3446
+ setAgentBy(by) {
3447
+ const { agentDeclined: _agentDeclined, ...rest } = this.session;
3448
+ this.writeJson("session.json", { ...rest, agentBy: by });
3449
+ }
3450
+ declineAgent() {
3451
+ this.patchSession({ agentDeclined: true });
3452
+ }
3453
+ /** A restore brings back only an agent still connected when the daemon stopped. */
3454
+ clearAgent() {
3455
+ const { agent: _agent, agentBy: _agentBy, ...rest } = this.session;
3456
+ this.writeJson("session.json", rest);
3457
+ }
2960
3458
  /** Remember one of the user's agent-config picks - the model, and anything else
2961
3459
  * the agent offers - so the next session opens on it. */
2962
3460
  setAgentConfig(configId, value) {
@@ -2967,7 +3465,7 @@ var SessionStore = class {
2967
3465
  * The picks go with it - they described that agent's options, and the next one
2968
3466
  * need not have them. */
2969
3467
  end(by) {
2970
- const { agent: _agent, agentConfig: _agentConfig, ...rest } = this.session;
3468
+ const { agent: _agent, agentBy: _agentBy, agentDeclined: _agentDeclined, agentConfig: _agentConfig, ...rest } = this.session;
2971
3469
  this.writeJson("session.json", { ...rest, state: "ended", endedBy: by });
2972
3470
  }
2973
3471
  /** This review's conversations, oldest first, with one guaranteed to exist.
@@ -3401,19 +3899,19 @@ var TRANSIENT = /\.(staging|prev)-\d+$/;
3401
3899
  function recover(sessionsDir, entries3) {
3402
3900
  for (const name of entries3) {
3403
3901
  const path = join9(sessionsDir, name);
3404
- if (/\.staging-\d+$/.test(name)) rmSync5(path, { recursive: true, force: true });
3902
+ if (/\.staging-\d+$/.test(name)) rmSync6(path, { recursive: true, force: true });
3405
3903
  const prev = /^(.*)\.prev-\d+$/.exec(name);
3406
3904
  if (!prev) continue;
3407
3905
  const original = join9(sessionsDir, prev[1]);
3408
- if (existsSync6(original)) rmSync5(path, { recursive: true, force: true });
3409
- else renameSync3(path, original);
3906
+ if (existsSync6(original)) rmSync6(path, { recursive: true, force: true });
3907
+ else renameSync4(path, original);
3410
3908
  }
3411
3909
  }
3412
3910
  function writeMerged(sessionsDir, merged, hadExisting) {
3413
3911
  const { key } = merged.files;
3414
3912
  const target = join9(sessionsDir, key);
3415
3913
  const staging = join9(sessionsDir, `${key}.staging-${process.pid}`);
3416
- rmSync5(staging, { recursive: true, force: true });
3914
+ rmSync6(staging, { recursive: true, force: true });
3417
3915
  const attachments = join9(staging, "attachments");
3418
3916
  mkdirSync8(attachments, { recursive: true });
3419
3917
  if (hadExisting) {
@@ -3426,27 +3924,27 @@ function writeMerged(sessionsDir, merged, hadExisting) {
3426
3924
  const from = join9(sessionsDir, copy.sourceKey, "attachments", `${copy.from.id}-${copy.from.name}`);
3427
3925
  if (existsSync6(from)) copyFileSync2(from, join9(attachments, `${copy.to.id}-${copy.to.name}`));
3428
3926
  }
3429
- for (const [field, file] of Object.entries(FILES)) {
3927
+ for (const [field3, file] of Object.entries(FILES)) {
3430
3928
  writeFileSync6(
3431
3929
  join9(staging, file),
3432
- JSON.stringify(merged.files[field], null, 2)
3930
+ JSON.stringify(merged.files[field3], null, 2)
3433
3931
  );
3434
3932
  }
3435
3933
  writeFileSync6(join9(staging, "session.json"), JSON.stringify(merged.files.session, null, 2));
3436
3934
  if (hadExisting) {
3437
3935
  const prev = join9(sessionsDir, `${key}.prev-${process.pid}`);
3438
- renameSync3(target, prev);
3439
- renameSync3(staging, target);
3440
- rmSync5(prev, { recursive: true, force: true });
3936
+ renameSync4(target, prev);
3937
+ renameSync4(staging, target);
3938
+ rmSync6(prev, { recursive: true, force: true });
3441
3939
  } else {
3442
- renameSync3(staging, target);
3940
+ renameSync4(staging, target);
3443
3941
  }
3444
3942
  }
3445
3943
  function moveToLegacy(sessionsDir, legacyDir, key) {
3446
3944
  mkdirSync8(legacyDir, { recursive: true });
3447
3945
  let dest = join9(legacyDir, key);
3448
3946
  for (let n = 2; existsSync6(dest); n++) dest = join9(legacyDir, `${key}-${n}`);
3449
- renameSync3(join9(sessionsDir, key), dest);
3947
+ renameSync4(join9(sessionsDir, key), dest);
3450
3948
  }
3451
3949
  function migrateLegacySessions(sessionsDir = SESSIONS_DIR, legacyDir = LEGACY_SESSIONS_DIR) {
3452
3950
  const report2 = { merged: /* @__PURE__ */ new Map(), skipped: [] };
@@ -3550,12 +4048,12 @@ function fieldsFromSchema(schema) {
3550
4048
  customs.push({ key, questionId, ...text ? { text } : {} });
3551
4049
  continue;
3552
4050
  }
3553
- const field = fieldFrom(key, property);
3554
- if (!field) {
4051
+ const field3 = fieldFrom(key, property);
4052
+ if (!field3) {
3555
4053
  if (required.has(key)) return null;
3556
4054
  continue;
3557
4055
  }
3558
- fields.push(required.has(key) ? field : { ...field, optional: true });
4056
+ fields.push(required.has(key) ? field3 : { ...field3, optional: true });
3559
4057
  }
3560
4058
  for (const { questionId, ...custom } of customs) {
3561
4059
  const owner = fields.find((f) => f.key === questionId);
@@ -3645,49 +4143,49 @@ function validateAnswers(fields, raw) {
3645
4143
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
3646
4144
  const given = raw;
3647
4145
  const answers = {};
3648
- for (const field of fields) {
3649
- const custom = field.kind === "select" || field.kind === "multiselect" ? field.custom : void 0;
4146
+ for (const field3 of fields) {
4147
+ const custom = field3.kind === "select" || field3.kind === "multiselect" ? field3.custom : void 0;
3650
4148
  const typed = custom ? given[custom.key] : void 0;
3651
4149
  if (typed !== void 0 && typed !== null && typed !== "") {
3652
4150
  if (typeof typed !== "string") return null;
3653
4151
  answers[custom.key] = typed;
3654
4152
  }
3655
- const value = given[field.key];
4153
+ const value = given[field3.key];
3656
4154
  if (value === void 0 || value === null || value === "") {
3657
- if (field.optional || typed) continue;
4155
+ if (field3.optional || typed) continue;
3658
4156
  return null;
3659
4157
  }
3660
- const checked = checkAnswer(field, value);
4158
+ const checked = checkAnswer(field3, value);
3661
4159
  if (checked === null) return null;
3662
- answers[field.key] = checked;
4160
+ answers[field3.key] = checked;
3663
4161
  }
3664
4162
  return answers;
3665
4163
  }
3666
- function checkAnswer(field, value) {
3667
- switch (field.kind) {
4164
+ function checkAnswer(field3, value) {
4165
+ switch (field3.kind) {
3668
4166
  case "select":
3669
- return typeof value === "string" && field.options.some((o) => o.id === value) ? value : null;
4167
+ return typeof value === "string" && field3.options.some((o) => o.id === value) ? value : null;
3670
4168
  case "multiselect": {
3671
4169
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) return null;
3672
4170
  const ids = value;
3673
4171
  if (new Set(ids).size !== ids.length) return null;
3674
- if (!ids.every((id) => field.options.some((o) => o.id === id))) return null;
3675
- if (field.min !== void 0 && ids.length < field.min) return null;
3676
- if (field.max !== void 0 && ids.length > field.max) return null;
4172
+ if (!ids.every((id) => field3.options.some((o) => o.id === id))) return null;
4173
+ if (field3.min !== void 0 && ids.length < field3.min) return null;
4174
+ if (field3.max !== void 0 && ids.length > field3.max) return null;
3677
4175
  return ids;
3678
4176
  }
3679
4177
  case "text": {
3680
4178
  if (typeof value !== "string") return null;
3681
- if (field.min !== void 0 && value.length < field.min) return null;
3682
- if (field.max !== void 0 && value.length > field.max) return null;
3683
- if (field.pattern !== void 0 && !safeMatch(field.pattern, value)) return null;
4179
+ if (field3.min !== void 0 && value.length < field3.min) return null;
4180
+ if (field3.max !== void 0 && value.length > field3.max) return null;
4181
+ if (field3.pattern !== void 0 && !safeMatch(field3.pattern, value)) return null;
3684
4182
  return value;
3685
4183
  }
3686
4184
  case "number": {
3687
4185
  if (typeof value !== "number" || !Number.isFinite(value)) return null;
3688
- if (field.integer && !Number.isInteger(value)) return null;
3689
- if (field.min !== void 0 && value < field.min) return null;
3690
- if (field.max !== void 0 && value > field.max) return null;
4186
+ if (field3.integer && !Number.isInteger(value)) return null;
4187
+ if (field3.min !== void 0 && value < field3.min) return null;
4188
+ if (field3.max !== void 0 && value > field3.max) return null;
3691
4189
  return value;
3692
4190
  }
3693
4191
  case "boolean":
@@ -3726,7 +4224,7 @@ function configValueName(option, value) {
3726
4224
 
3727
4225
  // src/usage-limit.ts
3728
4226
  import { spawn as spawn2 } from "node:child_process";
3729
- import { mkdirSync as mkdirSync9, mkdtempSync, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
4227
+ import { mkdirSync as mkdirSync9, mkdtempSync, readFileSync as readFileSync9, rmSync as rmSync7, writeFileSync as writeFileSync7 } from "node:fs";
3730
4228
  import { tmpdir } from "node:os";
3731
4229
  import { join as join10 } from "node:path";
3732
4230
 
@@ -3739,7 +4237,7 @@ var CLIENT = { name: "ezdesign", version: "1" };
3739
4237
  function askCodex(binary, method, params) {
3740
4238
  const child = spawn(binary, ["app-server"], { stdio: ["pipe", "pipe", "ignore"] });
3741
4239
  const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
3742
- return new Promise((resolve6) => {
4240
+ return new Promise((resolve7) => {
3743
4241
  let answer2;
3744
4242
  let rest = "";
3745
4243
  const send = (message2) => {
@@ -3764,8 +4262,8 @@ function askCodex(binary, method, params) {
3764
4262
  }
3765
4263
  }
3766
4264
  });
3767
- child.on("error", () => resolve6(void 0));
3768
- child.on("close", () => resolve6(answer2));
4265
+ child.on("error", () => resolve7(void 0));
4266
+ child.on("close", () => resolve7(answer2));
3769
4267
  send({ jsonrpc: "2.0", id: INITIALIZE, method: "initialize", params: { clientInfo: CLIENT } });
3770
4268
  }).finally(() => clearTimeout(timer));
3771
4269
  }
@@ -3902,25 +4400,25 @@ async function readClaudeLimit(binary) {
3902
4400
  });
3903
4401
  const timer = setTimeout(() => child.kill("SIGKILL"), READ_TIMEOUT_MS);
3904
4402
  try {
3905
- return await new Promise((resolve6) => {
4403
+ return await new Promise((resolve7) => {
3906
4404
  let out = "";
3907
4405
  child.stdout.setEncoding("utf8");
3908
4406
  child.stdout.on("data", (chunk) => out += chunk);
3909
- child.on("error", () => resolve6(void 0));
4407
+ child.on("error", () => resolve7(void 0));
3910
4408
  child.on("close", () => {
3911
4409
  try {
3912
4410
  const answer2 = JSON.parse(out).result;
3913
- resolve6(
4411
+ resolve7(
3914
4412
  typeof answer2 === "string" ? claudeLimitFromUsageText(answer2) ?? void 0 : void 0
3915
4413
  );
3916
4414
  } catch {
3917
- resolve6(void 0);
4415
+ resolve7(void 0);
3918
4416
  }
3919
4417
  });
3920
4418
  });
3921
4419
  } finally {
3922
4420
  clearTimeout(timer);
3923
- rmSync6(cwd, { recursive: true, force: true });
4421
+ rmSync7(cwd, { recursive: true, force: true });
3924
4422
  }
3925
4423
  }
3926
4424
  var lastRead = /* @__PURE__ */ new Map();
@@ -3935,12 +4433,13 @@ async function readLimit(command, now = Date.now()) {
3935
4433
  }
3936
4434
  var READ_TIMEOUT_MS = 6e4;
3937
4435
 
3938
- // src/agent-children.ts
4436
+ // src/child-groups.ts
3939
4437
  import { execFileSync } from "node:child_process";
3940
- import { mkdirSync as mkdirSync10, readFileSync as readFileSync10, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
4438
+ import { mkdirSync as mkdirSync10, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "node:fs";
3941
4439
  import { join as join11 } from "node:path";
3942
4440
  var FILE2 = join11(DATA_DIR, "agent-children.json");
3943
4441
  var live2 = /* @__PURE__ */ new Map();
4442
+ process.on("exit", () => killTrackedGroups());
3944
4443
  function read3() {
3945
4444
  try {
3946
4445
  return JSON.parse(readFileSync10(FILE2, "utf8"));
@@ -3967,7 +4466,7 @@ function running(pid) {
3967
4466
  return false;
3968
4467
  }
3969
4468
  }
3970
- function isStillTheAgent(pid, command) {
4469
+ function isStillTheProcess(pid, command) {
3971
4470
  try {
3972
4471
  const actual = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
3973
4472
  encoding: "utf8",
@@ -3988,17 +4487,17 @@ function killGroup(pid, signal = "SIGKILL") {
3988
4487
  }
3989
4488
  }
3990
4489
  }
3991
- function trackAgent(pid, command) {
4490
+ function trackGroup(pid, command) {
3992
4491
  live2.set(pid, command);
3993
4492
  write2();
3994
4493
  }
3995
- function untrackAgent(pid) {
4494
+ function untrackGroup(pid) {
3996
4495
  if (live2.delete(pid)) write2();
3997
4496
  }
3998
- function killTrackedAgents() {
4497
+ function killTrackedGroups() {
3999
4498
  for (const pid of live2.keys()) killGroup(pid);
4000
4499
  }
4001
- function reapOrphanedAgents() {
4500
+ function reapOrphanedGroups() {
4002
4501
  const record = read3();
4003
4502
  if (!record) {
4004
4503
  write2();
@@ -4007,17 +4506,17 @@ function reapOrphanedAgents() {
4007
4506
  if (record.daemonPid !== process.pid && running(record.daemonPid)) return 0;
4008
4507
  let killed = 0;
4009
4508
  for (const { pid, command } of record.children ?? []) {
4010
- if (pid === process.pid || !isStillTheAgent(pid, command)) continue;
4509
+ if (pid === process.pid || !isStillTheProcess(pid, command)) continue;
4011
4510
  killGroup(pid);
4012
4511
  killed++;
4013
4512
  }
4014
4513
  write2();
4015
4514
  return killed;
4016
4515
  }
4017
- function clearAgentRecord() {
4516
+ function clearGroupRecord() {
4018
4517
  live2.clear();
4019
4518
  try {
4020
- rmSync7(FILE2, { force: true });
4519
+ rmSync8(FILE2, { force: true });
4021
4520
  } catch {
4022
4521
  }
4023
4522
  }
@@ -4044,8 +4543,8 @@ var MessageQueue = class {
4044
4543
  next() {
4045
4544
  if (this.queued.length) return Promise.resolve(this.queued.shift());
4046
4545
  if (this.retired) return Promise.resolve(RETIRED);
4047
- return new Promise((resolve6) => {
4048
- this.waiting = resolve6;
4546
+ return new Promise((resolve7) => {
4547
+ this.waiting = resolve7;
4049
4548
  });
4050
4549
  }
4051
4550
  /** Wakes the reader and answers every later read. Retiring rather than
@@ -4114,7 +4613,6 @@ var FEED_CAP = 100;
4114
4613
  function trimTitle(title, cwd) {
4115
4614
  return title.replaceAll(`${cwd}/`, "");
4116
4615
  }
4117
- process.on("exit", () => killTrackedAgents());
4118
4616
  var AcpAgent = class {
4119
4617
  constructor(opts) {
4120
4618
  this.opts = opts;
@@ -4126,7 +4624,7 @@ var AcpAgent = class {
4126
4624
  stdio: ["pipe", "pipe", "pipe"],
4127
4625
  env: { ...process.env, ...opts.env }
4128
4626
  });
4129
- if (this.child.pid) trackAgent(this.child.pid, opts.command);
4627
+ if (this.child.pid) trackGroup(this.child.pid, opts.command);
4130
4628
  this.child.stderr?.on("data", (chunk) => {
4131
4629
  this.stderrTail = [...this.stderrTail, chunk.toString()].slice(-20);
4132
4630
  });
@@ -4320,10 +4818,10 @@ var AcpAgent = class {
4320
4818
  }
4321
4819
  settle(id, outcome) {
4322
4820
  if (!this.ask || this.ask.id !== id || !this.askResolve) return false;
4323
- const resolve6 = this.askResolve;
4821
+ const resolve7 = this.askResolve;
4324
4822
  this.ask = null;
4325
4823
  this.askResolve = null;
4326
- resolve6(outcome);
4824
+ resolve7(outcome);
4327
4825
  this.opts.onChange();
4328
4826
  return true;
4329
4827
  }
@@ -4518,11 +5016,11 @@ var AcpAgent = class {
4518
5016
  killGroup(pid, "SIGTERM");
4519
5017
  setTimeout(() => {
4520
5018
  killGroup(pid);
4521
- untrackAgent(pid);
5019
+ untrackGroup(pid);
4522
5020
  }, 3e3).unref();
4523
5021
  }
4524
5022
  untrack() {
4525
- if (this.child.pid !== void 0) untrackAgent(this.child.pid);
5023
+ if (this.child.pid !== void 0) untrackGroup(this.child.pid);
4526
5024
  }
4527
5025
  fail(message2) {
4528
5026
  if (this.state === "exited") return;
@@ -4538,10 +5036,10 @@ ${tail}` : message2;
4538
5036
  }
4539
5037
  /** A question nobody can answer any more must not hang the agent's request. */
4540
5038
  settleAsk() {
4541
- const resolve6 = this.askResolve;
5039
+ const resolve7 = this.askResolve;
4542
5040
  this.ask = null;
4543
5041
  this.askResolve = null;
4544
- resolve6?.(null);
5042
+ resolve7?.(null);
4545
5043
  }
4546
5044
  turnEnded(stopReason, epoch) {
4547
5045
  if (epoch !== this.epoch || this.state !== "working") return;
@@ -4557,19 +5055,19 @@ ${tail}` : message2;
4557
5055
  * was settled by a shutdown. One at a time by protocol shape: the agent
4558
5056
  * blocks on the request, so a second cannot arrive while one is pending. */
4559
5057
  pendAsk(ask) {
4560
- return new Promise((resolve6) => {
5058
+ return new Promise((resolve7) => {
4561
5059
  this.ask = { id: `ask-${++this.askSeq}`, ...ask };
4562
- this.askResolve = resolve6;
5060
+ this.askResolve = resolve7;
4563
5061
  this.opts.onChange();
4564
5062
  });
4565
5063
  }
4566
5064
  async requestPermission(params) {
4567
5065
  const toolName = params.toolCall.name ?? params.toolCall.title ?? "";
4568
5066
  if (this.opts.ownTool?.(toolName)) {
4569
- const once = params.options.find((o) => o.kind === "allow_once");
4570
- if (once) return { outcome: { outcome: "selected", optionId: once.optionId } };
5067
+ const once2 = params.options.find((o) => o.kind === "allow_once");
5068
+ if (once2) return { outcome: { outcome: "selected", optionId: once2.optionId } };
4571
5069
  }
4572
- const field = {
5070
+ const field3 = {
4573
5071
  key: "option",
4574
5072
  kind: "select",
4575
5073
  options: params.options.map((o) => ({ id: o.optionId, name: o.name, hint: o.kind }))
@@ -4577,7 +5075,7 @@ ${tail}` : message2;
4577
5075
  const outcome = await this.pendAsk({
4578
5076
  kind: "permission",
4579
5077
  title: params.toolCall.title ?? "The agent needs a decision",
4580
- fields: [field]
5078
+ fields: [field3]
4581
5079
  });
4582
5080
  const optionId = outcome && "answers" in outcome ? outcome.answers.option : void 0;
4583
5081
  if (typeof optionId !== "string") return { outcome: { outcome: "cancelled" } };
@@ -4729,8 +5227,8 @@ ${tail}` : message2;
4729
5227
  this.canFork = !!init.agentCapabilities?.sessionCapabilities?.fork;
4730
5228
  this.ctx = ctx;
4731
5229
  await this.openSession();
4732
- await new Promise((resolve6) => {
4733
- this.finish = resolve6;
5230
+ await new Promise((resolve7) => {
5231
+ this.finish = resolve7;
4734
5232
  });
4735
5233
  });
4736
5234
  }
@@ -4822,109 +5320,1378 @@ function worthListing(chat, index, all) {
4822
5320
  return !!chat.title || chat.current || index === all.length - 1;
4823
5321
  }
4824
5322
 
4825
- // src/explore.ts
4826
- var MAX_VARIANT_HTML = 32 * 1024;
4827
- var MAX_VARIANT_NAME = 40;
4828
- var FORBIDDEN_TAGS = ["script", "iframe", "object", "embed", "link", "meta", "base"];
4829
- function variantProblemMessage(problem) {
4830
- switch (problem.code) {
4831
- case "empty":
4832
- return "html is empty. Send the markup for one element.";
4833
- case "too-long":
4834
- return `html is ${problem.length} bytes; the limit is ${MAX_VARIANT_HTML}. Send one element's markup, not a page.`;
4835
- case "name-too-long":
4836
- return `name is ${problem.length} characters; the limit is ${MAX_VARIANT_NAME}. A short label, not a description.`;
4837
- case "forbidden-tag":
4838
- return `html contains <${problem.tag}>, which a variant may not use. A variant is markup and CSS only - no scripts, no embeds, no external resources.`;
4839
- case "event-handler":
4840
- return `html sets the inline handler ${problem.attribute}. A variant is markup and CSS only - no scripts.`;
4841
- case "javascript-url":
4842
- return "html contains a javascript: url. A variant is markup and CSS only - no scripts.";
4843
- case "not-one-root":
4844
- return problem.roots === 0 ? "html has no element at its top level. A variant is one root element, optionally with a <style> block inside it." : `html has ${problem.roots} top-level elements. A variant is one root element - wrap them in a single container.`;
5323
+ // src/dev-server/index.ts
5324
+ import { readFileSync as readFileSync13, realpathSync as realpathSync2 } from "node:fs";
5325
+ import { join as join15, relative as relative2 } from "node:path";
5326
+
5327
+ // src/login-env.ts
5328
+ import { spawn as spawn4 } from "node:child_process";
5329
+ import { randomBytes as randomBytes5 } from "node:crypto";
5330
+ import { accessSync, constants, readFileSync as readFileSync11 } from "node:fs";
5331
+ import { delimiter, join as join13 } from "node:path";
5332
+ var LOGIN_SHELL_TIMEOUT_MS = 1e4;
5333
+ var PROCESS_LOCAL_VARS = /* @__PURE__ */ new Set(["_", "SHLVL", "PWD", "OLDPWD", LOGIN_ENV_VAR, RUN_AS_NODE]);
5334
+ var adopted = null;
5335
+ function adoptLoginEnvironment() {
5336
+ adopted ??= resolveLoginEnvironment(process.env, LOGIN_ENV_CACHE);
5337
+ return adopted;
5338
+ }
5339
+ async function resolveLoginEnvironment(env, cacheFile) {
5340
+ const request2 = env[LOGIN_ENV_VAR];
5341
+ if (!request2) return { kind: "inherited" };
5342
+ if (request2 === "resolved") return { kind: "already-resolved" };
5343
+ const shell = env.SHELL || "/bin/zsh";
5344
+ const home = env.HOME ?? "";
5345
+ const cached = readCache(cacheFile, shell, home);
5346
+ const fresh = dumpLoginShell(shell, env).then((dumped2) => {
5347
+ if (dumped2.env) cacheDump(cacheFile, { shell, home, env: dumped2.env });
5348
+ return dumped2;
5349
+ });
5350
+ if (cached) {
5351
+ merge(env, cached);
5352
+ void fresh.catch(() => {
5353
+ });
5354
+ return { kind: "cached" };
4845
5355
  }
5356
+ const started = Date.now();
5357
+ const dumped = await fresh;
5358
+ if (!dumped.env) return { kind: "failed", shell, error: dumped.error };
5359
+ merge(env, dumped.env);
5360
+ return { kind: "resolved", shell, ms: Date.now() - started };
4846
5361
  }
4847
- function checkVariant(name, html) {
4848
- if (name.length > MAX_VARIANT_NAME) return { code: "name-too-long", length: name.length };
4849
- if (!html.trim()) return { code: "empty" };
4850
- if (html.length > MAX_VARIANT_HTML) return { code: "too-long", length: html.length };
4851
- const tags = scanTags(html);
4852
- if (!tags) return { code: "not-one-root", roots: 0 };
4853
- for (const tag of tags.names) {
4854
- if (FORBIDDEN_TAGS.includes(tag)) return { code: "forbidden-tag", tag };
5362
+ function cacheDump(file, cache) {
5363
+ try {
5364
+ writeAtomic(file, JSON.stringify(cache), 384);
5365
+ } catch {
4855
5366
  }
4856
- const handler = /\son([a-z]+)\s*=/i.exec(html);
4857
- if (handler) return { code: "event-handler", attribute: `on${handler[1].toLowerCase()}` };
4858
- if (/javascript:/i.test(html)) return { code: "javascript-url" };
4859
- if (tags.roots !== 1) return { code: "not-one-root", roots: tags.roots };
4860
- return null;
4861
5367
  }
4862
- var VOID_TAGS = /* @__PURE__ */ new Set([
4863
- "area",
4864
- "base",
4865
- "br",
4866
- "col",
4867
- "embed",
4868
- "hr",
4869
- "img",
4870
- "input",
4871
- "link",
4872
- "meta",
4873
- "param",
4874
- "source",
4875
- "track",
4876
- "wbr"
4877
- ]);
4878
- function scanTags(html) {
4879
- const names2 = [];
4880
- let depth = 0;
4881
- let roots = 0;
4882
- let i = 0;
4883
- while (i < html.length) {
4884
- const lt = html.indexOf("<", i);
4885
- if (lt === -1) break;
4886
- if (html.startsWith("<!--", lt)) {
4887
- const end2 = html.indexOf("-->", lt + 4);
4888
- i = end2 === -1 ? html.length : end2 + 3;
4889
- continue;
4890
- }
4891
- if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
4892
- const end2 = html.indexOf(">", lt);
4893
- i = end2 === -1 ? html.length : end2 + 1;
4894
- continue;
4895
- }
4896
- const tag = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 64));
4897
- if (!tag) {
4898
- i = lt + 1;
4899
- continue;
4900
- }
4901
- const end = closingBracket(html, lt);
4902
- if (end === -1) break;
4903
- const closing = tag[1] === "/";
4904
- const name = tag[2].toLowerCase();
4905
- names2.push(name);
4906
- const selfClosing = html[end - 1] === "/";
4907
- if (closing) {
4908
- if (depth === 0) return null;
4909
- depth -= 1;
4910
- } else if (!selfClosing && !VOID_TAGS.has(name)) {
4911
- if (depth === 0) roots += 1;
4912
- depth += 1;
4913
- } else if (depth === 0) {
4914
- roots += 1;
4915
- }
4916
- i = end + 1;
4917
- if (!closing && RAW_TEXT2.has(name) && !selfClosing) {
4918
- const close = html.toLowerCase().indexOf(`</${name}`, i);
4919
- if (close === -1) break;
4920
- i = close;
4921
- }
5368
+ function merge(env, dump) {
5369
+ for (const [key, value] of Object.entries(dump)) if (!PROCESS_LOCAL_VARS.has(key)) env[key] = value;
5370
+ env[LOGIN_ENV_VAR] = "resolved";
5371
+ }
5372
+ function readCache(file, shell, home) {
5373
+ try {
5374
+ const cache = JSON.parse(readFileSync11(file, "utf8"));
5375
+ return cache.shell === shell && cache.home === home && cache.env && typeof cache.env === "object" ? cache.env : null;
5376
+ } catch {
5377
+ return null;
4922
5378
  }
4923
- return { names: names2, roots };
4924
5379
  }
4925
- var RAW_TEXT2 = /* @__PURE__ */ new Set(["style", "script", "textarea", "title"]);
4926
- function closingBracket(html, from) {
4927
- let quote = null;
5380
+ function dumpLoginShell(shell, env) {
5381
+ const marker = randomBytes5(8).toString("hex");
5382
+ const { file, args } = loginShellInvocation(shell, marker);
5383
+ return new Promise((resolve7) => {
5384
+ const minimal = { HOME: env.HOME ?? "", USER: env.USER ?? "", LOGNAME: env.LOGNAME ?? "", SHELL: shell, TERM: "dumb" };
5385
+ const child = spawn4(file, args, { env: minimal, stdio: ["ignore", "pipe", "ignore"], detached: true });
5386
+ let stdout = "";
5387
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
5388
+ const timer = setTimeout(() => {
5389
+ try {
5390
+ process.kill(-child.pid, "SIGKILL");
5391
+ } catch {
5392
+ }
5393
+ }, LOGIN_SHELL_TIMEOUT_MS);
5394
+ child.on("error", (error) => {
5395
+ clearTimeout(timer);
5396
+ resolve7({ error: error.message });
5397
+ });
5398
+ child.on("close", () => {
5399
+ clearTimeout(timer);
5400
+ const dump = parseEnvDump(stdout, marker);
5401
+ resolve7(dump ? { env: dump } : { error: `${shell} printed no environment within ${LOGIN_SHELL_TIMEOUT_MS / 1e3} s` });
5402
+ });
5403
+ });
5404
+ }
5405
+ function loginShellInvocation(shell, marker) {
5406
+ return { file: shell, args: ["-i", "-l", "-c", `printf %s ${marker}; /usr/bin/env -0; printf %s ${marker}`] };
5407
+ }
5408
+ function parseEnvDump(stdout, marker) {
5409
+ const first = stdout.indexOf(marker);
5410
+ const last = stdout.lastIndexOf(marker);
5411
+ if (first === -1 || last === first) return null;
5412
+ const dump = {};
5413
+ for (const entry of stdout.slice(first + marker.length, last).split("\0")) {
5414
+ const eq = entry.indexOf("=");
5415
+ if (eq > 0) dump[entry.slice(0, eq)] = entry.slice(eq + 1);
5416
+ }
5417
+ return dump;
5418
+ }
5419
+ function findExecutable(binary, path) {
5420
+ for (const dir of (path ?? "").split(delimiter).filter(Boolean)) {
5421
+ const candidate = join13(dir, binary);
5422
+ try {
5423
+ accessSync(candidate, constants.X_OK);
5424
+ return candidate;
5425
+ } catch {
5426
+ }
5427
+ }
5428
+ return null;
5429
+ }
5430
+
5431
+ // src/dev-server/detect.ts
5432
+ import { readFileSync as readFileSync12, statSync as statSync4 } from "node:fs";
5433
+ import { join as join14 } from "node:path";
5434
+ var LOCKFILES_BY_PRECEDENCE = [
5435
+ { file: "pnpm-lock.yaml", manager: "pnpm" },
5436
+ { file: "yarn.lock", manager: "yarn" },
5437
+ { file: "bun.lock", manager: "bun" },
5438
+ { file: "bun.lockb", manager: "bun" },
5439
+ { file: "package-lock.json", manager: "npm" }
5440
+ ];
5441
+ function readProjectFacts(root) {
5442
+ const isFile = (name) => statSync4(join14(root, name), { throwIfNoEntry: false })?.isFile() === true;
5443
+ return {
5444
+ packageJson: readPackageJson(join14(root, "package.json")),
5445
+ lockfiles: LOCKFILES_BY_PRECEDENCE.filter((l) => isFile(l.file)).map((l) => l.file),
5446
+ pnpmWorkspace: isFile("pnpm-workspace.yaml")
5447
+ };
5448
+ }
5449
+ function readPackageJson(file) {
5450
+ let pkg2;
5451
+ try {
5452
+ pkg2 = JSON.parse(readFileSync12(file, "utf8"));
5453
+ } catch {
5454
+ return null;
5455
+ }
5456
+ if (!pkg2 || typeof pkg2 !== "object" || Array.isArray(pkg2)) return null;
5457
+ const { scripts, packageManager, workspaces } = pkg2;
5458
+ const named = scripts && typeof scripts === "object" ? Object.entries(scripts).filter((e) => typeof e[1] === "string") : [];
5459
+ return {
5460
+ scripts: Object.fromEntries(named),
5461
+ ...typeof packageManager === "string" ? { packageManager } : {},
5462
+ workspaces: Array.isArray(workspaces) || !!workspaces && typeof workspaces === "object" && Array.isArray(workspaces.packages)
5463
+ };
5464
+ }
5465
+ var MANAGERS = ["pnpm", "yarn", "bun", "npm"];
5466
+ var SERVER_LOOKING = /(^|[:_-])(dev|start|serve)([:_-]|$)/;
5467
+ function detectDevCommand(facts) {
5468
+ const pkg2 = facts.packageJson;
5469
+ if (!pkg2) return { kind: "undecided", reason: "no-package-json", serverScripts: [] };
5470
+ const script = "dev" in pkg2.scripts ? "dev" : "start" in pkg2.scripts ? "start" : null;
5471
+ if (!script) {
5472
+ const serverScripts = Object.keys(pkg2.scripts).filter((name) => SERVER_LOOKING.test(name));
5473
+ const atRoot = pkg2.workspaces || facts.pnpmWorkspace;
5474
+ return { kind: "undecided", reason: atRoot ? "monorepo-root" : "no-script", serverScripts };
5475
+ }
5476
+ const fromField = MANAGERS.find((m) => pkg2.packageManager?.split("@")[0] === m);
5477
+ if (fromField) return { kind: "command", command: { manager: fromField, script, managerFrom: "packageManager" } };
5478
+ const lock = LOCKFILES_BY_PRECEDENCE.find((l) => facts.lockfiles.includes(l.file));
5479
+ if (lock) return { kind: "command", command: { manager: lock.manager, script, managerFrom: "lockfile" } };
5480
+ return { kind: "command", command: { manager: "npm", script, managerFrom: "default" } };
5481
+ }
5482
+ function commandLine(command) {
5483
+ switch (command.manager) {
5484
+ case "npm":
5485
+ return command.script === "start" ? "npm start" : "npm run dev";
5486
+ case "bun":
5487
+ return `bun run ${command.script}`;
5488
+ default:
5489
+ return `${command.manager} ${command.script}`;
5490
+ }
5491
+ }
5492
+ function requiredTools(command) {
5493
+ return command.manager === "bun" ? ["bun"] : [command.manager, "node"];
5494
+ }
5495
+ function toolsForLine(line2) {
5496
+ const first = line2.trim().split(/\s+/)[0] ?? "";
5497
+ if (!/^[\w.+-]+$/.test(first)) return [];
5498
+ return ["npm", "npx", "pnpm", "yarn"].includes(first) ? [first, "node"] : [first];
5499
+ }
5500
+ var parts = (...items) => items.map((item) => typeof item === "string" ? { text: item } : item);
5501
+ var run = (command) => ({ command });
5502
+ var INSTALL = {
5503
+ node: parts("\u5F9E https://nodejs.org \u5B89\u88DD Node.js\uFF0C\u6216\u57F7\u884C ", run("brew install node")),
5504
+ npm: parts("npm \u96A8 Node.js \u4E00\u8D77\u5B89\u88DD\uFF1A\u5F9E https://nodejs.org \u5B89\u88DD Node.js"),
5505
+ pnpm: parts("\u57F7\u884C ", run("corepack enable pnpm"), "\uFF0C\u6216 ", run("npm install -g pnpm")),
5506
+ yarn: parts("\u57F7\u884C ", run("corepack enable yarn"), "\uFF0C\u6216 ", run("npm install -g yarn")),
5507
+ bun: parts("\u57F7\u884C ", run("curl -fsSL https://bun.sh/install | bash"), "\uFF0C\u6216 ", run("brew install oven-sh/bun/bun"))
5508
+ };
5509
+ function missingTools(tools, findOnPath) {
5510
+ return tools.filter((tool2) => findOnPath(tool2) === null).map((tool2) => ({ tool: tool2, install: INSTALL[tool2] ?? parts("\u5B89\u88DD ", run(tool2), "\uFF0C\u4E26\u78BA\u8A8D\u767B\u5165 shell \u7684 PATH \u627E\u5F97\u5230\u5B83") }));
5511
+ }
5512
+
5513
+ // src/dev-server/lifecycle.ts
5514
+ var INITIAL = { state: { phase: "idle" }, lastAttempt: 0, lastRunId: 0 };
5515
+ function step(machine, event) {
5516
+ const state = machine.state;
5517
+ const to = (next, effects = [], counters = {}) => ({
5518
+ machine: { ...machine, ...counters, state: next },
5519
+ effects,
5520
+ stateChanged: next !== state
5521
+ });
5522
+ const same = (effects = []) => ({ machine, effects, stateChanged: false });
5523
+ const current = (runId) => liveRun(state)?.id === runId;
5524
+ const resolveAfresh = (now, by) => {
5525
+ const attempt = machine.lastAttempt + 1;
5526
+ return to({ phase: "resolving", since: now, by, attempt }, [{ type: "resolve", attempt }], { lastAttempt: attempt });
5527
+ };
5528
+ const restartRun = (now, by) => {
5529
+ if (state.phase === "stopping") return to({ ...state, then: { kind: "restart", by, since: now } });
5530
+ const run4 = liveRun(state);
5531
+ return to({ phase: "stopping", run: run4, then: { kind: "restart", by, since: now } }, [
5532
+ { type: "kill", runId: run4.id },
5533
+ ...detachShown(state)
5534
+ ]);
5535
+ };
5536
+ const crash = (run4, cause, tail, exit = {}) => to(
5537
+ {
5538
+ phase: "crashed",
5539
+ command: run4.command,
5540
+ cause,
5541
+ ...exit.code !== void 0 && exit.code !== null ? { exitCode: exit.code } : {},
5542
+ ...exit.signal ? { signal: exit.signal } : {},
5543
+ tail
5544
+ },
5545
+ [...detachShown(state), NO_RESUME]
5546
+ );
5547
+ switch (event.type) {
5548
+ case "ensure":
5549
+ return RESTARTABLE.has(state.phase) ? resolveAfresh(event.now, event.by) : same();
5550
+ case "restart":
5551
+ if (liveRun(state)) return restartRun(event.now, event.by);
5552
+ return RESTARTABLE.has(state.phase) ? resolveAfresh(event.now, event.by) : same();
5553
+ case "confirmed":
5554
+ return liveRun(state) ? restartRun(event.now, event.by) : resolveAfresh(event.now, event.by);
5555
+ case "stop": {
5556
+ if (state.phase === "stopping") return to({ ...state, then: { kind: "stopped" } }, [NO_RESUME]);
5557
+ const run4 = liveRun(state);
5558
+ if (run4) {
5559
+ return to({ phase: "stopping", run: run4, then: { kind: "stopped" } }, [
5560
+ { type: "kill", runId: run4.id },
5561
+ ...detachShown(state),
5562
+ NO_RESUME
5563
+ ]);
5564
+ }
5565
+ if (state.phase === "resolving" || state.phase === "needs-command") return to({ phase: "stopped" }, [NO_RESUME]);
5566
+ if (state.phase === "blocked") return to({ phase: "stopped", command: state.command }, [NO_RESUME]);
5567
+ return same();
5568
+ }
5569
+ case "resolved": {
5570
+ if (state.phase !== "resolving" || state.attempt !== event.attempt) return same();
5571
+ const r = event.resolution;
5572
+ switch (r.kind) {
5573
+ case "run": {
5574
+ const run4 = { id: machine.lastRunId + 1, command: r.command, startedAt: event.now, by: state.by };
5575
+ return to(
5576
+ { phase: "starting", since: state.since, run: run4 },
5577
+ [
5578
+ { type: "spawn", run: run4 },
5579
+ { type: "arm-timers", runId: run4.id },
5580
+ { type: "persist-resume", resume: { by: state.by } }
5581
+ ],
5582
+ { lastRunId: run4.id }
5583
+ );
5584
+ }
5585
+ case "external":
5586
+ return to({ phase: "external", origin: r.origin }, [{ type: "attach", origin: r.origin }]);
5587
+ case "blocked":
5588
+ return to({ phase: "blocked", command: r.command, missing: r.missing });
5589
+ case "undecided":
5590
+ return to({ phase: "needs-command", reason: r.reason, serverScripts: r.serverScripts }, [{ type: "ask-agent" }]);
5591
+ }
5592
+ return same();
5593
+ }
5594
+ case "origins": {
5595
+ if (!current(event.runId) || !event.origins.length) return same();
5596
+ if (state.phase === "starting") {
5597
+ const origin = event.origins[0];
5598
+ return to({ phase: "running", run: state.run, origin, origins: event.origins }, [{ type: "attach", origin }]);
5599
+ }
5600
+ if (state.phase === "running" && event.origins.length > state.origins.length) {
5601
+ return to({ ...state, origins: event.origins });
5602
+ }
5603
+ return same();
5604
+ }
5605
+ case "scan-due":
5606
+ return state.phase === "starting" && current(event.runId) ? same([{ type: "scan-ports", runId: event.runId }]) : same();
5607
+ case "port-found":
5608
+ if (state.phase !== "starting" || !current(event.runId)) return same();
5609
+ return to({ phase: "running", run: state.run, origin: event.origin, origins: [event.origin] }, [
5610
+ { type: "attach", origin: event.origin }
5611
+ ]);
5612
+ case "ready-timeout": {
5613
+ if (state.phase !== "starting" || !current(event.runId)) return same();
5614
+ const crashed = crash(state.run, "no-url", event.tail);
5615
+ return { ...crashed, effects: [{ type: "kill", runId: event.runId }, ...crashed.effects] };
5616
+ }
5617
+ case "exited": {
5618
+ if (!current(event.runId)) return same();
5619
+ if (state.phase === "stopping") {
5620
+ return state.then.kind === "stopped" ? to({ phase: "stopped", command: state.run.command }) : resolveAfresh(state.then.since, state.then.by);
5621
+ }
5622
+ if (state.phase === "running" && event.code === 0) {
5623
+ return same([{ type: "confirm-external", runId: event.runId, origin: state.origin, tail: event.tail }]);
5624
+ }
5625
+ return crash(liveRun(state), "exited", event.tail, { code: event.code, signal: event.signal });
5626
+ }
5627
+ case "external-confirmed":
5628
+ if (state.phase !== "running" || !current(event.runId)) return same();
5629
+ return to({ phase: "external", origin: event.origin }, [{ type: "attach", origin: event.origin }, NO_RESUME]);
5630
+ case "external-gone":
5631
+ if (state.phase !== "running" || !current(event.runId)) return same();
5632
+ return crash(state.run, "exited", event.tail, { code: 0 });
5633
+ case "spawn-failed":
5634
+ if (state.phase !== "starting" || !current(event.runId)) return same();
5635
+ return crash(state.run, "spawn", [event.error]);
5636
+ case "ask-again":
5637
+ if (state.phase !== "needs-command" || state.proposal?.state === "asking") return same();
5638
+ return to({ phase: "needs-command", reason: state.reason, serverScripts: state.serverScripts }, [{ type: "ask-agent" }]);
5639
+ case "proposal":
5640
+ return state.phase === "needs-command" ? to({ ...state, proposal: event.proposal }) : same();
5641
+ case "command-forgotten":
5642
+ return state.phase === "blocked" || state.phase === "crashed" || state.phase === "stopped" ? to({ phase: "stopped" }) : same();
5643
+ case "origin-picked":
5644
+ if (state.phase !== "running" || state.origin === event.origin || !state.origins.includes(event.origin)) return same();
5645
+ return to({ ...state, origin: event.origin }, [{ type: "attach", origin: event.origin }]);
5646
+ }
5647
+ }
5648
+ var NO_RESUME = { type: "persist-resume", resume: null };
5649
+ var RESTARTABLE = /* @__PURE__ */ new Set(["idle", "stopped", "crashed", "blocked"]);
5650
+ function liveRun(state) {
5651
+ return state.phase === "starting" || state.phase === "running" || state.phase === "stopping" ? state.run : null;
5652
+ }
5653
+ function detachShown(state) {
5654
+ return state.phase === "running" ? [{ type: "detach", origin: state.origin }] : [];
5655
+ }
5656
+ var commandWire = (command) => ({ line: command.line, cwd: command.cwd, source: command.source });
5657
+ function toWire(state) {
5658
+ switch (state.phase) {
5659
+ case "idle":
5660
+ return { phase: "idle" };
5661
+ case "resolving":
5662
+ return { phase: "starting", since: state.since };
5663
+ case "starting":
5664
+ return { phase: "starting", command: commandWire(state.run.command), since: state.since };
5665
+ case "running":
5666
+ return {
5667
+ phase: "running",
5668
+ command: commandWire(state.run.command),
5669
+ origin: state.origin,
5670
+ origins: [...state.origins],
5671
+ since: state.run.startedAt
5672
+ };
5673
+ case "stopping":
5674
+ return state.then.kind === "restart" ? { phase: "starting", command: commandWire(state.run.command), since: state.then.since } : { phase: "stopped", command: commandWire(state.run.command) };
5675
+ case "external":
5676
+ return { phase: "external", origin: state.origin };
5677
+ case "needs-command":
5678
+ return {
5679
+ phase: "needs-command",
5680
+ reason: state.reason,
5681
+ serverScripts: [...state.serverScripts],
5682
+ ...state.proposal ? { proposal: state.proposal } : {}
5683
+ };
5684
+ case "blocked":
5685
+ return { phase: "blocked", command: commandWire(state.command), missing: [...state.missing] };
5686
+ case "stopped":
5687
+ return { phase: "stopped", ...state.command ? { command: commandWire(state.command) } : {} };
5688
+ case "crashed":
5689
+ return {
5690
+ phase: "crashed",
5691
+ command: commandWire(state.command),
5692
+ cause: state.cause,
5693
+ ...state.exitCode !== void 0 ? { exitCode: state.exitCode } : {},
5694
+ ...state.signal ? { signal: state.signal } : {},
5695
+ tail: [...state.tail]
5696
+ };
5697
+ }
5698
+ }
5699
+
5700
+ // src/dev-server/process.ts
5701
+ import { execFile, spawn as spawn5 } from "node:child_process";
5702
+ import { promisify } from "node:util";
5703
+
5704
+ // src/dev-server/urls.ts
5705
+ var ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
5706
+ function stripAnsi(text) {
5707
+ return text.replace(ANSI, "");
5708
+ }
5709
+ var URL_IN_TEXT = /\bhttps?:\/\/[^\s'"<>`)]+/g;
5710
+ var LOCAL_HOSTS = {
5711
+ localhost: "localhost",
5712
+ "127.0.0.1": "127.0.0.1",
5713
+ "[::1]": "[::1]",
5714
+ "0.0.0.0": "localhost",
5715
+ "[::]": "localhost"
5716
+ };
5717
+ function localOriginsIn(line2) {
5718
+ const found = [];
5719
+ for (const [raw] of stripAnsi(line2).matchAll(URL_IN_TEXT)) {
5720
+ let url;
5721
+ try {
5722
+ url = new URL(raw.replace(/[.,;:!?\]}]+$/, ""));
5723
+ } catch {
5724
+ continue;
5725
+ }
5726
+ const host = LOCAL_HOSTS[url.hostname];
5727
+ if (!host) continue;
5728
+ found.push(`${url.protocol}//${host}${url.port ? `:${url.port}` : ""}`);
5729
+ }
5730
+ return found;
5731
+ }
5732
+ var OriginScanner = class {
5733
+ partial = "";
5734
+ seen = [];
5735
+ push(chunk) {
5736
+ const text = this.partial + chunk;
5737
+ const parts2 = text.split(/\r?\n/);
5738
+ this.partial = parts2.pop() ?? "";
5739
+ return parts2.map((line2) => this.take(line2));
5740
+ }
5741
+ flush() {
5742
+ const rest = this.partial;
5743
+ this.partial = "";
5744
+ return rest ? [this.take(rest)] : [];
5745
+ }
5746
+ get origins() {
5747
+ return this.seen;
5748
+ }
5749
+ take(line2) {
5750
+ const visible = stripAnsi(line2).split("\r").at(-1) ?? "";
5751
+ for (const origin of localOriginsIn(visible)) if (!this.seen.includes(origin)) this.seen.push(origin);
5752
+ return visible;
5753
+ }
5754
+ };
5755
+ function parseListeningPorts(lsofOutput) {
5756
+ const ports = /* @__PURE__ */ new Set();
5757
+ for (const line2 of lsofOutput.split("\n")) {
5758
+ if (!line2.startsWith("n")) continue;
5759
+ const port = Number(line2.slice(line2.lastIndexOf(":") + 1));
5760
+ if (Number.isInteger(port) && port > 0 && port < 65536) ports.add(port);
5761
+ }
5762
+ return [...ports].sort((a, b) => a - b);
5763
+ }
5764
+
5765
+ // src/dev-server/process.ts
5766
+ var DEV_SERVER_ENV = { BROWSER: "none" };
5767
+ var ManagedProcess = class _ManagedProcess {
5768
+ constructor(pid) {
5769
+ this.pid = pid;
5770
+ this.closed = new Promise((resolve7) => this.markClosed = resolve7);
5771
+ }
5772
+ scanner = new OriginScanner();
5773
+ exitedAt = null;
5774
+ /** Settles once the leader has exited and its pipes are drained. */
5775
+ closed;
5776
+ markClosed;
5777
+ /** Never throws: a shell that cannot start reports through `spawnFailed`. */
5778
+ static launch(spec, log, listener) {
5779
+ let child;
5780
+ try {
5781
+ child = spawn5(spec.line, {
5782
+ shell: true,
5783
+ detached: true,
5784
+ cwd: spec.absoluteCwd,
5785
+ stdio: ["ignore", "pipe", "pipe"],
5786
+ env: { ...process.env, ...DEV_SERVER_ENV }
5787
+ });
5788
+ } catch (error) {
5789
+ const proc2 = new _ManagedProcess(void 0);
5790
+ proc2.markClosed();
5791
+ queueMicrotask(() => listener.spawnFailed(error instanceof Error ? error.message : String(error)));
5792
+ return proc2;
5793
+ }
5794
+ const proc = new _ManagedProcess(child.pid);
5795
+ const pid = child.pid;
5796
+ if (pid) trackGroup(pid, spec.line);
5797
+ const take = (chunk) => {
5798
+ const lines = proc.scanner.push(chunk.toString());
5799
+ if (!lines.length) return;
5800
+ log.push(lines);
5801
+ listener.output(lines, proc.scanner.origins);
5802
+ };
5803
+ child.stdout?.on("data", take);
5804
+ child.stderr?.on("data", take);
5805
+ child.on("exit", () => {
5806
+ if (!pid) return;
5807
+ try {
5808
+ process.kill(-pid, "SIGTERM");
5809
+ } catch {
5810
+ }
5811
+ });
5812
+ child.on("error", (error) => {
5813
+ if (pid) untrackGroup(pid);
5814
+ if (proc.exitedAt !== null) return;
5815
+ proc.exitedAt = Date.now();
5816
+ proc.markClosed();
5817
+ listener.spawnFailed(error.message);
5818
+ });
5819
+ child.on("close", (code, signal) => {
5820
+ if (pid) untrackGroup(pid);
5821
+ if (proc.exitedAt !== null) return;
5822
+ const rest = proc.scanner.flush();
5823
+ if (rest.length) {
5824
+ log.push(rest);
5825
+ listener.output(rest, proc.scanner.origins);
5826
+ }
5827
+ proc.exitedAt = Date.now();
5828
+ proc.markClosed();
5829
+ listener.exited(code, signal);
5830
+ });
5831
+ return proc;
5832
+ }
5833
+ get running() {
5834
+ return this.pid !== void 0 && this.exitedAt === null;
5835
+ }
5836
+ async stop(graceMs) {
5837
+ const pid = this.pid;
5838
+ if (!this.running || pid === void 0) return;
5839
+ killGroup(pid, graceMs > 0 ? "SIGTERM" : "SIGKILL");
5840
+ const hard = setTimeout(() => killGroup(pid, "SIGKILL"), graceMs);
5841
+ hard.unref();
5842
+ await this.closed;
5843
+ clearTimeout(hard);
5844
+ }
5845
+ async listeningOrigins() {
5846
+ if (!this.running || this.pid === void 0) return [];
5847
+ const pids = (await run2("pgrep", ["-g", String(this.pid)])).split(/\s+/).filter(Boolean);
5848
+ if (!pids.length) return [];
5849
+ const listening = await run2("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(","), "-F", "pn"]);
5850
+ return parseListeningPorts(listening).map((port) => `http://localhost:${port}`);
5851
+ }
5852
+ };
5853
+ var execFileAsync = promisify(execFile);
5854
+ async function run2(file, args) {
5855
+ try {
5856
+ return (await execFileAsync(file, args, { encoding: "utf8", timeout: 5e3 })).stdout;
5857
+ } catch {
5858
+ return "";
5859
+ }
5860
+ }
5861
+ var LogRing = class {
5862
+ constructor(cap = DEV_SERVER_LOG_LINES) {
5863
+ this.cap = cap;
5864
+ }
5865
+ lines = [];
5866
+ dropped = 0;
5867
+ push(lines) {
5868
+ this.lines.push(...lines);
5869
+ const over = this.lines.length - this.cap;
5870
+ if (over > 0) {
5871
+ this.lines.splice(0, over);
5872
+ this.dropped += over;
5873
+ }
5874
+ }
5875
+ tail(count2) {
5876
+ return this.lines.slice(-count2);
5877
+ }
5878
+ clear() {
5879
+ this.lines = [];
5880
+ this.dropped = 0;
5881
+ }
5882
+ wire() {
5883
+ return { lines: [...this.lines], dropped: this.dropped };
5884
+ }
5885
+ };
5886
+ async function originAnswers(origin, timeoutMs = 1500) {
5887
+ try {
5888
+ const res = await fetch(origin, { redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
5889
+ await res.body?.cancel();
5890
+ return true;
5891
+ } catch {
5892
+ return false;
5893
+ }
5894
+ }
5895
+ async function listenerCwd(origin) {
5896
+ let port;
5897
+ try {
5898
+ const url = new URL(origin);
5899
+ port = url.port || (url.protocol === "https:" ? "443" : "80");
5900
+ } catch {
5901
+ return { kind: "unknown" };
5902
+ }
5903
+ const pid = (await run2("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"])).split(/\s+/).find(Boolean);
5904
+ if (!pid) return { kind: "unknown" };
5905
+ const cwd = (await run2("lsof", ["-a", "-p", pid, "-d", "cwd", "-Fn"])).split("\n").find((l) => l.startsWith("n"));
5906
+ return cwd ? { kind: "known", path: cwd.slice(1) } : { kind: "unknown" };
5907
+ }
5908
+
5909
+ // src/dev-server/proposal.ts
5910
+ import { randomBytes as randomBytes6 } from "node:crypto";
5911
+ import { mkdirSync as mkdirSync11, realpathSync, statSync as statSync5 } from "node:fs";
5912
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5913
+ var PROPOSAL_TIMEOUT_MS = 18e4;
5914
+ var MAX_LINE = 500;
5915
+ async function askAgentForCommand(req) {
5916
+ try {
5917
+ return await askOnce(req);
5918
+ } catch (error) {
5919
+ return { kind: "failed", error: error instanceof Error ? error.message : String(error) };
5920
+ }
5921
+ }
5922
+ function askOnce(req) {
5923
+ mkdirSync11(ACP_NPM_PREFIX, { recursive: true });
5924
+ return new Promise((settle) => {
5925
+ let asked = false;
5926
+ let done = false;
5927
+ const finish = (outcome) => {
5928
+ if (done) return;
5929
+ done = true;
5930
+ clearTimeout(timer);
5931
+ req.signal.removeEventListener("abort", aborted);
5932
+ agent.stop();
5933
+ settle(outcome);
5934
+ };
5935
+ const agent = new AcpAgent({
5936
+ command: req.agent,
5937
+ launch: launchCommand(req.agent, ACP_NPM_PREFIX),
5938
+ cwd: req.project,
5939
+ onChange: () => {
5940
+ const snap = agent.snapshot();
5941
+ if (snap.ask) agent.decline(snap.ask.id);
5942
+ if (snap.state === "idle" && !asked) {
5943
+ asked = true;
5944
+ agent.prompt(proposalPrompt(req));
5945
+ }
5946
+ },
5947
+ onTurnEnd: (reply) => finish(fromReply(reply, req)),
5948
+ onExit: (error) => finish({ kind: "failed", error: error ?? "the agent exited before it answered" })
5949
+ });
5950
+ const timer = setTimeout(() => finish({ kind: "failed", error: "the agent did not answer in time" }), PROPOSAL_TIMEOUT_MS);
5951
+ const aborted = () => finish({ kind: "failed", error: "no longer needed" });
5952
+ req.signal.addEventListener("abort", aborted, { once: true });
5953
+ });
5954
+ }
5955
+ var REASONS = {
5956
+ "no-package-json": "There is no package.json at the project root.",
5957
+ "no-script": 'package.json has neither a "dev" nor a "start" script.',
5958
+ "monorepo-root": 'This is a workspace root with neither a "dev" nor a "start" script.'
5959
+ };
5960
+ function proposingAgent(preferred, installed) {
5961
+ const usable = (command) => {
5962
+ const profile = agentProfileFor(command);
5963
+ return !profile || installed(profile);
5964
+ };
5965
+ return [...preferred, ...AGENT_PROFILES.map((p) => p.command)].find((c) => !!c && usable(c)) ?? null;
5966
+ }
5967
+ function proposalPrompt(req) {
5968
+ const scripts = Object.entries(req.facts.packageJson?.scripts ?? {});
5969
+ return [
5970
+ "ezdesign wants to start this project's local web dev server so the user can review the page.",
5971
+ `Please propose the command that starts this project's dev server, from ${req.project}.`,
5972
+ "",
5973
+ `Its own rules could not decide: ${REASONS[req.undecided.reason]}`,
5974
+ scripts.length ? `Scripts in package.json:
5975
+ ${scripts.map(([name, line2]) => `- ${name}: ${line2}`).join("\n")}` : "",
5976
+ req.facts.lockfiles.length ? `Lockfiles: ${req.facts.lockfiles.join(", ")}` : "No lockfile.",
5977
+ "",
5978
+ "Read files to decide; do not run anything or change anything - every such request will be declined.",
5979
+ "Choose the one web app a designer would want to see, and the command that serves it with live reload.",
5980
+ "End your answer with exactly one fenced json block:",
5981
+ "```json",
5982
+ '{"command": "pnpm --filter web dev", "cwd": ".", "reason": "one sentence"}',
5983
+ "```",
5984
+ "`cwd` is relative to the project root. If nothing here serves a web page, answer",
5985
+ '`{"command": null, "reason": "one sentence"}` instead.'
5986
+ ].filter((line2, i, all) => line2 || all[i - 1]).join("\n");
5987
+ }
5988
+ function parseProposalReply(reply) {
5989
+ const blocks = [...reply.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)];
5990
+ const last = blocks.at(-1)?.[1];
5991
+ if (!last) return null;
5992
+ let parsed;
5993
+ try {
5994
+ parsed = JSON.parse(last);
5995
+ } catch {
5996
+ return null;
5997
+ }
5998
+ if (!parsed || typeof parsed !== "object") return null;
5999
+ const { command, cwd, reason } = parsed;
6000
+ const why = typeof reason === "string" ? reason.slice(0, MAX_LINE) : "";
6001
+ if (command === null) return { kind: "none", reason: why };
6002
+ if (typeof command !== "string") return null;
6003
+ const line2 = command.trim();
6004
+ if (!line2 || line2.length > MAX_LINE || /[\0\r\n]/.test(line2)) return null;
6005
+ const dir = typeof cwd === "string" && cwd.trim() ? cwd.trim() : ".";
6006
+ if (isAbsolute(dir)) return null;
6007
+ return { kind: "command", line: line2, cwd: dir, reason: why };
6008
+ }
6009
+ function fromReply(reply, req) {
6010
+ const parsed = parseProposalReply(reply);
6011
+ if (!parsed) return { kind: "failed", error: "the agent did not answer with a command" };
6012
+ if (parsed.kind === "none") return { kind: "none-found", reason: parsed.reason };
6013
+ const cwd = projectRelativePath(req.project, parsed.cwd);
6014
+ if (cwd === null) return { kind: "failed", error: `the agent named a folder outside the project: ${parsed.cwd}` };
6015
+ return {
6016
+ kind: "proposed",
6017
+ proposal: { id: randomBytes6(8).toString("hex"), agent: req.agent, line: parsed.line, cwd, reason: parsed.reason }
6018
+ };
6019
+ }
6020
+ function projectRelativePath(project, cwd) {
6021
+ if (isAbsolute(cwd)) return null;
6022
+ try {
6023
+ const root = realpathSync(project);
6024
+ const target = realpathSync(resolve(root, cwd));
6025
+ if (!statSync5(target).isDirectory()) return null;
6026
+ const rel = relative(root, target);
6027
+ return rel === "" || !rel.startsWith("..") && !rel.startsWith(sep) && !isAbsolute(rel) ? rel : null;
6028
+ } catch {
6029
+ return null;
6030
+ }
6031
+ }
6032
+
6033
+ // src/dev-server/routes.ts
6034
+ import { Router as Router2 } from "express";
6035
+ function initiator(req) {
6036
+ return req.body?.host === "desktop" ? "desktop" : "browser";
6037
+ }
6038
+ var field = (req, name) => req.body?.[name];
6039
+ function devServerApiRouter(devServer) {
6040
+ const router = Router2();
6041
+ router.get("/dev-server/log", (_req, res) => {
6042
+ res.set("cache-control", "no-store").json(devServer.logWire());
6043
+ });
6044
+ router.post("/dev-server/start", (req, res) => {
6045
+ devServer.ensure(initiator(req));
6046
+ res.status(202).json({ ok: true });
6047
+ });
6048
+ router.post("/dev-server/restart", (req, res) => {
6049
+ devServer.restart(initiator(req));
6050
+ res.status(202).json({ ok: true });
6051
+ });
6052
+ router.post("/dev-server/stop", async (_req, res) => {
6053
+ if (devServer.external) return void res.status(409).json({ error: "this dev server was not started here" });
6054
+ await devServer.stop();
6055
+ res.json({ ok: true });
6056
+ });
6057
+ router.post("/dev-server/command", (req, res) => {
6058
+ const line2 = field(req, "line");
6059
+ const cwd = field(req, "cwd") ?? "";
6060
+ if (typeof line2 !== "string" || typeof cwd !== "string") {
6061
+ return void res.status(400).json({ error: "line and cwd must be strings" });
6062
+ }
6063
+ const proposalId = field(req, "proposalId");
6064
+ const choice = { line: line2, cwd, ...typeof proposalId === "string" ? { proposalId } : {} };
6065
+ const outcome = devServer.saveCommandAndRun(choice, initiator(req));
6066
+ if (outcome === "invalid-line") return void res.status(400).json({ error: "the command must be one line of 1-500 characters" });
6067
+ if (outcome === "invalid-cwd") return void res.status(400).json({ error: "the folder must be inside the project" });
6068
+ res.status(202).json({ ok: true });
6069
+ });
6070
+ router.delete("/dev-server/command", (_req, res) => {
6071
+ devServer.forgetCommand();
6072
+ res.json({ ok: true });
6073
+ });
6074
+ router.post("/dev-server/propose", (_req, res) => {
6075
+ devServer.askAgentAgain();
6076
+ res.status(202).json({ ok: true });
6077
+ });
6078
+ router.post("/dev-server/origin", (req, res) => {
6079
+ const origin = field(req, "origin");
6080
+ if (typeof origin !== "string" || !devServer.pickOrigin(origin)) {
6081
+ return void res.status(404).json({ error: "not an origin this dev server printed" });
6082
+ }
6083
+ res.json({ ok: true });
6084
+ });
6085
+ return router;
6086
+ }
6087
+
6088
+ // src/dev-server/index.ts
6089
+ function isTheAgentsProposal(offered, proposalId, line2, cwd) {
6090
+ return !!offered && proposalId === offered.id && offered.line === line2 && offered.cwd === cwd;
6091
+ }
6092
+ var CRASH_TAIL_LINES = 30;
6093
+ var PORT_SCAN_EVERY_MS = 1e3;
6094
+ var MAX_LINE2 = 500;
6095
+ var DevServer = class {
6096
+ constructor(deps) {
6097
+ this.deps = deps;
6098
+ }
6099
+ machine = INITIAL;
6100
+ live = null;
6101
+ log = new LogRing();
6102
+ proposal = null;
6103
+ startPreview = null;
6104
+ snapshot() {
6105
+ const wire = toWire(this.machine.state);
6106
+ if (wire.phase !== "idle" && wire.phase !== "stopped" || wire.command) return wire;
6107
+ const { command } = this.upcoming();
6108
+ return command ? { ...wire, command } : wire;
6109
+ }
6110
+ summary() {
6111
+ const wire = toWire(this.machine.state);
6112
+ return "origin" in wire ? { phase: wire.phase, origin: wire.origin } : { phase: wire.phase };
6113
+ }
6114
+ offersApp() {
6115
+ return this.machine.state.phase !== "idle" || this.upcoming().runnable;
6116
+ }
6117
+ logWire() {
6118
+ return this.log.wire();
6119
+ }
6120
+ /** Idempotent: a no-op while starting, running or external; from anywhere
6121
+ * else it reuses (D7), runs, or waits for a command. Never throws and never
6122
+ * waits - progress arrives through `onChange`. */
6123
+ ensure(by) {
6124
+ this.dispatch({ type: "ensure", now: Date.now(), by });
6125
+ }
6126
+ restart(by) {
6127
+ this.dispatch({ type: "restart", now: Date.now(), by });
6128
+ }
6129
+ get managedBy() {
6130
+ const state = this.machine.state;
6131
+ return state.phase === "starting" || state.phase === "running" || state.phase === "stopping" ? state.run.by : null;
6132
+ }
6133
+ get external() {
6134
+ return this.machine.state.phase === "external";
6135
+ }
6136
+ /** The daemon is exiting: the group goes, gracefully, and what the record
6137
+ * says to resume stays for the next daemon. */
6138
+ async release() {
6139
+ if (this.live?.process.running) await this.live.process.stop(DEV_SERVER_STOP_GRACE_MS);
6140
+ }
6141
+ /** Resolves once the process group is gone (or at once when there was none).
6142
+ * A server this daemon did not start is left alone (D7). */
6143
+ async stop() {
6144
+ const live3 = this.live;
6145
+ this.dispatch({ type: "stop" });
6146
+ if (live3?.process.running) await live3.process.closed;
6147
+ }
6148
+ saveCommandAndRun(choice, by) {
6149
+ const line2 = choice.line.trim();
6150
+ if (!line2 || line2.length > MAX_LINE2 || /[\0\r\n]/.test(line2)) return "invalid-line";
6151
+ const cwd = projectRelativePath(this.deps.project, choice.cwd.trim() || ".");
6152
+ if (cwd === null) return "invalid-cwd";
6153
+ const source = isTheAgentsProposal(this.proposal?.live, choice.proposalId, line2, cwd) ? "agent" : "user";
6154
+ this.writeRecord({ ...this.readRecord(), command: { line: line2, cwd, source, decidedAt: Date.now() } });
6155
+ this.dispatch({ type: "confirmed", now: Date.now(), by });
6156
+ return "started";
6157
+ }
6158
+ askAgentAgain() {
6159
+ this.dispatch({ type: "ask-again" });
6160
+ }
6161
+ forgetCommand() {
6162
+ const { command: _, ...rest } = this.readRecord();
6163
+ this.writeRecord(rest);
6164
+ this.startPreview = null;
6165
+ this.dispatch({ type: "command-forgotten" });
6166
+ this.deps.onChange();
6167
+ }
6168
+ pickOrigin(origin) {
6169
+ const state = this.machine.state;
6170
+ if (state.phase !== "running" || !state.origins.includes(origin)) return false;
6171
+ this.dispatch({ type: "origin-picked", origin });
6172
+ return true;
6173
+ }
6174
+ resume() {
6175
+ const resume = this.readRecord().resume;
6176
+ if (resume) this.ensure(resume.by);
6177
+ }
6178
+ forgetResume() {
6179
+ const record = this.readRecord();
6180
+ if (!record.resume) return;
6181
+ const { resume: _, ...rest } = record;
6182
+ this.writeRecord(rest);
6183
+ }
6184
+ dispatch(event) {
6185
+ const { machine, effects, stateChanged } = step(this.machine, event);
6186
+ if (this.proposal && machine.state.phase !== "needs-command") {
6187
+ this.proposal.abort.abort();
6188
+ this.proposal = null;
6189
+ }
6190
+ this.machine = machine;
6191
+ if (stateChanged) this.startPreview = null;
6192
+ for (const effect of effects) this.perform(effect);
6193
+ if (stateChanged) this.deps.onChange();
6194
+ }
6195
+ dispatchAfterThisStep(event) {
6196
+ queueMicrotask(() => this.dispatch(event));
6197
+ }
6198
+ reportAlreadyExited(runId) {
6199
+ this.dispatchAfterThisStep({ type: "exited", runId, code: null, signal: null, tail: this.log.tail(CRASH_TAIL_LINES) });
6200
+ }
6201
+ perform(effect) {
6202
+ switch (effect.type) {
6203
+ case "resolve": {
6204
+ const { attempt } = effect;
6205
+ void this.resolve().then((resolution) => this.dispatch({ type: "resolved", attempt, resolution, now: Date.now() }));
6206
+ return;
6207
+ }
6208
+ case "spawn": {
6209
+ const { run: run4 } = effect;
6210
+ const runId = run4.id;
6211
+ this.log.clear();
6212
+ this.log.push([`$ ${run4.command.line}`]);
6213
+ const spec = { line: run4.command.line, absoluteCwd: join15(this.deps.project, run4.command.cwd) };
6214
+ const managed = ManagedProcess.launch(spec, this.log, {
6215
+ output: (_, origins) => this.dispatch({ type: "origins", runId, origins }),
6216
+ exited: (code, signal) => this.dispatch({ type: "exited", runId, code, signal, tail: this.log.tail(CRASH_TAIL_LINES) }),
6217
+ spawnFailed: (error) => this.dispatch({ type: "spawn-failed", runId, error })
6218
+ });
6219
+ this.live = { runId, process: managed };
6220
+ return;
6221
+ }
6222
+ case "arm-timers": {
6223
+ const { runId } = effect;
6224
+ setTimeout(() => this.dispatch({ type: "scan-due", runId }), DEV_SERVER_PORT_SCAN_AFTER_MS).unref();
6225
+ setTimeout(
6226
+ () => this.dispatch({ type: "ready-timeout", runId, tail: this.log.tail(CRASH_TAIL_LINES) }),
6227
+ DEV_SERVER_READY_MS
6228
+ ).unref();
6229
+ return;
6230
+ }
6231
+ case "scan-ports": {
6232
+ const { runId } = effect;
6233
+ const managed = this.live?.runId === runId ? this.live.process : null;
6234
+ if (!managed) return;
6235
+ void (async () => {
6236
+ for (const origin of await managed.listeningOrigins()) {
6237
+ if (await originAnswers(origin)) return this.dispatch({ type: "port-found", runId, origin });
6238
+ }
6239
+ setTimeout(() => this.dispatch({ type: "scan-due", runId }), PORT_SCAN_EVERY_MS).unref();
6240
+ })();
6241
+ return;
6242
+ }
6243
+ case "kill": {
6244
+ const { runId } = effect;
6245
+ const managed = this.live?.runId === runId ? this.live.process : null;
6246
+ if (managed?.running) void managed.stop(DEV_SERVER_STOP_GRACE_MS);
6247
+ else this.reportAlreadyExited(runId);
6248
+ return;
6249
+ }
6250
+ case "confirm-external": {
6251
+ const { runId, origin, tail } = effect;
6252
+ void originAnswers(origin).then(
6253
+ (alive) => this.dispatch(alive ? { type: "external-confirmed", runId, origin } : { type: "external-gone", runId, tail })
6254
+ );
6255
+ return;
6256
+ }
6257
+ case "attach":
6258
+ return this.deps.attach(effect.origin);
6259
+ case "detach":
6260
+ return this.deps.detachLeavingRecord(effect.origin);
6261
+ case "ask-agent": {
6262
+ this.proposal?.abort.abort();
6263
+ this.proposal = null;
6264
+ const agent = this.deps.proposingAgent();
6265
+ if (!agent) return;
6266
+ const asking = { live: null, abort: new AbortController() };
6267
+ this.proposal = asking;
6268
+ this.dispatchAfterThisStep({ type: "proposal", proposal: { state: "asking", agent } });
6269
+ const facts = readProjectFacts(this.deps.project);
6270
+ const undecided = detectDevCommand(facts);
6271
+ if (undecided.kind !== "undecided") return;
6272
+ void askAgentForCommand({ agent, project: this.deps.project, facts, undecided, signal: asking.abort.signal }).then(
6273
+ (outcome) => {
6274
+ if (this.proposal !== asking) return;
6275
+ if (outcome.kind === "proposed") asking.live = outcome.proposal;
6276
+ this.dispatch({
6277
+ type: "proposal",
6278
+ proposal: outcome.kind === "proposed" ? { state: "proposed", ...outcome.proposal } : outcome.kind === "none-found" ? { state: "none-found", agent, reason: outcome.reason } : { state: "failed", agent, error: outcome.error }
6279
+ });
6280
+ }
6281
+ );
6282
+ return;
6283
+ }
6284
+ case "persist-resume": {
6285
+ const { resume: _, ...rest } = this.readRecord();
6286
+ this.writeRecord(effect.resume ? { ...rest, resume: effect.resume } : rest);
6287
+ return;
6288
+ }
6289
+ }
6290
+ }
6291
+ async resolve() {
6292
+ await this.deps.environmentReady();
6293
+ const recorded = this.deps.userAttachedOrigin();
6294
+ if (recorded && !this.deps.heldElsewhere(recorded) && await originAnswers(recorded)) {
6295
+ const cwd = await listenerCwd(recorded);
6296
+ if (cwd.kind === "unknown" || this.contains(cwd.path)) return { kind: "external", origin: recorded };
6297
+ }
6298
+ const stored = this.storedCommand();
6299
+ let command;
6300
+ let tools;
6301
+ if (stored) {
6302
+ command = stored;
6303
+ tools = toolsForLine(stored.line);
6304
+ } else {
6305
+ const detection = detectDevCommand(readProjectFacts(this.deps.project));
6306
+ if (detection.kind === "undecided") {
6307
+ return { kind: "undecided", reason: detection.reason, serverScripts: detection.serverScripts };
6308
+ }
6309
+ command = { line: commandLine(detection.command), cwd: "", source: "detected" };
6310
+ tools = requiredTools(detection.command);
6311
+ }
6312
+ const missing = missingTools(tools, (binary) => findExecutable(binary, process.env.PATH));
6313
+ return missing.length ? { kind: "blocked", command, missing } : { kind: "run", command };
6314
+ }
6315
+ contains(path) {
6316
+ try {
6317
+ return projectRelativePath(this.deps.project, relative2(realpathSync2(this.deps.project), path)) !== null;
6318
+ } catch {
6319
+ return false;
6320
+ }
6321
+ }
6322
+ upcoming() {
6323
+ if (this.startPreview) return this.startPreview;
6324
+ const stored = this.storedCommand();
6325
+ if (stored) return this.startPreview = { command: stored, runnable: true };
6326
+ const detection = detectDevCommand(readProjectFacts(this.deps.project));
6327
+ this.startPreview = detection.kind === "command" ? { command: { line: commandLine(detection.command), cwd: "", source: "detected" }, runnable: true } : { command: null, runnable: detection.reason === "monorepo-root" || detection.serverScripts.length > 0 };
6328
+ return this.startPreview;
6329
+ }
6330
+ readRecord() {
6331
+ try {
6332
+ return JSON.parse(readFileSync13(join15(this.deps.dir, RECORD_FILE), "utf8"));
6333
+ } catch {
6334
+ return {};
6335
+ }
6336
+ }
6337
+ writeRecord(record) {
6338
+ writeAtomic(join15(this.deps.dir, RECORD_FILE), `${JSON.stringify(record, null, 2)}
6339
+ `);
6340
+ }
6341
+ storedCommand() {
6342
+ const stored = this.readRecord().command;
6343
+ return stored ? { line: stored.line, cwd: stored.cwd, source: stored.source } : null;
6344
+ }
6345
+ };
6346
+ var RECORD_FILE = "dev-server.json";
6347
+
6348
+ // src/desktop-wire.ts
6349
+ var DESKTOP_CONTRACT = 1;
6350
+ var DESKTOP_HEADER = "x-ezdesign-desktop";
6351
+
6352
+ // src/projects/book.ts
6353
+ import { existsSync as existsSync8, readFileSync as readFileSync14 } from "node:fs";
6354
+ import { basename } from "node:path";
6355
+ var ProjectBook = class {
6356
+ constructor(sessions, file = PROJECTS_FILE) {
6357
+ this.file = file;
6358
+ this.entries = readBook(file);
6359
+ this.withSession = new Map([...sessions].map((s) => [s.project, { createdAt: s.createdAt }]));
6360
+ }
6361
+ entries;
6362
+ withSession;
6363
+ markUsed(project, now) {
6364
+ if (!this.withSession.has(project)) this.withSession.set(project, { createdAt: now });
6365
+ this.update(project, { usedAt: now });
6366
+ }
6367
+ add(project, now) {
6368
+ this.update(project, { addedAt: now });
6369
+ }
6370
+ hide(project, now) {
6371
+ if (this.isListed(project)) this.update(project, { hiddenAt: now });
6372
+ }
6373
+ isListed(project) {
6374
+ return this.entries.has(project) || this.withSession.has(project);
6375
+ }
6376
+ list() {
6377
+ return mergeProjects(this.entries, this.withSession, (project) => existsSync8(project));
6378
+ }
6379
+ update(project, change) {
6380
+ this.entries.set(project, { ...this.entries.get(project), ...change });
6381
+ const file = { version: 1, projects: Object.fromEntries(this.entries) };
6382
+ writeAtomic(this.file, `${JSON.stringify(file, null, 2)}
6383
+ `);
6384
+ }
6385
+ };
6386
+ var isHidden = (entry) => entry.hiddenAt !== void 0 && entry.hiddenAt >= Math.max(entry.usedAt ?? 0, entry.addedAt ?? 0);
6387
+ var byName = new Intl.Collator("en", { sensitivity: "base", numeric: true });
6388
+ function mergeProjects(entries3, sessions, exists) {
6389
+ const rows = [];
6390
+ for (const project of /* @__PURE__ */ new Set([...entries3.keys(), ...sessions.keys()])) {
6391
+ const entry = entries3.get(project) ?? {};
6392
+ if (isHidden(entry)) continue;
6393
+ const usedAt = entry.usedAt ?? entry.addedAt ?? sessions.get(project)?.createdAt ?? null;
6394
+ rows.push({ project, name: basename(project), usedAt, exists: exists(project) });
6395
+ }
6396
+ return rows.sort((a, b) => byName.compare(a.name, b.name) || byName.compare(a.project, b.project));
6397
+ }
6398
+ function readBook(file) {
6399
+ try {
6400
+ const parsed = JSON.parse(readFileSync14(file, "utf8"));
6401
+ return new Map(Object.entries(parsed.projects ?? {}));
6402
+ } catch {
6403
+ return /* @__PURE__ */ new Map();
6404
+ }
6405
+ }
6406
+
6407
+ // src/projects/hub.ts
6408
+ import { statSync as statSync6 } from "node:fs";
6409
+ import { isAbsolute as isAbsolute2 } from "node:path";
6410
+ var ProjectHub = class {
6411
+ constructor(deps) {
6412
+ this.deps = deps;
6413
+ }
6414
+ listeners = /* @__PURE__ */ new Set();
6415
+ touched = false;
6416
+ list() {
6417
+ const runtimes = new Map(this.deps.sessions.list().map((r) => [r.project, r]));
6418
+ const projects = this.deps.book.list().map((row) => {
6419
+ const runtime = runtimes.get(row.project);
6420
+ return {
6421
+ ...row,
6422
+ session: runtime ? { devServer: runtime.devServer.summary(), agentWorking: runtime.agentWorking } : null
6423
+ };
6424
+ });
6425
+ return { version: this.deps.version, projects, account: this.deps.account() };
6426
+ }
6427
+ add(path) {
6428
+ if (!isAbsolute2(path) || !statSync6(path, { throwIfNoEntry: false })?.isDirectory()) {
6429
+ return { error: "not a folder on this computer" };
6430
+ }
6431
+ const project = this.deps.projectRoot(path);
6432
+ this.deps.book.add(project, Date.now());
6433
+ this.changed();
6434
+ return this.list().projects.find((p) => p.project === project);
6435
+ }
6436
+ hide(project) {
6437
+ this.deps.book.hide(project, Date.now());
6438
+ this.changed();
6439
+ }
6440
+ /** Opens the review and connects its agent; the dev server waits for the user to start it. */
6441
+ async open(project, by) {
6442
+ if (!this.deps.book.isListed(project)) return { ok: false, status: 404, error: "not a project on the list" };
6443
+ await this.deps.restored();
6444
+ const opened = await this.deps.sessions.open({ project, reopen: true, now: Date.now() });
6445
+ if (!opened.ok) return { ok: false, status: 404, error: "the review could not be opened" };
6446
+ opened.runtime.ensureAgent(this.deps.defaultAgent(), by);
6447
+ this.changed();
6448
+ return { project, shellUrl: opened.runtime.shellUrl() };
6449
+ }
6450
+ async stop(project) {
6451
+ const runtime = this.deps.sessions.list().find((r) => r.project === project);
6452
+ if (runtime && !runtime.devServer.external) await runtime.devServer.stop();
6453
+ }
6454
+ async stopManagedBy(by) {
6455
+ const targets = this.deps.sessions.list().filter((r) => r.devServer.managedBy === by);
6456
+ await Promise.all(targets.map((r) => r.devServer.stop()));
6457
+ return targets.length;
6458
+ }
6459
+ disconnectAgentsOf(by) {
6460
+ return this.deps.sessions.list().filter((r) => r.disconnectAgentOf(by)).length;
6461
+ }
6462
+ changed() {
6463
+ for (const listener of this.listeners) listener();
6464
+ }
6465
+ subscribe(listener) {
6466
+ this.listeners.add(listener);
6467
+ this.touched = true;
6468
+ return () => this.listeners.delete(listener);
6469
+ }
6470
+ takeBusy() {
6471
+ const busy = this.touched || this.listeners.size > 0;
6472
+ this.touched = false;
6473
+ return busy;
6474
+ }
6475
+ };
6476
+
6477
+ // src/projects/routes.ts
6478
+ import { readFileSync as readFileSync15 } from "node:fs";
6479
+ import { join as join16 } from "node:path";
6480
+ import { Router as Router3 } from "express";
6481
+
6482
+ // src/version-header.ts
6483
+ var VERSION_HEADER = "x-ezdesign-version";
6484
+
6485
+ // src/version.ts
6486
+ function versionMismatchBody(cliVersion, daemonVersion) {
6487
+ return {
6488
+ error: cliVersion ? `this ${APP_NAME} CLI is v${cliVersion} but the daemon is running v${daemonVersion}` : `this ${APP_NAME} CLI predates the running daemon (v${daemonVersion})`,
6489
+ hint: `re-run as \`npx -y ${NPM_PACKAGE}@latest <url>\` to bring both to the current version`
6490
+ };
6491
+ }
6492
+ function versionGate(daemonVersion) {
6493
+ return (req, res, next) => {
6494
+ const cliVersion = req.get(VERSION_HEADER) || void 0;
6495
+ if (cliVersion === daemonVersion) return next();
6496
+ res.status(409).json(versionMismatchBody(cliVersion, daemonVersion));
6497
+ };
6498
+ }
6499
+ function desktopContractGate(served = DESKTOP_CONTRACT) {
6500
+ return (req, res, next) => {
6501
+ const asked = Number(req.get(DESKTOP_HEADER));
6502
+ if (Number.isInteger(asked) && asked >= 1 && asked <= served) return next();
6503
+ res.status(409).json({ error: `this ${APP_NAME} daemon serves desktop contracts up to ${served}` });
6504
+ };
6505
+ }
6506
+
6507
+ // src/projects/routes.ts
6508
+ var field2 = (req, name) => req.body?.[name];
6509
+ function projectsApiRouter(hub, opts) {
6510
+ const router = Router3();
6511
+ router.use("/projects", refuseCrossSite(opts.origin));
6512
+ router.get("/projects/events", (req, res) => {
6513
+ res.set({ "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
6514
+ res.flushHeaders();
6515
+ const push = () => res.write(`data: ${JSON.stringify(hub.list())}
6516
+
6517
+ `);
6518
+ push();
6519
+ const unsubscribe = hub.subscribe(push);
6520
+ req.on("close", unsubscribe);
6521
+ });
6522
+ router.use("/projects", versionGate(opts.version));
6523
+ router.get("/projects", (_req, res) => {
6524
+ res.set("cache-control", "no-store").json(hub.list());
6525
+ });
6526
+ router.post("/projects/open", async (req, res) => {
6527
+ const project = field2(req, "project");
6528
+ if (typeof project !== "string") return void res.status(400).json({ error: "project required" });
6529
+ const opened = await hub.open(project, field2(req, "host") === "desktop" ? "desktop" : "browser");
6530
+ if ("ok" in opened) return void res.status(opened.status).json({ error: opened.error });
6531
+ res.json(opened);
6532
+ });
6533
+ router.post("/projects/stop", async (req, res) => {
6534
+ const project = field2(req, "project");
6535
+ if (typeof project !== "string") return void res.status(400).json({ error: "project required" });
6536
+ await hub.stop(project);
6537
+ res.json({ ok: true });
6538
+ });
6539
+ router.post("/projects", (req, res) => {
6540
+ const path = field2(req, "path");
6541
+ if (typeof path !== "string") return void res.status(400).json({ error: "path required" });
6542
+ const added = hub.add(path.trim());
6543
+ if ("error" in added) return void res.status(400).json(added);
6544
+ res.json(added);
6545
+ });
6546
+ router.post("/projects/hide", (req, res) => {
6547
+ const project = field2(req, "project");
6548
+ if (typeof project !== "string") return void res.status(400).json({ error: "project required" });
6549
+ hub.hide(project);
6550
+ res.json({ ok: true });
6551
+ });
6552
+ return router;
6553
+ }
6554
+ var homeHtml = (version, icon) => `<!doctype html>
6555
+ <html lang="en">
6556
+ <head>
6557
+ <meta charset="utf-8">
6558
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6559
+ <title>${APP_NAME}</title>
6560
+ <link rel="icon" href="${icon}">
6561
+ <link rel="stylesheet" href="${URL_PREFIX}/home.css">
6562
+ </head>
6563
+ <body>
6564
+ <div id="ez-home"></div>
6565
+ <script src="${URL_PREFIX}/home.js" data-version="${version}"></script>
6566
+ </body>
6567
+ </html>`;
6568
+ function homePageRouter(opts) {
6569
+ const router = Router3();
6570
+ router.get(HOME_PATH, (_req, res) => res.type("html").set("cache-control", "no-store").send(homeHtml(opts.version, opts.icon)));
6571
+ for (const file of ["home.js", "home.css"]) {
6572
+ router.get(`${URL_PREFIX}/${file}`, (_req, res) => {
6573
+ res.type(file.endsWith(".css") ? "text/css" : "text/javascript").set("cache-control", "no-store").send(readFileSync15(join16(opts.distDir, file)));
6574
+ });
6575
+ }
6576
+ return router;
6577
+ }
6578
+
6579
+ // src/project-root.ts
6580
+ import { existsSync as existsSync9 } from "node:fs";
6581
+ import { dirname as dirname5, join as join17, parse, resolve as resolve2 } from "node:path";
6582
+ function projectRoot(from) {
6583
+ let dir = resolve2(from);
6584
+ const { root } = parse(dir);
6585
+ for (; ; ) {
6586
+ if (existsSync9(join17(dir, ".git")) || existsSync9(join17(dir, "package.json"))) return dir;
6587
+ if (dir === root) return resolve2(from);
6588
+ dir = dirname5(dir);
6589
+ }
6590
+ }
6591
+
6592
+ // src/explore.ts
6593
+ var MAX_VARIANT_HTML = 32 * 1024;
6594
+ var MAX_VARIANT_NAME = 40;
6595
+ var FORBIDDEN_TAGS = ["script", "iframe", "object", "embed", "link", "meta", "base"];
6596
+ function variantProblemMessage(problem) {
6597
+ switch (problem.code) {
6598
+ case "empty":
6599
+ return "html is empty. Send the markup for one element.";
6600
+ case "too-long":
6601
+ return `html is ${problem.length} bytes; the limit is ${MAX_VARIANT_HTML}. Send one element's markup, not a page.`;
6602
+ case "name-too-long":
6603
+ return `name is ${problem.length} characters; the limit is ${MAX_VARIANT_NAME}. A short label, not a description.`;
6604
+ case "forbidden-tag":
6605
+ return `html contains <${problem.tag}>, which a variant may not use. A variant is markup and CSS only - no scripts, no embeds, no external resources.`;
6606
+ case "event-handler":
6607
+ return `html sets the inline handler ${problem.attribute}. A variant is markup and CSS only - no scripts.`;
6608
+ case "javascript-url":
6609
+ return "html contains a javascript: url. A variant is markup and CSS only - no scripts.";
6610
+ case "not-one-root":
6611
+ return problem.roots === 0 ? "html has no element at its top level. A variant is one root element, optionally with a <style> block inside it." : `html has ${problem.roots} top-level elements. A variant is one root element - wrap them in a single container.`;
6612
+ }
6613
+ }
6614
+ function checkVariant(name, html) {
6615
+ if (name.length > MAX_VARIANT_NAME) return { code: "name-too-long", length: name.length };
6616
+ if (!html.trim()) return { code: "empty" };
6617
+ if (html.length > MAX_VARIANT_HTML) return { code: "too-long", length: html.length };
6618
+ const tags = scanTags(html);
6619
+ if (!tags) return { code: "not-one-root", roots: 0 };
6620
+ for (const tag of tags.names) {
6621
+ if (FORBIDDEN_TAGS.includes(tag)) return { code: "forbidden-tag", tag };
6622
+ }
6623
+ const handler = /\son([a-z]+)\s*=/i.exec(html);
6624
+ if (handler) return { code: "event-handler", attribute: `on${handler[1].toLowerCase()}` };
6625
+ if (/javascript:/i.test(html)) return { code: "javascript-url" };
6626
+ if (tags.roots !== 1) return { code: "not-one-root", roots: tags.roots };
6627
+ return null;
6628
+ }
6629
+ var VOID_TAGS = /* @__PURE__ */ new Set([
6630
+ "area",
6631
+ "base",
6632
+ "br",
6633
+ "col",
6634
+ "embed",
6635
+ "hr",
6636
+ "img",
6637
+ "input",
6638
+ "link",
6639
+ "meta",
6640
+ "param",
6641
+ "source",
6642
+ "track",
6643
+ "wbr"
6644
+ ]);
6645
+ function scanTags(html) {
6646
+ const names2 = [];
6647
+ let depth = 0;
6648
+ let roots = 0;
6649
+ let i = 0;
6650
+ while (i < html.length) {
6651
+ const lt = html.indexOf("<", i);
6652
+ if (lt === -1) break;
6653
+ if (html.startsWith("<!--", lt)) {
6654
+ const end2 = html.indexOf("-->", lt + 4);
6655
+ i = end2 === -1 ? html.length : end2 + 3;
6656
+ continue;
6657
+ }
6658
+ if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
6659
+ const end2 = html.indexOf(">", lt);
6660
+ i = end2 === -1 ? html.length : end2 + 1;
6661
+ continue;
6662
+ }
6663
+ const tag = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 64));
6664
+ if (!tag) {
6665
+ i = lt + 1;
6666
+ continue;
6667
+ }
6668
+ const end = closingBracket(html, lt);
6669
+ if (end === -1) break;
6670
+ const closing = tag[1] === "/";
6671
+ const name = tag[2].toLowerCase();
6672
+ names2.push(name);
6673
+ const selfClosing = html[end - 1] === "/";
6674
+ if (closing) {
6675
+ if (depth === 0) return null;
6676
+ depth -= 1;
6677
+ } else if (!selfClosing && !VOID_TAGS.has(name)) {
6678
+ if (depth === 0) roots += 1;
6679
+ depth += 1;
6680
+ } else if (depth === 0) {
6681
+ roots += 1;
6682
+ }
6683
+ i = end + 1;
6684
+ if (!closing && RAW_TEXT2.has(name) && !selfClosing) {
6685
+ const close = html.toLowerCase().indexOf(`</${name}`, i);
6686
+ if (close === -1) break;
6687
+ i = close;
6688
+ }
6689
+ }
6690
+ return { names: names2, roots };
6691
+ }
6692
+ var RAW_TEXT2 = /* @__PURE__ */ new Set(["style", "script", "textarea", "title"]);
6693
+ function closingBracket(html, from) {
6694
+ let quote = null;
4928
6695
  for (let i = from + 1; i < html.length; i += 1) {
4929
6696
  const ch = html[i];
4930
6697
  if (quote) {
@@ -4972,7 +6739,7 @@ function point2(v) {
4972
6739
  const r = typeof rel === "object" && rel !== null ? rel : {};
4973
6740
  return { x: px2, y: py, rel: { x: num(r.x) ?? 0, y: num(r.y) ?? 0 } };
4974
6741
  }
4975
- function rect(v) {
6742
+ function rect2(v) {
4976
6743
  if (typeof v !== "object" || v === null) return void 0;
4977
6744
  const { x, y, width, height } = v;
4978
6745
  const vals = [num(x), num(y), num(width), num(height)];
@@ -5008,7 +6775,7 @@ function sanitizeAnchor(raw) {
5008
6775
  if (page) out.page = page;
5009
6776
  const p = point2(v.point);
5010
6777
  if (p) out.point = p;
5011
- const r = rect(v.rect);
6778
+ const r = rect2(v.rect);
5012
6779
  if (r) out.rect = r;
5013
6780
  const vp = viewport(v.viewport);
5014
6781
  if (vp) out.viewport = vp;
@@ -5185,8 +6952,8 @@ function parseBatchView(raw) {
5185
6952
  }
5186
6953
 
5187
6954
  // src/truth.ts
5188
- import { existsSync as existsSync8 } from "node:fs";
5189
- import { join as join13, resolve } from "node:path";
6955
+ import { existsSync as existsSync10 } from "node:fs";
6956
+ import { join as join18, resolve as resolve3 } from "node:path";
5190
6957
  var PRODUCT_MD = "PRODUCT.md";
5191
6958
  var DESIGN_MD = "DESIGN.md";
5192
6959
  function designHarnessEnabled(env = process.env) {
@@ -5201,8 +6968,8 @@ function effortLine(effort) {
5201
6968
  var FILE_NAME = { product: PRODUCT_MD, design: DESIGN_MD };
5202
6969
  function truthState(project, enabled, seen = false) {
5203
6970
  return {
5204
- product: existsSync8(join13(project, PRODUCT_MD)),
5205
- design: existsSync8(join13(project, DESIGN_MD)),
6971
+ product: existsSync10(join18(project, PRODUCT_MD)),
6972
+ design: existsSync10(join18(project, DESIGN_MD)),
5206
6973
  enabled,
5207
6974
  seen
5208
6975
  };
@@ -5234,10 +7001,10 @@ function harnessReady(truth) {
5234
7001
  return truth.enabled && truth.product && truth.design;
5235
7002
  }
5236
7003
  function skillDir(fromDir) {
5237
- return resolve(fromDir, "..", "skills", "ezdesign");
7004
+ return resolve3(fromDir, "..", "skills", "ezdesign");
5238
7005
  }
5239
7006
  function skillReference(skill, name) {
5240
- return join13(skill, "reference", `${name}.md`);
7007
+ return join18(skill, "reference", `${name}.md`);
5241
7008
  }
5242
7009
  var names = (files) => files.map((f) => `\`${FILE_NAME[f]}\``);
5243
7010
  function truthFilesZh(files) {
@@ -5255,7 +7022,7 @@ function creationSteps(missing, skill) {
5255
7022
  steps.push("stack");
5256
7023
  if (missing.includes("design")) steps.push("design");
5257
7024
  steps.push("components");
5258
- return steps.map((step) => `read \`${skillReference(skill, step)}\` and follow it`);
7025
+ return steps.map((step2) => `read \`${skillReference(skill, step2)}\` and follow it`);
5259
7026
  }
5260
7027
  function makeReading(skill, effort = "high") {
5261
7028
  const files = ["make", "baseline", ...effort === "low" ? [] : ["principles"]].map(
@@ -5505,55 +7272,90 @@ function toAgentAttachment(a, files) {
5505
7272
  mime: a.mime,
5506
7273
  size: a.size,
5507
7274
  path: files.attachmentPath(a),
5508
- ...a.origin ? { origin: a.origin } : {}
7275
+ ...a.origin ? { origin: a.origin } : {},
7276
+ ...a.video && a.video.state !== "pending" ? { video: toAgentVideo(a, a.video, files) } : {}
7277
+ };
7278
+ }
7279
+ var READ_FRAMES = "A recording you cannot open: do not read `path`. Read `overview` first, then the `sheet` of each stretch of `motion` you need. A sheet is a grid of frames in time order, left to right, each labelled with its time in seconds; a motion sheet is cropped to `region` (pixels of the video) and starts with the frame before the motion, so consecutive labels give you its timing and easing. `shown` < `frames` means only that many of its changed frames are on the sheet, evenly spaced.";
7280
+ var READ_AUDIO = " It has sound you cannot hear: if it may matter, ask the user what is said.";
7281
+ var UNREADABLE = {
7282
+ "unsupported-browser": () => "the browser the review ran in cannot decode video",
7283
+ "unsupported-codec": (detail) => `that browser cannot decode its codec (${detail})`,
7284
+ unreadable: (detail) => `its file could not be read as a video (${detail})`,
7285
+ crashed: () => "the browser stopped while taking them"
7286
+ };
7287
+ var seconds = (ms) => Math.round(ms) / 1e3;
7288
+ function toAgentVideo(a, video, files) {
7289
+ if (video.state === "failed") {
7290
+ return {
7291
+ read: `No frames could be taken from this video: ${UNREADABLE[video.reason](video.detail)}. Do not try to read \`path\`. Tell the user, and ask them what it shows.`,
7292
+ unreadable: video.reason
7293
+ };
7294
+ }
7295
+ return {
7296
+ read: READ_FRAMES + (video.audio ? READ_AUDIO : ""),
7297
+ duration: seconds(video.durationMs),
7298
+ width: video.width,
7299
+ height: video.height,
7300
+ fps: video.fps,
7301
+ audio: video.audio,
7302
+ overview: files.sheetPath(a, 1),
7303
+ motion: video.motion.map((m) => ({
7304
+ from: seconds(m.fromMs),
7305
+ to: seconds(m.toMs),
7306
+ region: m.region,
7307
+ frames: m.frames,
7308
+ shown: m.shown,
7309
+ ...m.sheet === null ? {} : { sheet: files.sheetPath(a, m.sheet) }
7310
+ }))
5509
7311
  };
5510
7312
  }
5511
7313
  function toAgentAttachments(list2, files) {
5512
7314
  return list2?.length ? list2.map((a) => toAgentAttachment(a, files)) : void 0;
5513
7315
  }
5514
7316
  function anchorParts(anchor, textMax, withViewport, frames) {
5515
- const parts = [];
5516
- if (anchor.source) parts.push(anchor.source);
5517
- if (anchor.components?.length) parts.push(`<${anchor.components.join(" \u2190 ")}>`);
5518
- if (anchor.section) parts.push(`[section: ${anchor.section}]`);
5519
- if (anchor.text) parts.push(`"${truncate(anchor.text, textMax)}"`);
7317
+ const parts2 = [];
7318
+ if (anchor.source) parts2.push(anchor.source);
7319
+ if (anchor.components?.length) parts2.push(`<${anchor.components.join(" \u2190 ")}>`);
7320
+ if (anchor.section) parts2.push(`[section: ${anchor.section}]`);
7321
+ if (anchor.text) parts2.push(`"${truncate(anchor.text, textMax)}"`);
5520
7322
  if (anchor.contains?.length) {
5521
- parts.push(`[framed ${anchor.contains.length}: ${anchor.contains.join("; ")}]`);
7323
+ parts2.push(`[framed ${anchor.contains.length}: ${anchor.contains.join("; ")}]`);
5522
7324
  }
5523
7325
  if (withViewport && anchor.viewport) {
5524
7326
  const { width, height, preset } = anchor.viewport;
5525
- parts.push(preset ? `@${preset} ${width}x${height}` : `@${width}x${height}`);
7327
+ parts2.push(preset ? `@${preset} ${width}x${height}` : `@${width}x${height}`);
5526
7328
  }
5527
7329
  if (anchor.frame) {
5528
7330
  const named = frames?.(anchor.frame);
5529
- parts.push(
7331
+ parts2.push(
5530
7332
  named ? `[canvas frame "${named.frame}" on canvas "${named.canvas}"]` : `[canvas frame ${anchor.frame.canvasId}/${anchor.frame.frameId}]`
5531
7333
  );
5532
7334
  }
5533
- if (parts.length === 0 && anchor.selector) parts.push(anchor.selector);
5534
- return parts;
7335
+ if (parts2.length === 0 && anchor.selector) parts2.push(anchor.selector);
7336
+ return parts2;
5535
7337
  }
5536
7338
  function referenceLabel(reference, frames) {
5537
7339
  return anchorParts(reference.anchor, 40, false, frames).join(" \xB7 ") || reference.label;
5538
7340
  }
5539
7341
  function toAgentItem(a, files, frames) {
5540
- const parts = anchorParts(a.anchor, a.kind === "text" ? 60 : 40, true, frames);
5541
- if (a.kind === "page") parts.unshift(`[page ${a.anchor.page ?? ""}]`.trim());
7342
+ const parts2 = anchorParts(a.anchor, a.kind === "text" ? 60 : 40, true, frames);
7343
+ if (a.kind === "page") parts2.unshift(`[page ${a.anchor.page ?? ""}]`.trim());
5542
7344
  if (a.kind === "point") {
5543
7345
  const rel = a.anchor.point?.rel;
5544
7346
  const at = rel ? ` ${Math.round(rel.x * 100)}%/${Math.round(rel.y * 100)}%` : "";
5545
- parts.unshift(`[pin${at}]`);
7347
+ parts2.unshift(`[pin${at}]`);
5546
7348
  }
5547
7349
  a.references?.forEach(
5548
- (r) => parts.push(`[ref ${r.n}: ${r.variant ? "chosen variant \xB7 " : ""}${referenceLabel(r, frames)}]`)
7350
+ (r) => parts2.push(`[ref ${r.n}: ${r.variant ? "chosen variant \xB7 " : ""}${referenceLabel(r, frames)}]`)
5549
7351
  );
5550
- a.attachments?.forEach((x, i) => parts.push(`[file ${i + 1}: ${x.name}]`));
7352
+ a.attachments?.forEach((x, i) => parts2.push(`[file ${i + 1}: ${x.name}${x.video ? ", video" : ""}]`));
5551
7353
  const attachments = toAgentAttachments(a.attachments, files);
5552
7354
  return {
5553
7355
  id: a.id,
5554
7356
  kind: a.kind,
5555
7357
  comment: a.comment,
5556
- label: parts.join(" \xB7 "),
7358
+ label: parts2.join(" \xB7 "),
5557
7359
  anchor: a.anchor,
5558
7360
  ...attachments ? { attachments } : {},
5559
7361
  ...a.references?.length ? { references: a.references } : {}
@@ -5717,103 +7519,218 @@ function register(server, sink) {
5717
7519
  }
5718
7520
  );
5719
7521
  }
5720
-
5721
- // src/skills.ts
5722
- import { readFileSync as readFileSync11, readdirSync as readdirSync6 } from "node:fs";
5723
- import { homedir as homedir4 } from "node:os";
5724
- import { join as join14 } from "node:path";
5725
- var SKILL_DIRS = [".claude/skills", ".agents/skills"];
5726
- function readFrontmatter(path) {
5727
- let text;
7522
+
7523
+ // src/skills.ts
7524
+ import { readFileSync as readFileSync16, readdirSync as readdirSync6, statSync as statSync7 } from "node:fs";
7525
+ import { homedir as homedir4 } from "node:os";
7526
+ import { join as join19 } from "node:path";
7527
+ var SKILL_DIRS = [".claude/skills", ".agents/skills"];
7528
+ function readFrontmatter(path) {
7529
+ let text;
7530
+ try {
7531
+ text = readFileSync16(path, "utf8");
7532
+ } catch {
7533
+ return null;
7534
+ }
7535
+ if (!text.startsWith("---")) return null;
7536
+ const end = text.indexOf("\n---", 3);
7537
+ if (end === -1) return null;
7538
+ const fields = {};
7539
+ for (const line2 of text.slice(3, end).split("\n")) {
7540
+ const match = /^(name|description|user-invocable):\s*(.*)$/.exec(line2);
7541
+ if (!match) continue;
7542
+ const value = match[2].trim().replace(/^['"]|['"]$/g, "");
7543
+ if (value) fields[match[1]] = value;
7544
+ }
7545
+ return fields;
7546
+ }
7547
+ function entries2(dir) {
7548
+ try {
7549
+ return readdirSync6(dir, { withFileTypes: true }).filter((e) => !e.name.startsWith(".") && (e.isDirectory() || e.isSymbolicLink() && isDir(join19(dir, e.name)))).map((e) => e.name);
7550
+ } catch {
7551
+ return [];
7552
+ }
7553
+ }
7554
+ function isDir(path) {
7555
+ try {
7556
+ return statSync7(path).isDirectory();
7557
+ } catch {
7558
+ return false;
7559
+ }
7560
+ }
7561
+ function readSkill(dir, name, source) {
7562
+ const fields = readFrontmatter(join19(dir, name, "SKILL.md"));
7563
+ if (!fields) return null;
7564
+ if (fields["user-invocable"]?.toLowerCase() === "false") return null;
7565
+ return { name, description: fields.description ?? "", source };
7566
+ }
7567
+ function listSkills(project, home = homedir4()) {
7568
+ const found = /* @__PURE__ */ new Map();
7569
+ const roots = [
7570
+ [home, "user"],
7571
+ [project, "project"]
7572
+ ];
7573
+ for (const [root, source] of roots) {
7574
+ for (const dir of SKILL_DIRS) {
7575
+ const container = join19(root, dir);
7576
+ for (const name of entries2(container)) {
7577
+ const skill = readSkill(container, name, source);
7578
+ if (skill) found.set(skill.name, skill);
7579
+ }
7580
+ }
7581
+ }
7582
+ const plugins = join19(project, ".claude/plugins");
7583
+ for (const plugin of entries2(plugins)) {
7584
+ const container = join19(plugins, plugin, "skills");
7585
+ for (const name of entries2(container)) {
7586
+ const skill = readSkill(container, name, "plugin");
7587
+ if (skill) found.set(`${plugin}:${name}`, { ...skill, name: `${plugin}:${name}` });
7588
+ }
7589
+ }
7590
+ return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
7591
+ }
7592
+ function skillPrefix(agent) {
7593
+ return agentBrandFor(agent) === "claude" ? "/" : "$";
7594
+ }
7595
+ function spendSkillMarkers(text, skills, agent) {
7596
+ return text.replace(/\[skill (\d+)\]/g, (whole, n) => {
7597
+ const name = skills[Number(n) - 1];
7598
+ return name === void 0 ? whole : `${skillPrefix(agent)}${name}`;
7599
+ });
7600
+ }
7601
+
7602
+ // src/registry.ts
7603
+ import { spawn as spawn7 } from "node:child_process";
7604
+ import { createHash as createHash4 } from "node:crypto";
7605
+ import { existsSync as existsSync12, mkdirSync as mkdirSync13, openSync as openSync2, readFileSync as readFileSync17, realpathSync as realpathSync4, rmSync as rmSync10, writeFileSync as writeFileSync9 } from "node:fs";
7606
+ import { join as join21 } from "node:path";
7607
+
7608
+ // src/installer.ts
7609
+ import { spawn as spawn6 } from "node:child_process";
7610
+ import { existsSync as existsSync11, mkdirSync as mkdirSync12, readdirSync as readdirSync7, realpathSync as realpathSync3, rmSync as rmSync9 } from "node:fs";
7611
+ import { dirname as dirname6, join as join20, resolve as resolve4, sep as sep2 } from "node:path";
7612
+ function npmCli() {
7613
+ const bin = dirname6(process.execPath);
7614
+ for (const candidate of [
7615
+ join20(bin, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
7616
+ join20(bin, "node_modules", "npm", "bin", "npm-cli.js")
7617
+ ]) {
7618
+ if (existsSync11(candidate)) return candidate;
7619
+ }
7620
+ return null;
7621
+ }
7622
+ function npmErrorSummary(stderr) {
7623
+ const lines = stderr.split("\n").map((l) => l.trim());
7624
+ const own = lines.filter((l) => /^npm (error|ERR!)/.test(l)).map((l) => l.replace(/^npm (error|ERR!)\s*/, "")).filter((l) => l && !/^A complete log of this run/.test(l));
7625
+ if (own.length) return own.slice(0, 2).join(" ");
7626
+ return lines.filter(Boolean).at(-1) ?? "";
7627
+ }
7628
+ function run3(label, command, args, opts = {}) {
7629
+ return new Promise((resolve7, reject) => {
7630
+ const child = spawn6(command, args, { stdio: ["ignore", "pipe", "pipe"], shell: opts.shell, env: opts.env });
7631
+ let stdout = "";
7632
+ let stderr = "";
7633
+ child.stdout.on("data", (c) => stdout += c);
7634
+ child.stderr.on("data", (c) => stderr += c);
7635
+ child.on("error", (err) => reject(new Error(`${label} could not start: ${err.message}`)));
7636
+ child.on("close", (code) => {
7637
+ if (code === 0) return resolve7({ stdout, stderr });
7638
+ const why = npmErrorSummary(stderr);
7639
+ reject(new Error(`${label} failed${why ? `: ${why}` : ` (exit ${code})`}`));
7640
+ });
7641
+ });
7642
+ }
7643
+ async function npm(label, args) {
7644
+ const cli = npmCli();
7645
+ if (cli) {
7646
+ await run3(label, process.execPath, [cli, ...args]);
7647
+ return;
7648
+ }
7649
+ await run3(label, process.platform === "win32" ? "npm.cmd" : "npm", args.map(shellQuote), {
7650
+ shell: true
7651
+ });
7652
+ }
7653
+ function packageSpec(version) {
7654
+ return `${NPM_PACKAGE}@${version}`;
7655
+ }
7656
+ function installedPackageRoot(version) {
7657
+ return join20(VERSIONS_DIR, version, "node_modules", NPM_PACKAGE);
7658
+ }
7659
+ async function verifyInstall(pkgRoot, version) {
7660
+ const cliEntry2 = join20(pkgRoot, "dist", "cli.mjs");
7661
+ if (!existsSync11(cliEntry2)) return false;
7662
+ try {
7663
+ const { stdout } = await run3(`${APP_NAME} --version`, SELF_NODE.execPath, [cliEntry2, "--version"], {
7664
+ env: selfNodeEnv()
7665
+ });
7666
+ return stdout.trim() === version;
7667
+ } catch {
7668
+ return false;
7669
+ }
7670
+ }
7671
+ async function installVersion(version) {
7672
+ const pkgRoot = installedPackageRoot(version);
7673
+ if (await verifyInstall(pkgRoot, version)) return pkgRoot;
7674
+ const prefix = join20(VERSIONS_DIR, version);
7675
+ mkdirSync12(prefix, { recursive: true });
7676
+ await npm(`npm install ${packageSpec(version)}`, [
7677
+ "install",
7678
+ "--prefix",
7679
+ prefix,
7680
+ "--no-audit",
7681
+ "--no-fund",
7682
+ "--no-package-lock",
7683
+ "--loglevel=error",
7684
+ packageSpec(version)
7685
+ ]);
7686
+ if (!await verifyInstall(pkgRoot, version)) {
7687
+ throw new Error(`the installed ${packageSpec(version)} does not run as v${version}`);
7688
+ }
7689
+ return pkgRoot;
7690
+ }
7691
+ function realOrResolved(path) {
5728
7692
  try {
5729
- text = readFileSync11(path, "utf8");
7693
+ return realpathSync3(path);
5730
7694
  } catch {
5731
- return null;
5732
- }
5733
- if (!text.startsWith("---")) return null;
5734
- const end = text.indexOf("\n---", 3);
5735
- if (end === -1) return null;
5736
- const fields = {};
5737
- for (const line2 of text.slice(3, end).split("\n")) {
5738
- const match = /^(name|description|user-invocable):\s*(.*)$/.exec(line2);
5739
- if (!match) continue;
5740
- const value = match[2].trim().replace(/^['"]|['"]$/g, "");
5741
- if (value) fields[match[1]] = value;
7695
+ return resolve4(path);
5742
7696
  }
5743
- return fields;
5744
7697
  }
5745
- function entries2(dir) {
7698
+ function pruneInstalledVersions(runningFrom, root = VERSIONS_DIR) {
7699
+ let versions;
5746
7700
  try {
5747
- return readdirSync6(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
7701
+ versions = readdirSync7(root);
5748
7702
  } catch {
5749
- return [];
5750
- }
5751
- }
5752
- function readSkill(dir, name, source) {
5753
- const fields = readFrontmatter(join14(dir, name, "SKILL.md"));
5754
- if (!fields) return null;
5755
- if (fields["user-invocable"]?.toLowerCase() === "false") return null;
5756
- return { name, description: fields.description ?? "", source };
5757
- }
5758
- function listSkills(project, home = homedir4()) {
5759
- const found = /* @__PURE__ */ new Map();
5760
- const roots = [
5761
- [home, "user"],
5762
- [project, "project"]
5763
- ];
5764
- for (const [root, source] of roots) {
5765
- for (const dir of SKILL_DIRS) {
5766
- const container = join14(root, dir);
5767
- for (const name of entries2(container)) {
5768
- const skill = readSkill(container, name, source);
5769
- if (skill) found.set(skill.name, skill);
5770
- }
5771
- }
7703
+ return;
5772
7704
  }
5773
- const plugins = join14(project, ".claude/plugins");
5774
- for (const plugin of entries2(plugins)) {
5775
- const container = join14(plugins, plugin, "skills");
5776
- for (const name of entries2(container)) {
5777
- const skill = readSkill(container, name, "plugin");
5778
- if (skill) found.set(`${plugin}:${name}`, { ...skill, name: `${plugin}:${name}` });
5779
- }
7705
+ const running2 = realOrResolved(runningFrom);
7706
+ for (const version of versions) {
7707
+ const dir = join20(root, version);
7708
+ if (running2.startsWith(realOrResolved(dir) + sep2)) continue;
7709
+ rmSync9(dir, { recursive: true, force: true });
5780
7710
  }
5781
- return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
5782
- }
5783
- function skillPrefix(agent) {
5784
- return agentBrandFor(agent) === "claude" ? "/" : "$";
5785
- }
5786
- function spendSkillMarkers(text, skills, agent) {
5787
- return text.replace(/\[skill (\d+)\]/g, (whole, n) => {
5788
- const name = skills[Number(n) - 1];
5789
- return name === void 0 ? whole : `${skillPrefix(agent)}${name}`;
5790
- });
5791
7711
  }
5792
7712
 
5793
7713
  // src/registry.ts
5794
- import { spawn as spawn4 } from "node:child_process";
5795
- import { createHash as createHash4 } from "node:crypto";
5796
- import { mkdirSync as mkdirSync11, openSync as openSync2, readFileSync as readFileSync12, realpathSync, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "node:fs";
5797
7714
  function readRegistry() {
5798
7715
  try {
5799
- return JSON.parse(readFileSync12(REGISTRY_FILE, "utf8"));
7716
+ return JSON.parse(readFileSync17(REGISTRY_FILE, "utf8"));
5800
7717
  } catch {
5801
7718
  return null;
5802
7719
  }
5803
7720
  }
5804
7721
  function writeRegistry(info) {
5805
- mkdirSync11(DATA_DIR, { recursive: true });
7722
+ mkdirSync13(DATA_DIR, { recursive: true });
5806
7723
  writeFileSync9(REGISTRY_FILE, JSON.stringify(info, null, 2));
5807
7724
  }
5808
7725
  function clearRegistry(pid) {
5809
7726
  if (pid !== void 0 && readRegistry()?.pid !== pid) return;
5810
- rmSync8(REGISTRY_FILE, { force: true });
7727
+ rmSync10(REGISTRY_FILE, { force: true });
5811
7728
  }
5812
7729
  var ownDataDirId;
5813
7730
  function dataDirId() {
5814
7731
  if (ownDataDirId === void 0) {
5815
- mkdirSync11(DATA_DIR, { recursive: true });
5816
- const real = realpathSync.native(DATA_DIR);
7732
+ mkdirSync13(DATA_DIR, { recursive: true });
7733
+ const real = realpathSync4.native(DATA_DIR);
5817
7734
  ownDataDirId = createHash4("sha256").update(real).digest("hex").slice(0, 16);
5818
7735
  }
5819
7736
  return ownDataDirId;
@@ -5829,7 +7746,7 @@ async function probeDaemon(port, pid) {
5829
7746
  if (!res.ok) return null;
5830
7747
  const body = await res.json();
5831
7748
  if (body.ok !== true || body.pid !== pid || !servesThisDataDir(body)) return null;
5832
- return { version: body.version ?? "unknown" };
7749
+ return { version: body.version ?? "unknown", ...typeof body.desktop === "number" ? { desktopContract: body.desktop } : {} };
5833
7750
  } catch {
5834
7751
  return null;
5835
7752
  }
@@ -5838,7 +7755,7 @@ async function findRunningDaemon() {
5838
7755
  const info = readRegistry();
5839
7756
  if (!info) return null;
5840
7757
  const probed = await probeDaemon(info.port, info.pid);
5841
- if (probed) return { ...info, version: probed.version };
7758
+ if (probed) return { ...info, ...probed };
5842
7759
  clearRegistry(info.pid);
5843
7760
  return null;
5844
7761
  }
@@ -5860,18 +7777,20 @@ async function stopDaemon(daemon) {
5860
7777
  await new Promise((r) => setTimeout(r, 50));
5861
7778
  }
5862
7779
  }
5863
- function launchDaemon(cliEntry2, args = []) {
5864
- mkdirSync11(DATA_DIR, { recursive: true });
7780
+ function launchDaemon(cliEntry2, args = [], launch = {}) {
7781
+ mkdirSync13(DATA_DIR, { recursive: true });
5865
7782
  const log = openSync2(DAEMON_LOG, "a");
5866
- const child = spawn4(process.execPath, [cliEntry2, "__daemon", ...args], {
7783
+ const runtime = launch.runtime ?? SELF_NODE;
7784
+ const child = spawn7(runtime.execPath, [cliEntry2, "__daemon", ...args], {
5867
7785
  detached: true,
5868
- stdio: ["ignore", log, log]
7786
+ stdio: ["ignore", log, log],
7787
+ env: selfNodeEnv({ ...process.env, ...launch.env }, runtime)
5869
7788
  });
5870
7789
  child.unref();
5871
7790
  return child;
5872
7791
  }
5873
- async function spawnDaemon(cliEntry2) {
5874
- launchDaemon(cliEntry2);
7792
+ async function spawnDaemon(launch) {
7793
+ launchDaemon(launch.cliEntry, [], launch);
5875
7794
  const deadline = Date.now() + 8e3;
5876
7795
  while (Date.now() < deadline) {
5877
7796
  await new Promise((r) => setTimeout(r, 150));
@@ -5881,18 +7800,29 @@ async function spawnDaemon(cliEntry2) {
5881
7800
  throw new Error(`daemon failed to start within 8s (see ${DAEMON_LOG})`);
5882
7801
  }
5883
7802
  async function ensureDaemon(cliEntry2, version) {
7803
+ return ensureDaemonWhere(() => ({ cliEntry: cliEntry2 }), (running2) => running2.version === version);
7804
+ }
7805
+ async function ensureDaemonWhere(launch, accepts) {
5884
7806
  for (let attempt = 0; attempt < 2; attempt++) {
5885
- const running2 = await findRunningDaemon() ?? await spawnDaemon(cliEntry2);
5886
- if (running2.version === version) return running2;
7807
+ const running2 = await findRunningDaemon() ?? await spawnDaemon(await launch());
7808
+ if (accepts(running2)) return running2;
5887
7809
  await stopDaemon(running2);
5888
7810
  }
5889
7811
  throw new Error(`a daemon running another version keeps taking over (see ${DAEMON_LOG})`);
5890
7812
  }
7813
+ async function resolveDaemon(cliEntry2, version) {
7814
+ const running2 = await findRunningDaemon();
7815
+ if (running2 && isNewer(running2.version, version)) {
7816
+ const entry = join21(installedPackageRoot(running2.version), "dist", "cli.mjs");
7817
+ if (existsSync12(entry)) return { kind: "newer", daemon: running2, cliEntry: entry };
7818
+ }
7819
+ return { kind: "ours", daemon: await ensureDaemon(cliEntry2, version) };
7820
+ }
5891
7821
 
5892
7822
  // src/library/index.ts
5893
7823
  import { createHash as createHash6 } from "node:crypto";
5894
- import { createReadStream, mkdirSync as mkdirSync13, writeFileSync as writeFileSync10 } from "node:fs";
5895
- import { resolve as resolve2 } from "node:path";
7824
+ import { createReadStream, mkdirSync as mkdirSync15, writeFileSync as writeFileSync10 } from "node:fs";
7825
+ import { resolve as resolve5 } from "node:path";
5896
7826
 
5897
7827
  // src/library/internal/errors.ts
5898
7828
  var LibraryError = class extends Error {
@@ -5921,13 +7851,13 @@ function asLibraryError(error) {
5921
7851
  }
5922
7852
 
5923
7853
  // src/library/internal/files.ts
5924
- import { createHash as createHash5, randomBytes as randomBytes6 } from "node:crypto";
5925
- import { existsSync as existsSync9, mkdirSync as mkdirSync12, readdirSync as readdirSync7, renameSync as renameSync4, rmSync as rmSync9 } from "node:fs";
7854
+ import { createHash as createHash5, randomBytes as randomBytes8 } from "node:crypto";
7855
+ import { existsSync as existsSync13, mkdirSync as mkdirSync14, readdirSync as readdirSync8, renameSync as renameSync5, rmSync as rmSync11 } from "node:fs";
5926
7856
  import { open } from "node:fs/promises";
5927
- import { dirname as dirname5, join as join15 } from "node:path";
7857
+ import { dirname as dirname7, join as join22 } from "node:path";
5928
7858
 
5929
7859
  // src/library/internal/ulid.ts
5930
- import { randomBytes as randomBytes5 } from "node:crypto";
7860
+ import { randomBytes as randomBytes7 } from "node:crypto";
5931
7861
  var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
5932
7862
  var RANDOM_MAX = (1n << 80n) - 1n;
5933
7863
  var lastTime = -1;
@@ -5945,7 +7875,7 @@ function ulid(now = Date.now()) {
5945
7875
  lastRandom += 1n;
5946
7876
  } else {
5947
7877
  lastTime = Math.max(now, lastTime);
5948
- lastRandom = BigInt(`0x${randomBytes5(10).toString("hex")}`) >> 1n;
7878
+ lastRandom = BigInt(`0x${randomBytes7(10).toString("hex")}`) >> 1n;
5949
7879
  }
5950
7880
  return encode(BigInt(lastTime), 10) + encode(lastRandom, 16);
5951
7881
  }
@@ -5954,15 +7884,15 @@ function ulid(now = Date.now()) {
5954
7884
  function libraryPaths(root) {
5955
7885
  return {
5956
7886
  root,
5957
- db: join15(root, "library.db"),
5958
- blobs: join15(root, "blobs"),
5959
- thumbs: join15(root, "thumbs"),
7887
+ db: join22(root, "library.db"),
7888
+ blobs: join22(root, "blobs"),
7889
+ thumbs: join22(root, "thumbs"),
5960
7890
  // Under the root so sealing a stage into `blobs/` is a rename on one filesystem.
5961
- staging: join15(root, "staging")
7891
+ staging: join22(root, "staging")
5962
7892
  };
5963
7893
  }
5964
7894
  function contentPath(dir, sha256, ext) {
5965
- return join15(dir, sha256.slice(0, 2), `${sha256}.${ext}`);
7895
+ return join22(dir, sha256.slice(0, 2), `${sha256}.${ext}`);
5966
7896
  }
5967
7897
  var SNIFF_BYTES = 4096;
5968
7898
  var ascii = (bytes, start, end) => String.fromCharCode(...bytes.subarray(start, end));
@@ -6005,10 +7935,10 @@ var StagedFile = class {
6005
7935
  thumbPath;
6006
7936
  framePath;
6007
7937
  constructor(dir, name) {
6008
- this.partPath = join15(dir, `${name}.part`);
6009
- this.sealedPath = join15(dir, `${name}.bytes`);
6010
- this.thumbPath = join15(dir, `${name}.thumb.webp`);
6011
- this.framePath = join15(dir, `${name}.frame.png`);
7938
+ this.partPath = join22(dir, `${name}.part`);
7939
+ this.sealedPath = join22(dir, `${name}.bytes`);
7940
+ this.thumbPath = join22(dir, `${name}.thumb.webp`);
7941
+ this.framePath = join22(dir, `${name}.frame.png`);
6012
7942
  }
6013
7943
  get bytes() {
6014
7944
  return this.written;
@@ -6038,7 +7968,7 @@ var StagedFile = class {
6038
7968
  await this.handle.sync();
6039
7969
  await this.handle.close();
6040
7970
  this.handle = null;
6041
- renameSync4(this.partPath, this.sealedPath);
7971
+ renameSync5(this.partPath, this.sealedPath);
6042
7972
  return this.hash.digest("hex");
6043
7973
  }
6044
7974
  remove() {
@@ -6047,7 +7977,7 @@ var StagedFile = class {
6047
7977
  });
6048
7978
  this.handle = null;
6049
7979
  }
6050
- for (const path of [this.partPath, this.sealedPath, this.thumbPath, this.framePath]) rmSync9(path, { force: true });
7980
+ for (const path of [this.partPath, this.sealedPath, this.thumbPath, this.framePath]) rmSync11(path, { force: true });
6051
7981
  }
6052
7982
  };
6053
7983
  function isAlive(pid) {
@@ -6061,22 +7991,22 @@ function isAlive(pid) {
6061
7991
  var unreleased = /* @__PURE__ */ new Set();
6062
7992
  var exitHookInstalled = false;
6063
7993
  function sweepDeadStaging(paths) {
6064
- for (const name of readdirSync7(paths.staging)) {
7994
+ for (const name of readdirSync8(paths.staging)) {
6065
7995
  const pid = Number(name.split("-")[0]);
6066
7996
  if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !isAlive(pid)) {
6067
- rmSync9(join15(paths.staging, name), { recursive: true, force: true });
7997
+ rmSync11(join22(paths.staging, name), { recursive: true, force: true });
6068
7998
  }
6069
7999
  }
6070
8000
  }
6071
8001
  function claimStaging(paths) {
6072
- const dir = join15(paths.staging, `${process.pid}-${randomBytes6(4).toString("hex")}`);
6073
- mkdirSync12(dir);
8002
+ const dir = join22(paths.staging, `${process.pid}-${randomBytes8(4).toString("hex")}`);
8003
+ mkdirSync14(dir);
6074
8004
  const area = {
6075
8005
  dir,
6076
8006
  create: () => new StagedFile(dir, ulid()),
6077
8007
  release: () => {
6078
8008
  unreleased.delete(area);
6079
- rmSync9(dir, { recursive: true, force: true });
8009
+ rmSync11(dir, { recursive: true, force: true });
6080
8010
  }
6081
8011
  };
6082
8012
  unreleased.add(area);
@@ -6089,12 +8019,12 @@ function claimStaging(paths) {
6089
8019
  return area;
6090
8020
  }
6091
8021
  function placeContent(from, target) {
6092
- if (existsSync9(target)) {
6093
- rmSync9(from, { force: true });
8022
+ if (existsSync13(target)) {
8023
+ rmSync11(from, { force: true });
6094
8024
  return "existed";
6095
8025
  }
6096
- mkdirSync12(dirname5(target), { recursive: true });
6097
- renameSync4(from, target);
8026
+ mkdirSync14(dirname7(target), { recursive: true });
8027
+ renameSync5(from, target);
6098
8028
  return "placed";
6099
8029
  }
6100
8030
 
@@ -6128,24 +8058,6 @@ function pngSize(bytes) {
6128
8058
  return { width: view.getUint32(16), height: view.getUint32(20) };
6129
8059
  }
6130
8060
 
6131
- // src/library/policy.ts
6132
- var VIDEO_CAP_BYTES = 2e8;
6133
- var THUMBNAIL_LONG_SIDE = 400;
6134
- var TAG_MAX_LENGTH = 64;
6135
- function normalizeTag(raw) {
6136
- const name = raw.normalize("NFC").trim().replace(/\s+/g, " ");
6137
- if (name === "" || name.length > TAG_MAX_LENGTH || /[,,]/.test(name)) return null;
6138
- return lowercaseInitial(name);
6139
- }
6140
- function lowercaseInitial(name) {
6141
- const [first = "", second = ""] = name;
6142
- if (second !== second.toLowerCase()) return name;
6143
- return first.toLowerCase() + name.slice(first.length);
6144
- }
6145
- function tagKey(name) {
6146
- return name.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
6147
- }
6148
-
6149
8061
  // src/library/internal/store.ts
6150
8062
  var SCHEMA = { epoch: 1, version: 2 };
6151
8063
  var NODE_FLOOR = "22.13.0";
@@ -6270,14 +8182,14 @@ async function openStore(sqlite, file) {
6270
8182
  }
6271
8183
  }
6272
8184
  var OPEN_PATIENCE_MS = 5e3;
6273
- async function patiently(step) {
8185
+ async function patiently(step2) {
6274
8186
  const deadline = Date.now() + OPEN_PATIENCE_MS;
6275
8187
  for (; ; ) {
6276
8188
  try {
6277
- return step();
8189
+ return step2();
6278
8190
  } catch (error) {
6279
8191
  if (!isBusy(error) || Date.now() > deadline) throw error;
6280
- await new Promise((resolve6) => setTimeout(resolve6, 10 + Math.random() * 40));
8192
+ await new Promise((resolve7) => setTimeout(resolve7, 10 + Math.random() * 40));
6281
8193
  }
6282
8194
  }
6283
8195
  }
@@ -6546,7 +8458,7 @@ var SUGGEST_LIMIT = 8;
6546
8458
  var FIND_LIMIT = 50;
6547
8459
  var clamp2 = (value, min, max) => Math.min(max, Math.max(min, Math.floor(value)));
6548
8460
  async function openLibrary(options = {}) {
6549
- const paths = libraryPaths(resolve2(options.dir ?? libraryDir()));
8461
+ const paths = libraryPaths(resolve5(options.dir ?? libraryDir()));
6550
8462
  const sqlite = await loadSqlite();
6551
8463
  ensureLayout(paths);
6552
8464
  const store = await openStore(sqlite, paths.db);
@@ -6560,7 +8472,7 @@ async function openLibrary(options = {}) {
6560
8472
  }
6561
8473
  function ensureLayout(paths) {
6562
8474
  try {
6563
- for (const dir of [paths.root, paths.blobs, paths.thumbs, paths.staging]) mkdirSync13(dir, { recursive: true });
8475
+ for (const dir of [paths.root, paths.blobs, paths.thumbs, paths.staging]) mkdirSync15(dir, { recursive: true });
6564
8476
  } catch (error) {
6565
8477
  throw asLibraryError(error);
6566
8478
  }
@@ -6881,12 +8793,22 @@ var Stage = class {
6881
8793
  };
6882
8794
 
6883
8795
  // src/library-routes.ts
6884
- import express2, { Router as Router2 } from "express";
8796
+ import express3, { Router as Router4 } from "express";
6885
8797
  var PAGE_LIMIT = 60;
6886
8798
  var MAX_TAGS = 100;
6887
8799
  var MAX_NOTE = 1e4;
6888
8800
  var ITEM_ID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
6889
8801
  var MAX_PASTE_BYTES = 64 * 1024 * 1024;
8802
+ function storedFileOptions(mediaType) {
8803
+ return {
8804
+ dotfiles: "allow",
8805
+ headers: {
8806
+ "content-type": mediaType,
8807
+ "x-content-type-options": "nosniff",
8808
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox"
8809
+ }
8810
+ };
8811
+ }
6890
8812
  var itemUrl = (id, file, sha256) => `${URL_PREFIX}/api/library/items/${id}/${file}?v=${sha256.slice(0, 16)}`;
6891
8813
  function storedOriginal(item) {
6892
8814
  return item.content.type === "file" ? item.content : item.content.frame;
@@ -6898,10 +8820,19 @@ function libraryAttachables(library, ids) {
6898
8820
  const file = item && storedOriginal(item);
6899
8821
  if (!item || !file) return { missing: id };
6900
8822
  const { pageUrl, pageTitle, mediaUrl } = item.source;
8823
+ const { content } = item;
6901
8824
  ok2.push({
6902
8825
  title: pageTitle || hostOf(pageUrl),
6903
8826
  file,
6904
- origin: { libraryItemId: item.id, pageUrl, pageTitle, mediaUrl, note: item.note, tags: [...item.tags] }
8827
+ origin: {
8828
+ libraryItemId: item.id,
8829
+ pageUrl,
8830
+ pageTitle,
8831
+ mediaUrl,
8832
+ note: item.note,
8833
+ tags: [...item.tags],
8834
+ ...content.type === "link" ? { videoFrame: { reason: content.reason, playbackTime: content.playbackTime } } : {}
8835
+ }
6905
8836
  });
6906
8837
  }
6907
8838
  return { ok: ok2 };
@@ -7005,7 +8936,7 @@ function refuse(res, status, code, error) {
7005
8936
  res.status(status).json({ error, code });
7006
8937
  }
7007
8938
  function libraryApiRouter(host) {
7008
- const router = Router2();
8939
+ const router = Router4();
7009
8940
  const withLibrary = (handle) => (req, res, next) => {
7010
8941
  host.library().then(
7011
8942
  (library) => {
@@ -7053,17 +8984,10 @@ function libraryApiRouter(host) {
7053
8984
  res.sendFile(
7054
8985
  stored.path,
7055
8986
  {
7056
- // The library lives under `~/.ezdesign`, which `send` would refuse as a dotfile path.
7057
- dotfiles: "allow",
8987
+ ...storedFileOptions(stored.mediaType),
7058
8988
  // Content-addressed: a url names its bytes for good.
7059
8989
  maxAge: req.query.v ? "1y" : 0,
7060
- immutable: Boolean(req.query.v),
7061
- headers: {
7062
- "content-type": stored.mediaType,
7063
- "x-content-type-options": "nosniff",
7064
- // A saved SVG opened on its own must not run on the session origin.
7065
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox"
7066
- }
8990
+ immutable: Boolean(req.query.v)
7067
8991
  },
7068
8992
  (error) => {
7069
8993
  if (error && !res.headersSent) refuse(res, 404, "not-found", `the ${file} of ${req.params.id} is missing`);
@@ -7073,7 +8997,7 @@ function libraryApiRouter(host) {
7073
8997
  })
7074
8998
  );
7075
8999
  }
7076
- router.post("/library/items", express2.raw({ type: "multipart/form-data", limit: MAX_PASTE_BYTES }), (req, res, next) => {
9000
+ router.post("/library/items", express3.raw({ type: "multipart/form-data", limit: MAX_PASTE_BYTES }), (req, res, next) => {
7077
9001
  void (async () => {
7078
9002
  const paste = await parsePaste(req);
7079
9003
  if (!paste) return refuse(res, 400, "bad-request", "a paste is a multipart form of original, thumbnail and meta");
@@ -7118,15 +9042,15 @@ function lazyLibrary(open3) {
7118
9042
  }
7119
9043
 
7120
9044
  // src/extension-routes.ts
7121
- import { Router as Router3 } from "express";
9045
+ import { Router as Router5 } from "express";
7122
9046
 
7123
9047
  // src/capture-seen.ts
7124
- import { mkdirSync as mkdirSync14, readFileSync as readFileSync13 } from "node:fs";
7125
- import { dirname as dirname6, join as join16 } from "node:path";
7126
- var seenFile = (dataDir) => join16(nativeHostDir(dataDir), "extension-seen.json");
9048
+ import { mkdirSync as mkdirSync16, readFileSync as readFileSync18 } from "node:fs";
9049
+ import { dirname as dirname8, join as join23 } from "node:path";
9050
+ var seenFile = (dataDir) => join23(nativeHostDir(dataDir), "extension-seen.json");
7127
9051
  function readExtensionSeen(dataDir) {
7128
9052
  try {
7129
- const { version, at } = JSON.parse(readFileSync13(seenFile(dataDir), "utf8"));
9053
+ const { version, at } = JSON.parse(readFileSync18(seenFile(dataDir), "utf8"));
7130
9054
  return typeof version === "string" && typeof at === "number" ? { version, at } : null;
7131
9055
  } catch {
7132
9056
  return null;
@@ -7135,7 +9059,7 @@ function readExtensionSeen(dataDir) {
7135
9059
 
7136
9060
  // src/extension-routes.ts
7137
9061
  function extensionApiRouter(placement) {
7138
- const router = Router3();
9062
+ const router = Router5();
7139
9063
  router.get("/extension/status", (_req, res) => {
7140
9064
  res.set("cache-control", "no-store").json({ greeted: readExtensionSeen(placement.dataDir) });
7141
9065
  });
@@ -7155,9 +9079,9 @@ function extensionApiRouter(placement) {
7155
9079
  }
7156
9080
 
7157
9081
  // src/announcement-routes.ts
7158
- import { Router as Router4 } from "express";
9082
+ import { Router as Router6 } from "express";
7159
9083
  function announcementApiRouter() {
7160
- const router = Router4();
9084
+ const router = Router6();
7161
9085
  router.get("/announcements/seen", (_req, res) => {
7162
9086
  res.set("cache-control", "no-store").json({ seen: seenAnnouncements() });
7163
9087
  });
@@ -7170,7 +9094,7 @@ function announcementApiRouter() {
7170
9094
  }
7171
9095
 
7172
9096
  // src/account.ts
7173
- import { createHash as createHash7, randomBytes as randomBytes7 } from "node:crypto";
9097
+ import { createHash as createHash7, randomBytes as randomBytes9 } from "node:crypto";
7174
9098
  var DEFAULT_API_URL = "https://api.ezdesign.dev";
7175
9099
  var ACCOUNT_CALLBACK_PATH = `${URL_PREFIX}/account/callback`;
7176
9100
  var CLIENT_ID = APP_NAME;
@@ -7208,8 +9132,8 @@ var Account = class {
7208
9132
  }
7209
9133
  authorizeUrl(redirectUri) {
7210
9134
  this.dropStale();
7211
- const verifier = base64url(randomBytes7(32));
7212
- const state = base64url(randomBytes7(16));
9135
+ const verifier = base64url(randomBytes9(32));
9136
+ const state = base64url(randomBytes9(16));
7213
9137
  this.pendingByState.set(state, { verifier, redirectUri, at: this.now() });
7214
9138
  const url = new URL(`${this.deps.baseUrl}/authorize`);
7215
9139
  url.search = new URLSearchParams({
@@ -7288,9 +9212,9 @@ var Account = class {
7288
9212
  };
7289
9213
 
7290
9214
  // src/account-routes.ts
7291
- import { Router as Router5 } from "express";
9215
+ import { Router as Router7 } from "express";
7292
9216
  function accountApiRouter(host) {
7293
- const router = Router5();
9217
+ const router = Router7();
7294
9218
  router.get("/account/login", (_req, res) => {
7295
9219
  res.set("cache-control", "no-store").redirect(303, host.account.authorizeUrl(host.callbackUrl()));
7296
9220
  });
@@ -7357,111 +9281,8 @@ ${page.close ? "<script>window.close()</script>" : ""}
7357
9281
  </html>`;
7358
9282
  }
7359
9283
 
7360
- // src/installer.ts
7361
- import { spawn as spawn5 } from "node:child_process";
7362
- import { existsSync as existsSync10, mkdirSync as mkdirSync15, readdirSync as readdirSync8, realpathSync as realpathSync2, rmSync as rmSync10 } from "node:fs";
7363
- import { dirname as dirname7, join as join17, resolve as resolve3, sep } from "node:path";
7364
- function npmCli() {
7365
- const bin = dirname7(process.execPath);
7366
- for (const candidate of [
7367
- join17(bin, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
7368
- join17(bin, "node_modules", "npm", "bin", "npm-cli.js")
7369
- ]) {
7370
- if (existsSync10(candidate)) return candidate;
7371
- }
7372
- return null;
7373
- }
7374
- function npmErrorSummary(stderr) {
7375
- const lines = stderr.split("\n").map((l) => l.trim());
7376
- const own = lines.filter((l) => /^npm (error|ERR!)/.test(l)).map((l) => l.replace(/^npm (error|ERR!)\s*/, "")).filter((l) => l && !/^A complete log of this run/.test(l));
7377
- if (own.length) return own.slice(0, 2).join(" ");
7378
- return lines.filter(Boolean).at(-1) ?? "";
7379
- }
7380
- function run(label, command, args, opts = {}) {
7381
- return new Promise((resolve6, reject) => {
7382
- const child = spawn5(command, args, { stdio: ["ignore", "pipe", "pipe"], shell: opts.shell });
7383
- let stdout = "";
7384
- let stderr = "";
7385
- child.stdout.on("data", (c) => stdout += c);
7386
- child.stderr.on("data", (c) => stderr += c);
7387
- child.on("error", (err) => reject(new Error(`${label} could not start: ${err.message}`)));
7388
- child.on("close", (code) => {
7389
- if (code === 0) return resolve6({ stdout, stderr });
7390
- const why = npmErrorSummary(stderr);
7391
- reject(new Error(`${label} failed${why ? `: ${why}` : ` (exit ${code})`}`));
7392
- });
7393
- });
7394
- }
7395
- async function npm(label, args) {
7396
- const cli = npmCli();
7397
- if (cli) {
7398
- await run(label, process.execPath, [cli, ...args]);
7399
- return;
7400
- }
7401
- await run(label, process.platform === "win32" ? "npm.cmd" : "npm", args.map(shellQuote), {
7402
- shell: true
7403
- });
7404
- }
7405
- function packageSpec(version) {
7406
- return `${NPM_PACKAGE}@${version}`;
7407
- }
7408
- function installedPackageRoot(version) {
7409
- return join17(VERSIONS_DIR, version, "node_modules", NPM_PACKAGE);
7410
- }
7411
- async function verifyInstall(pkgRoot, version) {
7412
- const cliEntry2 = join17(pkgRoot, "dist", "cli.mjs");
7413
- if (!existsSync10(cliEntry2)) return false;
7414
- try {
7415
- const { stdout } = await run(`${APP_NAME} --version`, process.execPath, [cliEntry2, "--version"]);
7416
- return stdout.trim() === version;
7417
- } catch {
7418
- return false;
7419
- }
7420
- }
7421
- async function installVersion(version) {
7422
- const pkgRoot = installedPackageRoot(version);
7423
- if (await verifyInstall(pkgRoot, version)) return pkgRoot;
7424
- const prefix = join17(VERSIONS_DIR, version);
7425
- mkdirSync15(prefix, { recursive: true });
7426
- await npm(`npm install ${packageSpec(version)}`, [
7427
- "install",
7428
- "--prefix",
7429
- prefix,
7430
- "--no-audit",
7431
- "--no-fund",
7432
- "--no-package-lock",
7433
- "--loglevel=error",
7434
- packageSpec(version)
7435
- ]);
7436
- if (!await verifyInstall(pkgRoot, version)) {
7437
- throw new Error(`the installed ${packageSpec(version)} does not run as v${version}`);
7438
- }
7439
- return pkgRoot;
7440
- }
7441
- function realOrResolved(path) {
7442
- try {
7443
- return realpathSync2(path);
7444
- } catch {
7445
- return resolve3(path);
7446
- }
7447
- }
7448
- function pruneInstalledVersions(runningFrom, root = VERSIONS_DIR) {
7449
- let versions;
7450
- try {
7451
- versions = readdirSync8(root);
7452
- } catch {
7453
- return;
7454
- }
7455
- const running2 = realOrResolved(runningFrom);
7456
- for (const version of versions) {
7457
- const dir = join17(root, version);
7458
- if (running2.startsWith(realOrResolved(dir) + sep)) continue;
7459
- rmSync10(dir, { recursive: true, force: true });
7460
- }
7461
- }
7462
-
7463
9284
  // src/updater.ts
7464
- import { join as join18 } from "node:path";
9285
+ import { join as join24 } from "node:path";
7465
9286
  var Updater = class {
7466
9287
  constructor(deps) {
7467
9288
  this.deps = deps;
@@ -7513,7 +9334,7 @@ var Updater = class {
7513
9334
  this.set("installing");
7514
9335
  const pkgRoot = await this.deps.install(target);
7515
9336
  this.set("handing-over");
7516
- await this.deps.handover(join18(pkgRoot, "dist", "cli.mjs"));
9337
+ await this.deps.handover(join24(pkgRoot, "dist", "cli.mjs"));
7517
9338
  await this.deps.retire();
7518
9339
  } catch (err) {
7519
9340
  this.error = err instanceof Error ? err.message : String(err);
@@ -7522,48 +9343,27 @@ var Updater = class {
7522
9343
  }
7523
9344
  };
7524
9345
 
7525
- // src/version.ts
7526
- var VERSION_HEADER = `x-${APP_NAME}-version`;
7527
- function versionMismatchBody(cliVersion, daemonVersion) {
7528
- return {
7529
- error: cliVersion ? `this ${APP_NAME} CLI is v${cliVersion} but the daemon is running v${daemonVersion}` : `this ${APP_NAME} CLI predates the running daemon (v${daemonVersion})`,
7530
- hint: `re-run as \`npx -y ${NPM_PACKAGE}@latest <url>\` to bring both to the current version`
7531
- };
7532
- }
7533
- function versionGate(daemonVersion) {
7534
- return (req, res, next) => {
7535
- const cliVersion = req.get(VERSION_HEADER) || void 0;
7536
- if (cliVersion === daemonVersion) return next();
7537
- res.status(409).json(versionMismatchBody(cliVersion, daemonVersion));
7538
- };
7539
- }
7540
-
7541
9346
  // src/daemon.ts
7542
9347
  function agentInstalled(profile, env = process.env) {
7543
- return (env.PATH ?? "").split(delimiter).filter(Boolean).some((dir) => {
7544
- try {
7545
- accessSync(join19(dir, profile.binary), constants.X_OK);
7546
- return true;
7547
- } catch {
7548
- return false;
7549
- }
7550
- });
9348
+ return findExecutable(profile.binary, env.PATH) !== null;
7551
9349
  }
7552
- var distDir = dirname8(fileURLToPath(import.meta.url));
9350
+ function defaultAgent() {
9351
+ return proposingAgent([rememberedLastAgent()], (p) => agentInstalled(p));
9352
+ }
9353
+ var distDir = dirname9(fileURLToPath(import.meta.url));
7553
9354
  var SKILL_DIR = skillDir(distDir);
7554
- var shellWord = (w) => /^[\w@%+=:,./-]+$/.test(w) ? w : `'${w.replace(/'/g, `'\\''`)}'`;
7555
- var SELF_CLI = [process.execPath, ...process.execArgv, process.argv[1] ?? ""].filter(Boolean).map(shellWord).join(" ");
9355
+ var SELF_CLI = selfNodeCommandLine([...process.execArgv, process.argv[1] ?? ""].filter(Boolean));
7556
9356
  function inspireAccess() {
7557
9357
  return readInspireToken() ? { cli: SELF_CLI } : "unavailable";
7558
9358
  }
7559
- var asset = (name) => readFileSync14(join19(distDir, name));
9359
+ var asset = (name) => readFileSync19(join25(distDir, name));
7560
9360
  var overlayKeyed = null;
7561
9361
  function overlayAssetKey() {
7562
- const files = ["overlay.js", "overlay.css"].map((name) => join19(distDir, name));
9362
+ const files = ["overlay.js", "overlay.css"].map((name) => join25(distDir, name));
7563
9363
  let stamp;
7564
9364
  try {
7565
9365
  stamp = files.map((file) => {
7566
- const stat = statSync4(file);
9366
+ const stat = statSync8(file);
7567
9367
  return `${stat.mtimeMs}:${stat.size}`;
7568
9368
  }).join("|");
7569
9369
  } catch {
@@ -7571,7 +9371,7 @@ function overlayAssetKey() {
7571
9371
  }
7572
9372
  if (overlayKeyed?.stamp !== stamp) {
7573
9373
  const hash = createHash8("sha1");
7574
- for (const file of files) hash.update(readFileSync14(file));
9374
+ for (const file of files) hash.update(readFileSync19(file));
7575
9375
  overlayKeyed = { stamp, key: hash.digest("hex").slice(0, 12) };
7576
9376
  }
7577
9377
  return overlayKeyed.key;
@@ -7585,7 +9385,7 @@ function decodeAttachmentName(raw) {
7585
9385
  }
7586
9386
  }
7587
9387
  var SHELL_ICON = `data:image/svg+xml,${encodeURIComponent(brandMarkSvg())}`;
7588
- var shellHtml = (version) => `<!doctype html>
9388
+ var shellHtml = (version, app) => `<!doctype html>
7589
9389
  <html lang="en">
7590
9390
  <head>
7591
9391
  <meta charset="utf-8">
@@ -7596,11 +9396,11 @@ var shellHtml = (version) => `<!doctype html>
7596
9396
  </head>
7597
9397
  <body>
7598
9398
  <div id="ez-shell"></div>
7599
- <script src="${URL_PREFIX}/shell.js" data-version="${version}"></script>
9399
+ <script src="${URL_PREFIX}/shell.js" data-version="${version}" data-app="${app ?? ""}"></script>
7600
9400
  </body>
7601
9401
  </html>`;
7602
9402
  function listen(app, port) {
7603
- return new Promise((resolve6, reject) => {
9403
+ return new Promise((resolve7, reject) => {
7604
9404
  const server = createServer(app);
7605
9405
  server.once("error", reject);
7606
9406
  server.listen(port, "127.0.0.1", () => {
@@ -7608,7 +9408,7 @@ function listen(app, port) {
7608
9408
  server.on("error", (err) => {
7609
9409
  console.error(`server on port ${port} failed: ${err.message}`);
7610
9410
  });
7611
- resolve6(server);
9411
+ resolve7(server);
7612
9412
  });
7613
9413
  });
7614
9414
  }
@@ -7659,9 +9459,10 @@ function acpPrompt(feedback, skills, agent, truth, history = [], effort = "high"
7659
9459
  ] : [],
7660
9460
  "Each item resolves to source: trust `anchor.source` (file:line) when present, else",
7661
9461
  "use `anchor.components` / `anchor.section` / `anchor.selector` / `anchor.text`.",
7662
- "`[file n]` in a comment is `attachments[n-1]` (read the file at `path`); one with an",
7663
- "`origin` is a reference from their library: `origin.pageUrl` is where it was captured",
7664
- "and `origin.note` what they wrote about it;",
9462
+ "`[file n]` in a comment is `attachments[n-1]` (read the file at `path`, or, for a video,",
9463
+ "what its `video.read` says); one with an `origin` is a reference from their library:",
9464
+ "`origin.pageUrl` is where it was captured, `origin.note` what they wrote about it, and",
9465
+ "`origin.videoFrame` that the file is one still of a video the library kept only as a link;",
7665
9466
  "`[ref n]` names the entry in `references` whose `n` matches.",
7666
9467
  "A reference carrying a `variant` is one the user chose in an explore round. Its html is",
7667
9468
  "the look they settled on, not code to paste: it is a stand-in that rendered in a shadow",
@@ -7707,7 +9508,8 @@ function explorePrompt(round, capture, files, truth, earlier, language) {
7707
9508
  round.direction ? `They asked for: ${round.direction}` : "They gave no direction: they want to see how it could be arranged to read more easily and look cleaner. Range across genuinely different arrangements - what leads, what recedes, how it is grouped, how much it shows - rather than varying one thing.",
7708
9509
  ...attachments?.length ? [
7709
9510
  "",
7710
- "`[file n]` in that direction is `attachments[n-1]` here - read the file at `path`:",
9511
+ "`[file n]` in that direction is `attachments[n-1]` here - read the file at `path`, or,",
9512
+ "for a video, what its `video.read` says:",
7711
9513
  "",
7712
9514
  "```json",
7713
9515
  JSON.stringify(attachments, null, 2),
@@ -7879,13 +9681,32 @@ function configChangeNote(option, value) {
7879
9681
  return `\u5DF2\u5207\u63DB${label}\uFF1A${configValueName(option, value)}`;
7880
9682
  }
7881
9683
  var SessionRuntime = class {
7882
- constructor(project, version, updater, library, account) {
9684
+ constructor(project, version, updater, library, account, services) {
7883
9685
  this.project = project;
7884
9686
  this.version = version;
7885
9687
  this.updater = updater;
7886
9688
  this.library = library;
7887
9689
  this.account = account;
9690
+ this.services = services;
7888
9691
  this.store = new SessionStore(project);
9692
+ this.devServer = new DevServer({
9693
+ project,
9694
+ dir: this.store.dir,
9695
+ userAttachedOrigin: () => {
9696
+ const app = this.store.app;
9697
+ return app.kind === "attached" ? app.origin : null;
9698
+ },
9699
+ heldElsewhere: (origin) => services.heldElsewhere(this, origin),
9700
+ attach: (origin) => services.attachOrigin(this, origin),
9701
+ detachLeavingRecord: (origin) => this.detachDevServerApp(origin),
9702
+ environmentReady: services.environmentReady,
9703
+ proposingAgent: () => proposingAgent([this.store.session.agent, rememberedLastAgent()], (p) => agentInstalled(p)),
9704
+ onChange: () => {
9705
+ this.showAppWhenServed();
9706
+ this.broadcast();
9707
+ services.projects.changed();
9708
+ }
9709
+ });
7889
9710
  this.appProxy = new AppProxy(`${URL_PREFIX}/shell`, (req) => {
7890
9711
  this.pageCookies = req.headers.cookie ?? null;
7891
9712
  });
@@ -7894,7 +9715,7 @@ var SessionRuntime = class {
7894
9715
  view: this.store,
7895
9716
  frameOrigin: () => `http://${FRAME_HOST}:${this.port}`,
7896
9717
  overlay: () => ({ assets: overlayAssetKey(), shellOrigin: this.shellOrigin }),
7897
- hasApp: () => this.app.kind === "attached",
9718
+ hasApp: () => this.app.kind === "attached" || this.devServer.offersApp(),
7898
9719
  onChange: (change) => this.canvasChanged(change),
7899
9720
  // eslint-disable-next-line no-console
7900
9721
  log: (message2) => console.error(message2)
@@ -7914,6 +9735,7 @@ var SessionRuntime = class {
7914
9735
  this.bus.setMaxListeners(50);
7915
9736
  }
7916
9737
  store;
9738
+ devServer;
7917
9739
  bus = new EventEmitter();
7918
9740
  /** The explore rounds this session is taking variants for, and the tool the
7919
9741
  * agent sends them through. */
@@ -7937,7 +9759,7 @@ var SessionRuntime = class {
7937
9759
  * Held in memory only, and handed out only for the token in the agent's environment. */
7938
9760
  pageCookies = null;
7939
9761
  pageStorage = null;
7940
- pageStateToken = randomBytes8(32).toString("base64url");
9762
+ pageStateToken = randomBytes10(32).toString("base64url");
7941
9763
  /** The dev-server proxy and the live attachment. */
7942
9764
  appProxy;
7943
9765
  port = 0;
@@ -7972,6 +9794,8 @@ var SessionRuntime = class {
7972
9794
  agentProgress = null;
7973
9795
  /** SPIKE: the ACP-driven agent, when this session owns one. */
7974
9796
  acp = null;
9797
+ /** Who connected `acp` from a shell, so the app's quit disconnects only its own. */
9798
+ acpBy = null;
7975
9799
  /** Since the last idle check. Born touched: a session just opened is in use. */
7976
9800
  touched = true;
7977
9801
  /** Release the port and detach everyone attached, so the origin can be handed
@@ -7979,11 +9803,11 @@ var SessionRuntime = class {
7979
9803
  async stop() {
7980
9804
  this.acp?.stop();
7981
9805
  this.acp = null;
7982
- for (const resolve6 of this.pollWaiters) resolve6(null);
9806
+ for (const resolve7 of this.pollWaiters) resolve7(null);
7983
9807
  this.pollWaiters.clear();
7984
9808
  for (const client2 of this.sseClients) client2.end();
7985
9809
  this.sseClients.clear();
7986
- const closed = new Promise((resolve6) => this.server.close(() => resolve6()));
9810
+ const closed = new Promise((resolve7) => this.server.close(() => resolve7()));
7987
9811
  this.server.closeAllConnections();
7988
9812
  this.appProxy.detach();
7989
9813
  this.canvases.close();
@@ -8032,10 +9856,66 @@ var SessionRuntime = class {
8032
9856
  }
8033
9857
  setRestoreOnStart(on) {
8034
9858
  this.store.setRestoreOnStart(on);
9859
+ if (!on) this.devServer.forgetResume();
9860
+ }
9861
+ detachDevServerApp(origin) {
9862
+ if (this.app.kind !== "attached" || this.app.origin !== origin) return;
9863
+ this.appProxy.detach();
9864
+ this.forgetPageState();
9865
+ this.canvases.appChanged({ show: false, create: this.shellClients.size > 0 });
9866
+ }
9867
+ lastDevServerPhase = "idle";
9868
+ showAppWhenServed() {
9869
+ const phase = this.devServer.snapshot().phase;
9870
+ const served = (p) => p === "running" || p === "external";
9871
+ if (served(phase) && !served(this.lastDevServerPhase)) {
9872
+ this.canvases.appChanged({ show: true, create: this.shellClients.size > 0 });
9873
+ }
9874
+ this.lastDevServerPhase = phase;
9875
+ }
9876
+ /** Opening the project: the agent it was on, else `fallback`, unless they disconnected it here. */
9877
+ ensureAgent(fallback, by) {
9878
+ const live3 = !!this.acp && this.acp.snapshot().state !== "exited";
9879
+ const { agent, agentDeclined } = this.store.session;
9880
+ if (live3 || agentDeclined || this.pollWaiters.size > 0) return;
9881
+ const command = agent ?? this.store.currentChat.agent ?? fallback;
9882
+ if (command) this.connectAgent(command, by);
9883
+ }
9884
+ /** A shell's pick. The chat carries on when this agent can resume it; otherwise,
9885
+ * as with a switch, a new chat begins. */
9886
+ connectAgent(command, by) {
9887
+ const live3 = !!this.acp && this.acp.snapshot().state !== "exited";
9888
+ const resumes = !live3 && (this.store.currentChat.agent ?? command) === command;
9889
+ if (!(resumes ? this.attachAcpAgent(command) : this.switchAcpAgent(command))) return false;
9890
+ this.acpBy = by;
9891
+ this.store.setAgentBy(by);
9892
+ this.broadcast();
9893
+ return true;
9894
+ }
9895
+ /** A restore does not bring it back. Asked for by the user, it stays off until they connect one. */
9896
+ disconnectAgent(opts) {
9897
+ this.acp?.stop();
9898
+ this.acp = null;
9899
+ this.acpBy = null;
9900
+ this.agentBusy = false;
9901
+ this.activeBatch = null;
9902
+ this.activeSkills = [];
9903
+ this.agentProgress = null;
9904
+ this.store.clearAgent();
9905
+ if (opts.declined) this.store.declineAgent();
9906
+ this.broadcast();
9907
+ }
9908
+ /** The app's quit: what its windows connected goes with them. */
9909
+ disconnectAgentOf(by) {
9910
+ if (this.acpBy !== by) return false;
9911
+ this.disconnectAgent({ declined: false });
9912
+ return true;
8035
9913
  }
8036
9914
  resumeAgent() {
8037
- const agent = this.store.session.agent;
8038
- if (agent) this.restoreAcpAgent(agent);
9915
+ const { agent, agentBy } = this.store.session;
9916
+ if (!agent) return;
9917
+ this.restoreAcpAgent(agent);
9918
+ this.acpBy = agentBy ?? null;
8039
9919
  }
8040
9920
  /** Off the live agent, not the record: the record holds what was asked for. */
8041
9921
  get acpCommand() {
@@ -8075,6 +9955,10 @@ var SessionRuntime = class {
8075
9955
  ...this.app.kind === "attached" ? { app: this.app.origin } : {},
8076
9956
  canvas: this.canvases.read.snapshot(this.agentHasCanvasTools),
8077
9957
  annotations: this.store.annotations,
9958
+ ...(() => {
9959
+ const pending = this.store.pendingVideoIds;
9960
+ return pending.length ? { pendingVideos: pending } : {};
9961
+ })(),
8078
9962
  conversation: this.store.visibleConversation,
8079
9963
  agentOnline: this.pollWaiters.size > 0 || !!this.acp && this.acp.snapshot().state !== "exited",
8080
9964
  agentBusy: this.agentBusy,
@@ -8093,7 +9977,10 @@ var SessionRuntime = class {
8093
9977
  ...update ? { update } : {},
8094
9978
  account: this.account.snapshot(),
8095
9979
  ...live3.length ? { explores: live3 } : {},
8096
- ...this.canExplore ? { canExplore: true } : {}
9980
+ ...this.canExplore ? { canExplore: true } : {},
9981
+ devServer: this.devServer.snapshot(),
9982
+ appTab: this.app.kind === "attached" || this.devServer.offersApp(),
9983
+ project: this.project
8097
9984
  };
8098
9985
  }
8099
9986
  /** The chat list as the shell draws it: newest first, because that is the one
@@ -8112,7 +9999,16 @@ var SessionRuntime = class {
8112
9999
 
8113
10000
  `;
8114
10001
  for (const res of this.sseClients) res.write(data);
10002
+ if (this.agentWorking !== this.listedAgentWorking) {
10003
+ this.listedAgentWorking = this.agentWorking;
10004
+ this.services.projects.changed();
10005
+ }
10006
+ }
10007
+ get agentWorking() {
10008
+ return this.agentBusy || !!this.acp?.snapshot().cancelling;
8115
10009
  }
10010
+ /** What the project list last heard, so it hears of a turn only as it starts and ends. */
10011
+ listedAgentWorking = false;
8116
10012
  /** The workspace wrote, or a frame's file was found changed. Shell clients get
8117
10013
  * the one canvas that changed as a named event - the snapshot never carries
8118
10014
  * frames, so a streaming turn does not resend forty of them per token - and
@@ -8210,7 +10106,7 @@ var SessionRuntime = class {
8210
10106
  return this.acp.snapshot().agent === command;
8211
10107
  }
8212
10108
  this.store.setAgent(command);
8213
- mkdirSync16(ACP_NPM_PREFIX, { recursive: true });
10109
+ mkdirSync17(ACP_NPM_PREFIX, { recursive: true });
8214
10110
  this.acp = new AcpAgent({
8215
10111
  command,
8216
10112
  launch: launchCommand(command, ACP_NPM_PREFIX),
@@ -8672,7 +10568,7 @@ var SessionRuntime = class {
8672
10568
  wakePollers() {
8673
10569
  const waiters = [...this.pollWaiters];
8674
10570
  this.pollWaiters.clear();
8675
- for (const resolve6 of waiters) resolve6(this.pollOutcome());
10571
+ for (const resolve7 of waiters) resolve7(this.pollOutcome());
8676
10572
  this.broadcast();
8677
10573
  }
8678
10574
  /** Immediate poll outcome, or null when the agent should keep waiting. */
@@ -8707,11 +10603,11 @@ var SessionRuntime = class {
8707
10603
  this.agentProgress = null;
8708
10604
  const immediate = this.pollOutcome();
8709
10605
  if (immediate) return immediate;
8710
- return new Promise((resolve6) => {
10606
+ return new Promise((resolve7) => {
8711
10607
  const waiter = (r) => {
8712
10608
  clearTimeout(timer);
8713
10609
  gone.removeEventListener("abort", leave);
8714
- resolve6(r);
10610
+ resolve7(r);
8715
10611
  };
8716
10612
  const leave = () => {
8717
10613
  this.pollWaiters.delete(waiter);
@@ -8725,36 +10621,51 @@ var SessionRuntime = class {
8725
10621
  });
8726
10622
  }
8727
10623
  apiRouter() {
8728
- const api = Router6();
10624
+ const api = Router8();
8729
10625
  api.use(refuseCrossSite(() => this.shellOrigin));
8730
10626
  api.use((_req, _res, next) => {
8731
10627
  this.touch();
8732
10628
  next();
8733
10629
  });
8734
- api.post(
8735
- "/attachments",
8736
- express3.raw({ type: () => true, limit: MAX_ATTACHMENT_BYTES }),
8737
- (req, res) => {
8738
- const bytes = req.body;
8739
- if (!Buffer.isBuffer(bytes) || bytes.byteLength === 0) {
8740
- return res.status(400).json({ error: "attachment body is empty" });
10630
+ api.post("/attachments", (req, res, next) => {
10631
+ const mime = (req.get("content-type") ?? "").split(";")[0]?.trim() || "application/octet-stream";
10632
+ const limit = attachmentByteLimit(mime);
10633
+ if (Number(req.get("content-length")) > limit) return res.status(413).json({ error: "attachment is too large" });
10634
+ this.store.receiveAttachment(decodeAttachmentName(req.get("x-ez-name")), mime, req, limit).then((stored) => {
10635
+ if (stored === "too-large") return res.status(413).json({ error: "attachment is too large" });
10636
+ if (stored === "empty") return res.status(400).json({ error: "attachment body is empty" });
10637
+ if (stored.video) this.broadcast();
10638
+ res.json(stored);
10639
+ }, next);
10640
+ });
10641
+ api.get("/attachments/:id/content", (req, res) => {
10642
+ const entry = this.store.attachmentIndex[req.params.id];
10643
+ if (!entry) return res.status(404).json({ error: "attachment not found" });
10644
+ res.sendFile(
10645
+ this.store.attachmentPath(entry),
10646
+ storedFileOptions(entry.mime),
10647
+ // A Range reader drops requests it no longer needs, so a stream cut short
10648
+ // after the headers went out is the reader's choice, not a failure.
10649
+ (error) => {
10650
+ if (error && !res.headersSent) res.status(404).json({ error: "attachment not found" });
8741
10651
  }
8742
- const mime = (req.get("content-type") ?? "").split(";")[0]?.trim();
8743
- const attachment = this.store.addAttachment(
8744
- decodeAttachmentName(req.get("x-ez-name")),
8745
- mime || "application/octet-stream",
8746
- bytes
8747
- );
8748
- res.json(attachment);
8749
- }
8750
- );
10652
+ );
10653
+ });
10654
+ api.put("/attachments/:id/video", express4.json({ limit: SETTLE_BODY_LIMIT }), (req, res) => {
10655
+ const settle = parseVideoSettle(req.body);
10656
+ if (typeof settle === "string") return res.status(400).json({ error: settle });
10657
+ const status = this.store.settleVideo(req.params.id, settle);
10658
+ if (status.state !== "gone") this.broadcast();
10659
+ res.status(status.state === "gone" ? 404 : 200).json(status);
10660
+ });
8751
10661
  api.post(CANVAS_MCP_ROUTE, ...this.canvasMcp.handlers());
8752
- api.use(express3.json({ limit: "2mb" }));
10662
+ api.use(express4.json({ limit: "2mb" }));
8753
10663
  api.post(MCP_ROUTE, this.exploreMcp.handler());
8754
10664
  api.use(canvasApiRouter({ user: this.canvases.user, read: this.canvases.read }));
8755
10665
  api.use(libraryApiRouter({ library: this.library }));
8756
10666
  api.use(extensionApiRouter({ distDir, version: this.version, dataDir: libraryHome() }));
8757
10667
  api.use(announcementApiRouter());
10668
+ api.use(devServerApiRouter(this.devServer));
8758
10669
  api.use(
8759
10670
  accountApiRouter({ account: this.account, callbackUrl: () => `${this.shellOrigin}${ACCOUNT_CALLBACK_PATH}` })
8760
10671
  );
@@ -8838,6 +10749,7 @@ var SessionRuntime = class {
8838
10749
  case "referenced":
8839
10750
  return res.status(409).json({ error: "attachment belongs to queued or sent feedback" });
8840
10751
  default:
10752
+ this.broadcast();
8841
10753
  return res.json({ ok: true });
8842
10754
  }
8843
10755
  });
@@ -8851,11 +10763,11 @@ var SessionRuntime = class {
8851
10763
  return res.status(404).json({ error: `\u7D20\u6750 ${found.missing} \u5DF2\u7D93\u4E0D\u5728\u7D20\u6750\u5EAB\u88E1\u4E86` });
8852
10764
  }
8853
10765
  const pick = newId();
8854
- res.json(
8855
- found.ok.map(
8856
- ({ title, file, origin }) => this.store.copyAttachment(title, file.mediaType, file.path, { pick, ...origin })
8857
- )
10766
+ const copied = found.ok.map(
10767
+ ({ title, file, origin }) => this.store.copyAttachment(title, file.mediaType, file.path, { pick, ...origin })
8858
10768
  );
10769
+ if (copied.some((a) => a.video)) this.broadcast();
10770
+ res.json(copied);
8859
10771
  },
8860
10772
  (error) => error instanceof LibraryError ? res.status(503).json({ error: error.message }) : next(error)
8861
10773
  ).catch(next);
@@ -8906,6 +10818,7 @@ var SessionRuntime = class {
8906
10818
  if (!ids) return res.status(400).json({ error: "attachments must be an array of ids" });
8907
10819
  const files = this.store.getAttachments(ids);
8908
10820
  if (!files) return res.status(400).json({ error: "unknown attachment id" });
10821
+ if (this.store.holdsPendingVideo(files)) return res.status(409).json({ error: "\u5F71\u7247\u9084\u5728\u8655\u7406\uFF0C\u8655\u7406\u5B8C\u624D\u80FD\u4EA4\u7D66 agent" });
8909
10822
  const refs = parseReferences(req.body?.references);
8910
10823
  if (!refs) return res.status(400).json({ error: "references must be an array of anchors" });
8911
10824
  const skills = Array.isArray(req.body?.skills) ? req.body.skills.filter((v) => typeof v === "string") : [];
@@ -8972,12 +10885,17 @@ var SessionRuntime = class {
8972
10885
  const id = String(req.body?.id ?? "");
8973
10886
  const profile = AGENT_PROFILES.find((p) => p.id === id);
8974
10887
  if (!profile) return res.status(400).json({ error: "unknown agent" });
8975
- if (!this.acp) {
8976
- return res.status(409).json({ error: "this review is not driving an agent" });
10888
+ if (!this.acp && this.pollWaiters.size > 0) {
10889
+ return res.status(409).json({ error: "another agent is driving this review" });
8977
10890
  }
8978
- if (!this.switchAcpAgent(profile.command)) {
10891
+ if (!this.connectAgent(profile.command, initiator(req))) {
8979
10892
  return res.status(409).json({ error: "that agent cannot be started right now" });
8980
10893
  }
10894
+ rememberLastAgent(profile.command);
10895
+ res.json({ ok: true });
10896
+ });
10897
+ api.post("/acp/disconnect", (_req, res) => {
10898
+ this.disconnectAgent({ declined: true });
8981
10899
  res.json({ ok: true });
8982
10900
  });
8983
10901
  api.get("/acp/skills", (_req, res) => {
@@ -9025,6 +10943,7 @@ var SessionRuntime = class {
9025
10943
  if (!ids) return res.status(400).json({ error: "attachments must be an array of ids" });
9026
10944
  const files = this.store.getAttachments(ids);
9027
10945
  if (!files) return res.status(400).json({ error: "unknown attachment id" });
10946
+ if (files.some((f) => f.video?.state === "pending")) return res.status(409).json({ error: "\u5F71\u7247\u9084\u5728\u8655\u7406\uFF0C\u8655\u7406\u5B8C\u624D\u80FD\u4EA4\u7D66 agent" });
9028
10947
  const refs = parseReferences(req.body?.references);
9029
10948
  if (!refs) return res.status(400).json({ error: "references must be an array of anchors" });
9030
10949
  const round = await this.startExplore({
@@ -9187,11 +11106,14 @@ var SessionRuntime = class {
9187
11106
  return api;
9188
11107
  }
9189
11108
  async start() {
9190
- const app = express3();
11109
+ const app = express4();
9191
11110
  app.use(frameHost(this.canvases.read, () => this.shellOrigin));
9192
- const rk = Router6();
9193
- rk.get("/shell", (_req, res) => res.type("html").send(shellHtml(this.version)));
9194
- for (const file of ["overlay.js", "overlay.css", "shell.js", "shell.css", "events-worker.js"]) {
11111
+ const rk = Router8();
11112
+ rk.get(
11113
+ "/shell",
11114
+ (_req, res) => res.type("html").send(shellHtml(this.version, this.app.kind === "attached" ? this.app.origin : null))
11115
+ );
11116
+ for (const file of ["overlay.js", "overlay.css", "shell.js", "shell.css", "events-worker.js", "video-digest.js"]) {
9195
11117
  const type = file.endsWith(".css") ? "text/css" : "text/javascript";
9196
11118
  const keyed = file.startsWith("overlay.");
9197
11119
  rk.get(
@@ -9204,7 +11126,7 @@ var SessionRuntime = class {
9204
11126
  }
9205
11127
  rk.use(
9206
11128
  "/announcements",
9207
- express3.static(join19(distDir, "announcements"), { immutable: true, maxAge: "1y", fallthrough: false })
11129
+ express4.static(join25(distDir, "announcements"), { immutable: true, maxAge: "1y", fallthrough: false })
9208
11130
  );
9209
11131
  rk.get(ACCOUNT_CALLBACK_PATH.slice(URL_PREFIX.length), crossSiteAccountCallback(this.account));
9210
11132
  rk.use("/api", this.apiRouter());
@@ -9222,6 +11144,7 @@ var SessionRuntime = class {
9222
11144
  if (this.port === preferred) this.shellReturnDeadline = Date.now() + SHELL_RETURN_MS;
9223
11145
  this.store.setPort(this.port);
9224
11146
  this.canvases.start();
11147
+ this.devServer.resume();
9225
11148
  return this.port;
9226
11149
  }
9227
11150
  /** Whether a shell is open on this session, once any left open across a
@@ -9289,17 +11212,23 @@ async function handoverTo(cliEntry2) {
9289
11212
  throw new Error(`new daemon did not come up within 20s (see ${DAEMON_LOG})`);
9290
11213
  }
9291
11214
  async function daemonMain(version, opts = {}) {
11215
+ scrubRunAsNode();
11216
+ const environment = adoptLoginEnvironment();
9292
11217
  const range = controlPortRange();
9293
11218
  if (opts.succeed === void 0) {
9294
- const [adopted] = await ownDaemonsInRange(range);
9295
- if (adopted) return adopt(adopted);
11219
+ const [adopted2] = await ownDaemonsInRange(range);
11220
+ if (adopted2) return adopt(adopted2);
9296
11221
  }
9297
11222
  let sessions;
11223
+ let projectsChanged = () => {
11224
+ };
9298
11225
  const broadcastAll = () => {
9299
11226
  for (const s of sessions.list()) s.broadcast();
11227
+ projectsChanged();
9300
11228
  };
9301
11229
  let stopping = false;
9302
11230
  let boundAt = 0n;
11231
+ const releaseDevServers = () => Promise.all(sessions.list().map((s) => s.devServer.release()));
9303
11232
  const updater = new Updater({
9304
11233
  current: version,
9305
11234
  latestVersion: updateChecksDisabled() ? async () => null : () => latestVersion(),
@@ -9307,16 +11236,42 @@ async function daemonMain(version, opts = {}) {
9307
11236
  handover: handoverTo,
9308
11237
  // Exit is the release: it frees every port at once and takes the agent
9309
11238
  // children with it, which is what the successor is waiting for.
9310
- retire: async () => process.exit(0),
11239
+ retire: async () => {
11240
+ await releaseDevServers();
11241
+ process.exit(0);
11242
+ },
9311
11243
  onChange: broadcastAll
9312
11244
  });
9313
11245
  const library = lazyLibrary(() => openLibrary({ dir: libraryDir(libraryHome()) }));
9314
11246
  const account = new Account({ baseUrl: apiBaseUrl(), onChange: broadcastAll });
11247
+ let controlPort = 0;
11248
+ const controlOrigin = () => `http://127.0.0.1:${controlPort}`;
11249
+ const book = new ProjectBook(listPersistedSessions());
11250
+ const projects = new ProjectHub({
11251
+ sessions: { list: () => sessions.list(), open: (req) => sessions.open(req) },
11252
+ book,
11253
+ version,
11254
+ account: () => account.snapshot(),
11255
+ defaultAgent,
11256
+ projectRoot,
11257
+ restored: () => restored
11258
+ });
11259
+ projectsChanged = () => projects.changed();
11260
+ const services = {
11261
+ projects,
11262
+ attachOrigin: (runtime, origin) => sessions.attachOrigin(runtime, origin, { userAttached: false, now: Date.now() }),
11263
+ heldElsewhere: (runtime, origin) => {
11264
+ const holder = sessions.byOrigin(origin);
11265
+ return !!holder && holder !== runtime;
11266
+ },
11267
+ environmentReady: () => environment
11268
+ };
9315
11269
  sessions = new SessionDirectory(
9316
- (project) => new SessionRuntime(project, version, updater, library, account),
11270
+ (project) => new SessionRuntime(project, version, updater, library, account, services),
9317
11271
  {
9318
11272
  restorable: listRestorableSessions,
9319
11273
  get: persistedSession,
11274
+ used: (project, now) => book.markUsed(project, now),
9320
11275
  migrate: () => {
9321
11276
  const { merged } = migrateLegacySessions();
9322
11277
  if (merged.size) {
@@ -9327,8 +11282,8 @@ async function daemonMain(version, opts = {}) {
9327
11282
  // eslint-disable-next-line no-console
9328
11283
  (message2) => console.error(message2)
9329
11284
  );
9330
- const control = express3();
9331
- control.use(express3.json());
11285
+ const control = express4();
11286
+ control.use(express4.json());
9332
11287
  control.get("/control/health", (_req, res) => {
9333
11288
  if (stopping) return res.status(503).json({ ok: false, stopping: true });
9334
11289
  res.json({
@@ -9337,12 +11292,32 @@ async function daemonMain(version, opts = {}) {
9337
11292
  version,
9338
11293
  pid: process.pid,
9339
11294
  dataDirId: dataDirId(),
9340
- boundAt: String(boundAt)
11295
+ boundAt: String(boundAt),
11296
+ desktop: DESKTOP_CONTRACT
9341
11297
  });
9342
11298
  });
11299
+ control.use("/control/desktop", refuseCrossSite(controlOrigin), desktopContractGate());
11300
+ control.post("/control/desktop/open", async (req, res) => {
11301
+ const path = req.body?.path;
11302
+ if (typeof path !== "string") return res.status(400).json({ error: "path required" });
11303
+ const added = projects.add(path);
11304
+ if ("error" in added) return res.status(400).json(added);
11305
+ const opened = await projects.open(added.project, "desktop");
11306
+ if ("ok" in opened) return res.status(opened.status).json({ error: opened.error });
11307
+ res.json(opened);
11308
+ });
11309
+ control.post("/control/desktop/quit", async (_req, res) => {
11310
+ projects.disconnectAgentsOf("desktop");
11311
+ res.json({ stopped: await projects.stopManagedBy("desktop") });
11312
+ });
11313
+ control.use(homePageRouter({ version, distDir, icon: SHELL_ICON }));
11314
+ control.use(`${URL_PREFIX}/api`, projectsApiRouter(projects, { version, origin: controlOrigin }));
11315
+ control.get(ACCOUNT_CALLBACK_PATH, crossSiteAccountCallback(account));
11316
+ control.use(`${URL_PREFIX}/api/account`, refuseCrossSite(controlOrigin));
11317
+ control.use(`${URL_PREFIX}/api`, accountApiRouter({ account, callbackUrl: () => `${controlOrigin()}${ACCOUNT_CALLBACK_PATH}` }));
9343
11318
  let ready;
9344
- const restored = new Promise((resolve6) => {
9345
- ready = resolve6;
11319
+ const restored = new Promise((resolve7) => {
11320
+ ready = resolve7;
9346
11321
  });
9347
11322
  control.use("/control/sessions", (_req, _res, next) => {
9348
11323
  restored.then(() => next(), next);
@@ -9360,7 +11335,7 @@ async function daemonMain(version, opts = {}) {
9360
11335
  );
9361
11336
  });
9362
11337
  control.post("/control/sessions", async (req, res) => {
9363
- const { url, reopen, project, agent } = req.body ?? {};
11338
+ const { url, reopen, project, agent, connectAgent, devServer } = req.body ?? {};
9364
11339
  if (typeof project !== "string" || !project) {
9365
11340
  return res.status(400).json({ error: "missing project" });
9366
11341
  }
@@ -9386,11 +11361,14 @@ async function daemonMain(version, opts = {}) {
9386
11361
  }
9387
11362
  const runtime = opened.runtime;
9388
11363
  runtime.touch();
11364
+ if (devServer === true && !parsed) runtime.devServer.ensure("cli");
9389
11365
  if (typeof agent === "string" && agent) {
9390
11366
  if (!runtime.attachAcpAgent(agent) && !runtime.switchAcpAgent(agent)) {
9391
11367
  return res.status(409).json({ error: "that agent cannot be started right now" });
9392
11368
  }
9393
11369
  runtime.broadcast();
11370
+ } else if (connectAgent === true) {
11371
+ runtime.ensureAgent(defaultAgent(), "cli");
9394
11372
  }
9395
11373
  res.json({
9396
11374
  port: runtime.port,
@@ -9414,16 +11392,17 @@ async function daemonMain(version, opts = {}) {
9414
11392
  ...runtime.app.kind === "attached" ? { app: runtime.app.origin } : {}
9415
11393
  });
9416
11394
  });
9417
- control.post("/control/stop", (_req, res) => {
11395
+ control.post("/control/stop", refuseCrossSite(controlOrigin), (_req, res) => {
9418
11396
  stopping = true;
9419
11397
  clearRegistry(process.pid);
9420
11398
  res.json({ ok: true });
9421
- setTimeout(() => process.exit(0), 100);
11399
+ void releaseDevServers().then(() => process.exit(0));
9422
11400
  });
9423
11401
  const controlServer = await listenOn(control, [...range.ports, 0]);
9424
11402
  boundAt = process.hrtime.bigint();
9425
11403
  const controlAddress = controlServer.address();
9426
11404
  const port = typeof controlAddress === "object" && controlAddress ? controlAddress.port : 0;
11405
+ controlPort = port;
9427
11406
  if (opts.succeed === void 0) {
9428
11407
  const before = (a, b) => {
9429
11408
  const [x, y] = [a.boundAt ?? 0n, b.boundAt ?? 0n];
@@ -9437,14 +11416,16 @@ async function daemonMain(version, opts = {}) {
9437
11416
  }
9438
11417
  }
9439
11418
  writeRegistry({ port, pid: process.pid, startedAt: Date.now() });
9440
- const reaped = reapOrphanedAgents();
11419
+ const reaped = reapOrphanedGroups();
9441
11420
  if (reaped > 0) {
9442
- console.error(`reaped ${reaped} agent process group(s) left by a killed daemon`);
11421
+ console.error(`reaped ${reaped} process group(s) left by a killed daemon`);
9443
11422
  }
9444
11423
  const cleanup = () => {
9445
11424
  clearRegistry(process.pid);
9446
- clearAgentRecord();
9447
- process.exit(0);
11425
+ void releaseDevServers().then(() => {
11426
+ clearGroupRecord();
11427
+ process.exit(0);
11428
+ });
9448
11429
  };
9449
11430
  process.on("SIGTERM", cleanup);
9450
11431
  process.on("SIGINT", cleanup);
@@ -9452,9 +11433,10 @@ async function daemonMain(version, opts = {}) {
9452
11433
  if (!predecessorGone) {
9453
11434
  console.error(`predecessor pid ${opts.succeed} is still running; restoring sessions anyway`);
9454
11435
  }
11436
+ await environment;
9455
11437
  await sessions.restore({ migrate: predecessorGone });
9456
11438
  ready();
9457
- if (predecessorGone) pruneInstalledVersions(join19(distDir, "cli.mjs"));
11439
+ if (predecessorGone) pruneInstalledVersions(join25(distDir, "cli.mjs"));
9458
11440
  void refreshCaptureInstall({ distDir, version, dataDir: libraryHome() }).catch(
9459
11441
  (err) => console.error(`capture extension refresh failed: ${err instanceof Error ? err.message : String(err)}`)
9460
11442
  );
@@ -9463,7 +11445,7 @@ async function daemonMain(version, opts = {}) {
9463
11445
  if (!envFlagOn(process.env[NO_IDLE_STOP_ENV])) {
9464
11446
  const idle = new IdleCount(IDLE_STOP_MS / IDLE_CHECK_MS);
9465
11447
  setInterval(() => {
9466
- const busy = sessions.list().filter((s) => s.takeBusy()).length > 0;
11448
+ const busy = [sessions.list().filter((s) => s.takeBusy()).length > 0, projects.takeBusy()].some(Boolean);
9467
11449
  if (idle.observe(busy) && sessions.size > 0) {
9468
11450
  sessions.retireIdle();
9469
11451
  cleanup();
@@ -9474,8 +11456,8 @@ async function daemonMain(version, opts = {}) {
9474
11456
  }
9475
11457
 
9476
11458
  // src/inspire.ts
9477
- import { existsSync as existsSync11, mkdirSync as mkdirSync17, writeFileSync as writeFileSync11 } from "node:fs";
9478
- import { dirname as dirname9, join as join20 } from "node:path";
11459
+ import { existsSync as existsSync14, mkdirSync as mkdirSync18, writeFileSync as writeFileSync11 } from "node:fs";
11460
+ import { dirname as dirname10, join as join26 } from "node:path";
9479
11461
  var DEFAULT_INSPIRE_URL = "https://inspire.ezdesign.dev";
9480
11462
  var INSPIRE_URL_ENV = `${APP_NAME.toUpperCase()}_INSPIRE_URL`;
9481
11463
  var SEARCH_TIMEOUT_MS = 15e3;
@@ -9490,17 +11472,17 @@ function apiUrl(path, base = inspireBaseUrl()) {
9490
11472
  return `${base}${path.startsWith("/") ? path : `/${path}`}`;
9491
11473
  }
9492
11474
  function inspirationCacheDir(dataDir = DATA_DIR) {
9493
- return join20(dataDir, "cache", "inspiration");
11475
+ return join26(dataDir, "cache", "inspiration");
9494
11476
  }
9495
11477
  function imageCachePath(entryId, image, index = 0, dataDir = DATA_DIR) {
9496
11478
  const position = image.position ?? index;
9497
- const parts = [image.kind, image.viewport ?? void 0, String(position)].filter(
11479
+ const parts2 = [image.kind, image.viewport ?? void 0, String(position)].filter(
9498
11480
  (p) => typeof p === "string" && p !== ""
9499
11481
  );
9500
- return join20(inspirationCacheDir(dataDir), entryId, `${parts.join("-")}.png`);
11482
+ return join26(inspirationCacheDir(dataDir), entryId, `${parts2.join("-")}.png`);
9501
11483
  }
9502
11484
  function sourceCachePath(entryId, source, dataDir = DATA_DIR) {
9503
- return join20(inspirationCacheDir(dataDir), entryId, `source.${source.kind}`);
11485
+ return join26(inspirationCacheDir(dataDir), entryId, `source.${source.kind}`);
9504
11486
  }
9505
11487
  var slugify = (raw) => raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
9506
11488
  function entryTitle(entry) {
@@ -9575,7 +11557,7 @@ async function download(url, path, fetchImpl) {
9575
11557
  try {
9576
11558
  const res = await fetchImpl(url, { signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS) });
9577
11559
  if (!res.ok) return false;
9578
- mkdirSync17(dirname9(path), { recursive: true });
11560
+ mkdirSync18(dirname10(path), { recursive: true });
9579
11561
  writeFileSync11(path, Buffer.from(await res.arrayBuffer()));
9580
11562
  return true;
9581
11563
  } catch {
@@ -9598,12 +11580,12 @@ async function cacheFiles(results, opts = {}) {
9598
11580
  );
9599
11581
  for (const [index, image] of ordered2) {
9600
11582
  const path = imageCachePath(entry.id, image, index, dataDir);
9601
- if (existsSync11(path) || await download(image.url, path, fetchImpl)) images.push(path);
11583
+ if (existsSync14(path) || await download(image.url, path, fetchImpl)) images.push(path);
9602
11584
  }
9603
11585
  const sources = [];
9604
11586
  for (const source of entry.sources ?? []) {
9605
11587
  const path = sourceCachePath(entry.id, source, dataDir);
9606
- if (await download(source.url, path, fetchImpl) || existsSync11(path)) sources.push(path);
11588
+ if (await download(source.url, path, fetchImpl) || existsSync14(path)) sources.push(path);
9607
11589
  }
9608
11590
  files.set(entry.id, { images, sources });
9609
11591
  }
@@ -9652,22 +11634,9 @@ function parseSearchArgs(args) {
9652
11634
  return { query, tags, limit, json: args.includes("--json") };
9653
11635
  }
9654
11636
 
9655
- // src/project-root.ts
9656
- import { existsSync as existsSync12 } from "node:fs";
9657
- import { dirname as dirname10, join as join21, parse, resolve as resolve4 } from "node:path";
9658
- function projectRoot(from) {
9659
- let dir = resolve4(from);
9660
- const { root } = parse(dir);
9661
- for (; ; ) {
9662
- if (existsSync12(join21(dir, ".git")) || existsSync12(join21(dir, "package.json"))) return dir;
9663
- if (dir === root) return resolve4(from);
9664
- dir = dirname10(dir);
9665
- }
9666
- }
9667
-
9668
11637
  // src/cli.ts
9669
11638
  var cliEntry = fileURLToPath2(import.meta.url);
9670
- var pkg = JSON.parse(readFileSync16(join22(dirname11(cliEntry), "../package.json"), "utf8"));
11639
+ var pkg = JSON.parse(readFileSync21(join27(dirname11(cliEntry), "../package.json"), "utf8"));
9671
11640
  var EXTENSION_HELP = ` ${APP_NAME} extension install [--user-data-dir <dir>] | uninstall | status
9672
11641
  install the native host the Chrome capture extension saves through, and
9673
11642
  print the folder to load in chrome://extensions. Saving needs no daemon
@@ -9678,14 +11647,18 @@ var EXTENSION_HELP = ` ${APP_NAME} extension install [--user-data-dir <dir>] |
9678
11647
  var HELP = `${APP_NAME} - annotate your live dev app, feedback flows to your local agent
9679
11648
 
9680
11649
  Usage:
9681
- ${APP_NAME} [<url>] [--reopen] [--agent <name|command>]
11650
+ ${APP_NAME} [<url>] [--reopen] [--agent <name|command> | --no-agent]
9682
11651
  open (or resume) this project's review session. With <url>, that dev
9683
11652
  server is on its App tab (a dev server that moved ports keeps the
9684
- conversation); without one, the shell opens on the project's canvases.
9685
- --agent (SPIKE) drives the agent over ACP: the session spawns it, feedback
9686
- goes straight to it, and its stream, questions and permission prompts all
9687
- surface in the review shell - no terminal needed. Profiles: claude, gemini,
9688
- codex; anything else is run as an ACP agent command line.
11653
+ conversation); without one, its App tab offers to start the project's
11654
+ dev server, or the shell opens on its canvases when there is none.
11655
+ The session drives its agent over ACP: it spawns it, feedback goes
11656
+ straight to it, and its stream, questions and permission prompts all
11657
+ surface in the review shell - no terminal needed. Without --agent it
11658
+ connects the agent this project had, else your last one, else the first
11659
+ installed of claude, codex and gemini; one you disconnected in the shell
11660
+ stays off. --agent picks one: claude, codex, gemini, or any ACP agent
11661
+ command line. --no-agent connects none, for an agent working it with poll.
9689
11662
  ${APP_NAME} poll [<url>] [--agent-reply <msg>]
9690
11663
  block until the user sends feedback; prints JSON, then exits
9691
11664
  ${APP_NAME} progress [<url>] <msg>
@@ -9820,7 +11793,9 @@ async function cmdOpen(rawUrl, flags, agentArg) {
9820
11793
  );
9821
11794
  }
9822
11795
  }
9823
- const daemon = await ensureDaemon(cliEntry, pkg.version);
11796
+ const resolved = await resolveDaemon(cliEntry, pkg.version);
11797
+ if (resolved.kind === "newer") return reexecAs(resolved.cliEntry);
11798
+ const daemon = resolved.daemon;
9824
11799
  const project = projectRoot(process.cwd());
9825
11800
  const res = await controlFetch(daemon.port, "/control/sessions", {
9826
11801
  method: "POST",
@@ -9828,7 +11803,7 @@ async function cmdOpen(rawUrl, flags, agentArg) {
9828
11803
  ...target ? { url: target.href } : {},
9829
11804
  reopen: flags.has("--reopen"),
9830
11805
  project,
9831
- ...agentArg ? { agent: agentCommand(agentArg) } : {}
11806
+ ...agentArg ? { agent: agentCommand(agentArg) } : { connectAgent: !flags.has("--no-agent") }
9832
11807
  })
9833
11808
  });
9834
11809
  const body = await res.json();
@@ -9839,10 +11814,19 @@ async function cmdOpen(rawUrl, flags, agentArg) {
9839
11814
  console.log(`shell: ${body.shellUrl}${body.shellOpen ? " (already open in your browser)" : ""}`);
9840
11815
  if (body.agent) {
9841
11816
  console.log(`agent: ${body.agent} (ACP) - review runs in the shell`);
9842
- } else {
11817
+ } else if (flags.has("--no-agent")) {
9843
11818
  console.log(`next: run \`${APP_NAME} poll\` in this project and wait for feedback`);
11819
+ } else {
11820
+ console.log(`next: pick an agent in the shell, or run again with --agent claude|codex|gemini`);
9844
11821
  }
9845
11822
  }
11823
+ function reexecAs(cliEntry2) {
11824
+ const { status } = spawnSync2(SELF_NODE.execPath, [cliEntry2, ...process.argv.slice(2)], {
11825
+ stdio: "inherit",
11826
+ env: selfNodeEnv()
11827
+ });
11828
+ process.exit(status ?? 1);
11829
+ }
9846
11830
  async function cmdPoll(handle, reply) {
9847
11831
  let port = await findSessionPort(handle);
9848
11832
  if (reply?.trim()) {
@@ -10030,7 +12014,7 @@ async function cmdExtension(args) {
10030
12014
  if (rest[i] !== "--user-data-dir") fail(`unknown option: ${rest[i]}`, `${APP_NAME} extension install [--user-data-dir <dir>]`);
10031
12015
  const dir = rest[++i];
10032
12016
  if (!dir) fail("missing value for --user-data-dir");
10033
- userDataDirs.push(resolve5(dir));
12017
+ userDataDirs.push(resolve6(dir));
10034
12018
  }
10035
12019
  const placement = { distDir: dirname11(cliEntry), version: pkg.version };
10036
12020
  try {
@@ -10117,6 +12101,7 @@ async function main() {
10117
12101
  const agentIdx = argv.indexOf("--agent");
10118
12102
  const agentArg = agentIdx >= 0 ? argv[agentIdx + 1] : void 0;
10119
12103
  if (agentIdx >= 0 && !agentArg) fail("missing value for --agent", "e.g. --agent claude");
12104
+ if (agentArg && flags.has("--no-agent")) fail("--agent and --no-agent cannot be used together");
10120
12105
  return cmdOpen(first === void 0 || first.startsWith("--") ? void 0 : first, flags, agentArg);
10121
12106
  }
10122
12107
  }