leglas 0.7.4 → 0.8.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.
package/README.md CHANGED
@@ -196,7 +196,14 @@ edit the hero. The supporting commands:
196
196
  a direction from the rail hands over a block that ends in this command, so
197
197
  an agent given the block can go and get the rest.
198
198
  - `npx leglas keep "Aurora" --to src/components/hero.tsx` moves the winner
199
- into real source and ends the exploration.
199
+ into real source and ends the exploration. It also writes down what the
200
+ exploration was, into `design-log/`: every direction with its note, the words
201
+ you typed at each of them, the captures the agent was sent, and which one
202
+ won. Plain markdown and PNGs, committed, so a pull request can link it and
203
+ somebody can read it in three months without this tool. Exploring is
204
+ episodic, and the archive is what makes coming back to a surface cheaper than
205
+ starting over. `npx leglas log` lists what is there. Set `logDir` if you want
206
+ it somewhere else.
200
207
 
201
208
  Asking for a change works from the interface too. Type what you want
202
209
  changed into the field under the rail (or press `R`) and Leglas composes a
@@ -388,6 +395,7 @@ Usage
388
395
  leglas classify Decide where a direction should live
389
396
  leglas add --title T --url U Register a preview on this machine
390
397
  leglas list Show every preview, shared and local
398
+ leglas log [entry] What past explorations decided
391
399
  leglas show <title> Everything Leglas knows about one direction
392
400
  leglas requests Show change requests made from the interface
393
401
  leglas watch --run "<cmd>" Hand each request to your agent as it arrives
package/dist/args.d.ts CHANGED
@@ -44,6 +44,10 @@ export type ParseResult = {
44
44
  } | {
45
45
  kind: "list";
46
46
  json: boolean;
47
+ } | {
48
+ kind: "log";
49
+ entry: string | null;
50
+ json: boolean;
47
51
  } | {
48
52
  kind: "show";
49
53
  title: string;
package/dist/bin.js CHANGED
@@ -287,6 +287,19 @@ function parseArgs(argv) {
287
287
  }
288
288
  return { kind: "requests", json: rest.includes("--json"), clear: rest.includes("--clear") };
289
289
  }
290
+ if (argv[0] === "log") {
291
+ const rest = argv.slice(1);
292
+ const flags = rest.filter((argument) => argument.startsWith("--"));
293
+ const unknown = flags.find((flag) => flag !== "--json");
294
+ if (unknown !== void 0) {
295
+ return { kind: "error", message: `leglas log does not take ${unknown}.` };
296
+ }
297
+ const names = rest.filter((argument) => !argument.startsWith("--"));
298
+ if (names.length > 1) {
299
+ return { kind: "error", message: "leglas log takes one entry at most." };
300
+ }
301
+ return { kind: "log", entry: names[0] ?? null, json: flags.includes("--json") };
302
+ }
290
303
  if (argv[0] === "list") {
291
304
  const rest = argv.slice(1);
292
305
  const unknown = rest.find((argument) => argument !== "--json");
@@ -409,6 +422,84 @@ function parseArgs(argv) {
409
422
  import { stat as stat4 } from "fs/promises";
410
423
  import { join as join13 } from "path";
411
424
 
425
+ // ../server/dist/log.js
426
+ var DEFAULT_LOG_DIR = "design-log";
427
+ function slugify(value) {
428
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
429
+ }
430
+ function frameFor(title, requests) {
431
+ let found = null;
432
+ for (const request of requests) {
433
+ if (request.title !== title)
434
+ continue;
435
+ for (const attachment of request.attachments ?? []) {
436
+ if (attachment.kind === "frame")
437
+ found = attachment;
438
+ }
439
+ }
440
+ return found;
441
+ }
442
+ function askedOf(title, requests) {
443
+ return requests.filter((request) => request.title === title && request.status !== "failed" && request.intent.trim() !== "").map((request) => request.intent.trim());
444
+ }
445
+ function composeEntry(input) {
446
+ const slug = `${input.date}-${slugify(input.surface)}`;
447
+ const pictures = [];
448
+ const lines2 = [];
449
+ lines2.push(`# ${input.surface}, ${input.date}`);
450
+ lines2.push("");
451
+ lines2.push(`**${input.won.title}** won and became \`${input.won.to}\`. ${input.previews.length === 1 ? "It was the only direction." : `${input.previews.length} directions were compared.`}`);
452
+ lines2.push("");
453
+ for (const preview of input.previews) {
454
+ const won = preview.title === input.won.title;
455
+ lines2.push(`## ${preview.title}${won ? " \u2014 kept" : ""}`);
456
+ lines2.push("");
457
+ if (preview.note !== void 0 && preview.note.trim() !== "") {
458
+ lines2.push(preview.note.trim());
459
+ lines2.push("");
460
+ }
461
+ const frame = frameFor(preview.title, input.requests);
462
+ if (frame !== null) {
463
+ const name = `${slugify(preview.title)}.png`;
464
+ pictures.push({ from: frame.file, to: name });
465
+ lines2.push(`![${preview.title}](${slug}/${name})`);
466
+ lines2.push("");
467
+ }
468
+ if (preview.basedOn !== void 0) {
469
+ lines2.push(`A variant of ${preview.basedOn}.`);
470
+ lines2.push("");
471
+ }
472
+ const asked = askedOf(preview.title, input.requests);
473
+ if (asked.length > 0) {
474
+ lines2.push("Asked for:");
475
+ lines2.push("");
476
+ for (const words of asked)
477
+ lines2.push(`- ${words}`);
478
+ lines2.push("");
479
+ }
480
+ const notes = input.annotations.filter((note) => note.title === preview.title);
481
+ if (notes.length > 0) {
482
+ lines2.push("Marked on the design:");
483
+ lines2.push("");
484
+ for (const note of notes)
485
+ lines2.push(`- ${note.note}`);
486
+ lines2.push("");
487
+ }
488
+ }
489
+ const failed = input.requests.filter((request) => request.status === "failed");
490
+ if (failed.length > 0) {
491
+ lines2.push("## Changes that did not land");
492
+ lines2.push("");
493
+ for (const request of failed) {
494
+ const why = request.failure?.message;
495
+ lines2.push(`- ${request.title}: ${request.intent}${why === void 0 ? "" : ` (${why})`}`);
496
+ }
497
+ lines2.push("");
498
+ }
499
+ return { slug, markdown: `${lines2.join("\n").trimEnd()}
500
+ `, pictures };
501
+ }
502
+
412
503
  // ../server/dist/config.js
413
504
  var DEFAULT_DEV_SERVER = "http://localhost:3000";
414
505
  var DEFAULT_INSTALL_COMMAND = "npm install";
@@ -527,6 +618,10 @@ function normalizeConfig(raw, options = {}) {
527
618
  if (requireDevCommand && previews.some((preview) => preview.branch !== void 0) && devCommand === void 0) {
528
619
  errors.push("A preview names a branch, so devCommand is required: Leglas has to start that checkout itself.");
529
620
  }
621
+ const logDir = source["logDir"] ?? DEFAULT_LOG_DIR;
622
+ if (typeof logDir !== "string" || logDir.trim() === "") {
623
+ errors.push("logDir must be a non-empty string.");
624
+ }
530
625
  const installCommand = source["installCommand"] ?? DEFAULT_INSTALL_COMMAND;
531
626
  if (typeof installCommand !== "string" || installCommand.trim() === "") {
532
627
  errors.push("installCommand must be a non-empty string.");
@@ -543,7 +638,8 @@ function normalizeConfig(raw, options = {}) {
543
638
  previews,
544
639
  scanPreviews,
545
640
  devCommand: typeof devCommand === "string" ? devCommand : void 0,
546
- installCommand
641
+ installCommand,
642
+ logDir
547
643
  },
548
644
  errors: []
549
645
  };
@@ -1321,6 +1417,7 @@ import net from "net";
1321
1417
  function createProxyHandler(options) {
1322
1418
  const target = new URL(options.target);
1323
1419
  const host = target.hostname;
1420
+ const dialHost = host.replace(/^\[|\]$/g, "");
1324
1421
  const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
1325
1422
  const authority = target.port ? `${host}:${target.port}` : host;
1326
1423
  function upstreamHeaders(req) {
@@ -1337,7 +1434,19 @@ function createProxyHandler(options) {
1337
1434
  }
1338
1435
  return {
1339
1436
  request(req, res, publicOrigin) {
1340
- const upstream = http.request({ host, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
1437
+ options.onActivity?.();
1438
+ options.onOpen?.();
1439
+ let open = true;
1440
+ const close = () => {
1441
+ if (!open)
1442
+ return;
1443
+ open = false;
1444
+ options.onActivity?.();
1445
+ options.onClose?.();
1446
+ };
1447
+ res.once("finish", close);
1448
+ res.once("close", close);
1449
+ const upstream = http.request({ host: dialHost, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
1341
1450
  const headers = { ...upstreamRes.headers };
1342
1451
  const location = rewriteLocation(typeof headers.location === "string" ? headers.location : void 0, publicOrigin);
1343
1452
  if (location !== void 0)
@@ -1359,7 +1468,17 @@ Start it, or point Leglas somewhere else with --user-port.`);
1359
1468
  req.pipe(upstream);
1360
1469
  },
1361
1470
  upgrade(req, socket, head) {
1362
- const upstream = net.connect(port, host, () => {
1471
+ options.onActivity?.();
1472
+ options.onOpen?.();
1473
+ let open = true;
1474
+ const closeActivity = () => {
1475
+ if (!open)
1476
+ return;
1477
+ open = false;
1478
+ options.onActivity?.();
1479
+ options.onClose?.();
1480
+ };
1481
+ const upstream = net.connect(port, dialHost, () => {
1363
1482
  const headers = Object.entries(upstreamHeaders(req)).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r
1364
1483
  `).join("");
1365
1484
  upstream.write(`${req.method} ${req.url} HTTP/1.1\r
@@ -1371,6 +1490,7 @@ ${headers}\r
1371
1490
  socket.pipe(upstream);
1372
1491
  });
1373
1492
  const shutdown = () => {
1493
+ closeActivity();
1374
1494
  upstream.destroy();
1375
1495
  socket.destroy();
1376
1496
  };
@@ -1381,6 +1501,62 @@ ${headers}\r
1381
1501
  }
1382
1502
  };
1383
1503
  }
1504
+ function startProxyServer(options) {
1505
+ return new Promise((resolve5, reject) => {
1506
+ let open = 0;
1507
+ const handler = createProxyHandler({
1508
+ ...options,
1509
+ onOpen: () => {
1510
+ open += 1;
1511
+ options.onOpen?.();
1512
+ },
1513
+ onClose: () => {
1514
+ open = Math.max(0, open - 1);
1515
+ options.onClose?.();
1516
+ }
1517
+ });
1518
+ const server = http.createServer((req, res) => {
1519
+ const address = server.address();
1520
+ const port = typeof address === "object" && address !== null ? address.port : 0;
1521
+ handler.request(req, res, `http://127.0.0.1:${port}`);
1522
+ });
1523
+ const sockets = /* @__PURE__ */ new Set();
1524
+ server.on("connection", (socket) => {
1525
+ sockets.add(socket);
1526
+ socket.once("close", () => sockets.delete(socket));
1527
+ });
1528
+ server.on("upgrade", (req, socket, head) => handler.upgrade(req, socket, head));
1529
+ const onError = (error) => {
1530
+ server.removeListener("listening", onListening);
1531
+ reject(error);
1532
+ };
1533
+ const onListening = () => {
1534
+ server.removeListener("error", onError);
1535
+ const address = server.address();
1536
+ const port = typeof address === "object" && address !== null ? address.port : 0;
1537
+ let closed = null;
1538
+ resolve5({
1539
+ active: () => open > 0,
1540
+ close: () => {
1541
+ if (closed !== null)
1542
+ return closed;
1543
+ closed = new Promise((done) => {
1544
+ for (const socket of sockets)
1545
+ socket.destroy();
1546
+ sockets.clear();
1547
+ server.closeAllConnections();
1548
+ server.close(() => done());
1549
+ });
1550
+ return closed;
1551
+ },
1552
+ url: `http://127.0.0.1:${port}`
1553
+ });
1554
+ };
1555
+ server.once("error", onError);
1556
+ server.once("listening", onListening);
1557
+ server.listen(0, "127.0.0.1");
1558
+ });
1559
+ }
1384
1560
 
1385
1561
  // ../server/dist/browser.js
1386
1562
  import { randomBytes } from "crypto";
@@ -1448,12 +1624,12 @@ function buildNumber(entry) {
1448
1624
  const digits = /(\d+)\s*$/.exec(entry)?.[1];
1449
1625
  return digits === void 0 ? 0 : Number(digits);
1450
1626
  }
1451
- function cacheRoots(platform, home, readdir4 = readableDirectories) {
1627
+ function cacheRoots(platform, home, readdir5 = readableDirectories) {
1452
1628
  const playwright = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
1453
1629
  const puppeteer = join4(home, ".cache", "puppeteer");
1454
1630
  const newestFirst = (dir, keep = () => true) => ({
1455
1631
  root: dir,
1456
- entries: readdir4(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
1632
+ entries: readdir5(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
1457
1633
  });
1458
1634
  return [
1459
1635
  newestFirst(playwright, (entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")),
@@ -1474,13 +1650,13 @@ function findBrowser(search = {}) {
1474
1650
  const home = search.home ?? homedir();
1475
1651
  const exists = search.exists ?? existsSync2;
1476
1652
  const onPath = search.onPath ?? ((name) => firstOnPath(name, env));
1477
- const readdir4 = search.readdir ?? readableDirectories;
1653
+ const readdir5 = search.readdir ?? readableDirectories;
1478
1654
  const firstExisting = (paths) => paths.find((path) => exists(path)) ?? null;
1479
1655
  for (const candidate of [env.LEGLAS_BROWSER, env.CHROME_PATH, env.PUPPETEER_EXECUTABLE_PATH]) {
1480
1656
  if (typeof candidate === "string" && candidate !== "" && exists(candidate))
1481
1657
  return candidate;
1482
1658
  }
1483
- const caches = cacheRoots(platform, home, readdir4);
1659
+ const caches = cacheRoots(platform, home, readdir5);
1484
1660
  const shell = firstExisting(caches.flatMap(({ root, entries }) => entries.flatMap((entry) => HEADLESS_SHELL.map((rest) => join4(root, entry, ...rest)))));
1485
1661
  if (shell !== null)
1486
1662
  return shell;
@@ -1510,7 +1686,7 @@ function findBrowser(search = {}) {
1510
1686
  }
1511
1687
  if (platform === "darwin" || platform === "linux") {
1512
1688
  const playwrightRoot = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
1513
- const playwright = readdir4(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1689
+ const playwright = readdir5(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1514
1690
  const root = join4(playwrightRoot, entry);
1515
1691
  return [
1516
1692
  ...FOR_TESTING.map((rest) => join4(root, ...rest)),
@@ -1527,7 +1703,7 @@ function findBrowser(search = {}) {
1527
1703
  const puppeteerCache = join4(home, ".cache", "puppeteer");
1528
1704
  for (const kind of ["chrome", "chrome-headless-shell"]) {
1529
1705
  const kindRoot = join4(puppeteerCache, kind);
1530
- const found = firstExisting(readdir4(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1706
+ const found = firstExisting(readdir5(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1531
1707
  const root = join4(kindRoot, entry);
1532
1708
  return [...FOR_TESTING, ...HEADLESS_SHELL].map((rest) => join4(root, ...rest));
1533
1709
  }));
@@ -2746,6 +2922,8 @@ async function startAppProcess(options) {
2746
2922
  }
2747
2923
 
2748
2924
  // ../server/dist/branches.js
2925
+ var BRANCH_IDLE_MS = 10 * 60 * 1e3;
2926
+ var BRANCH_SWEEP_MS = 3e4;
2749
2927
  function publicBranchState(state) {
2750
2928
  if (state.status === "ready")
2751
2929
  return { status: "ready" };
@@ -2755,7 +2933,11 @@ function createBranchRegistry(options) {
2755
2933
  const states = new Map(options.previews.map((preview) => [preview.title, { status: "idle" }]));
2756
2934
  const previews = new Map(options.previews.map((preview) => [preview.title, preview]));
2757
2935
  const inflight = /* @__PURE__ */ new Map();
2936
+ const stopping = /* @__PURE__ */ new Map();
2937
+ const lastActivity = /* @__PURE__ */ new Map();
2938
+ const proxies = /* @__PURE__ */ new Map();
2758
2939
  const boot = options.startWorktree ?? startWorktree;
2940
+ const proxy = options.startProxy ?? startProxyServer;
2759
2941
  let closed = false;
2760
2942
  let stopPromise = null;
2761
2943
  const transition = (title, state) => {
@@ -2767,6 +2949,43 @@ function createBranchRegistry(options) {
2767
2949
  options.onChange?.(title, state);
2768
2950
  return state;
2769
2951
  };
2952
+ const stopReady = (title, state, toIdle) => {
2953
+ const current = stopping.get(title);
2954
+ if (current !== void 0)
2955
+ return current;
2956
+ const pending = (async () => {
2957
+ await proxies.get(title)?.close().catch(() => {
2958
+ });
2959
+ await state.worktree.stop().catch(() => {
2960
+ });
2961
+ proxies.delete(title);
2962
+ lastActivity.delete(title);
2963
+ if (toIdle && !closed && states.get(title) === state) {
2964
+ transition(title, { status: "idle" });
2965
+ }
2966
+ })().finally(() => {
2967
+ stopping.delete(title);
2968
+ });
2969
+ stopping.set(title, pending);
2970
+ return pending;
2971
+ };
2972
+ const sweep = () => {
2973
+ const now = Date.now();
2974
+ for (const [title, state] of states) {
2975
+ const branchProxy = proxies.get(title);
2976
+ if (state.status !== "ready" || branchProxy === void 0 || stopping.has(title))
2977
+ continue;
2978
+ if (branchProxy.active()) {
2979
+ lastActivity.set(title, now);
2980
+ continue;
2981
+ }
2982
+ const seen = lastActivity.get(title) ?? now;
2983
+ if (now - seen >= BRANCH_IDLE_MS)
2984
+ void stopReady(title, state, true);
2985
+ }
2986
+ };
2987
+ const sweepTimer = setInterval(sweep, BRANCH_SWEEP_MS);
2988
+ sweepTimer.unref();
2770
2989
  const begin = (title) => {
2771
2990
  const preview = previews.get(title);
2772
2991
  const current = states.get(title);
@@ -2794,9 +3013,21 @@ function createBranchRegistry(options) {
2794
3013
  } catch (error) {
2795
3014
  checkout = Promise.reject(error);
2796
3015
  }
2797
- const starting = checkout.then((worktree) => {
3016
+ const starting = checkout.then(async (worktree) => {
2798
3017
  transition(title, { status: "starting", phase: "starting" });
2799
- return transition(title, { status: "ready", worktree });
3018
+ try {
3019
+ const branchProxy = await proxy({
3020
+ target: worktree.url,
3021
+ onActivity: () => lastActivity.set(title, Date.now())
3022
+ });
3023
+ proxies.set(title, branchProxy);
3024
+ lastActivity.set(title, Date.now());
3025
+ return transition(title, { status: "ready", worktree });
3026
+ } catch (error) {
3027
+ await worktree.stop().catch(() => {
3028
+ });
3029
+ throw error;
3030
+ }
2800
3031
  }).catch((error) => transition(title, {
2801
3032
  status: "failed",
2802
3033
  reason: error instanceof Error ? error.message : String(error)
@@ -2808,15 +3039,17 @@ function createBranchRegistry(options) {
2808
3039
  };
2809
3040
  return {
2810
3041
  state: (title) => states.get(title),
3042
+ url: (title) => proxies.get(title)?.url,
2811
3043
  start: begin,
2812
3044
  stop: () => {
2813
3045
  if (stopPromise !== null)
2814
3046
  return stopPromise;
2815
3047
  closed = true;
3048
+ clearInterval(sweepTimer);
2816
3049
  stopPromise = Promise.allSettled([...inflight.values()]).then(async () => {
2817
- const worktrees = [...states.values()].filter((state) => state.status === "ready").map((state) => state.worktree);
2818
- await Promise.all(worktrees.map((worktree) => worktree.stop().catch(() => {
2819
- })));
3050
+ await Promise.allSettled([...stopping.values()]);
3051
+ const ready = [...states.entries()].filter((entry) => entry[1].status === "ready" && proxies.has(entry[0]));
3052
+ await Promise.all(ready.map(([title, state]) => stopReady(title, state, false)));
2820
3053
  });
2821
3054
  return stopPromise;
2822
3055
  }
@@ -4913,6 +5146,7 @@ function resolveTitle(input, titles, renames) {
4913
5146
 
4914
5147
  // ../server/dist/server.js
4915
5148
  import { createReadStream, existsSync as existsSync3, statSync, unwatchFile, watch as watchFs, watchFile } from "fs";
5149
+ import { createHash as createHash2 } from "crypto";
4916
5150
  import { mkdir as mkdir8, readdir as readdir3, writeFile as writeFile8 } from "fs/promises";
4917
5151
  import http2 from "http";
4918
5152
  import net3 from "net";
@@ -4991,6 +5225,30 @@ function sendJson(res, status, body) {
4991
5225
  });
4992
5226
  res.end(payload);
4993
5227
  }
5228
+ function etagMatches(value, etag) {
5229
+ if (value === void 0)
5230
+ return false;
5231
+ const values = Array.isArray(value) ? value : [value];
5232
+ return values.some((header) => header.split(",").some((candidate) => {
5233
+ const tag = candidate.trim();
5234
+ return tag === "*" || tag === etag || tag === `W/${etag}`;
5235
+ }));
5236
+ }
5237
+ function sendConditionalJson(req, res, body) {
5238
+ const payload = JSON.stringify(body);
5239
+ const etag = `"${createHash2("sha256").update(payload).digest("base64url")}"`;
5240
+ if (etagMatches(req.headers["if-none-match"], etag)) {
5241
+ res.writeHead(304, { etag, "cache-control": "private, no-cache" });
5242
+ res.end();
5243
+ return;
5244
+ }
5245
+ res.writeHead(200, {
5246
+ "content-type": "application/json; charset=utf-8",
5247
+ "cache-control": "private, no-cache",
5248
+ etag
5249
+ });
5250
+ res.end(payload);
5251
+ }
4994
5252
  var CAPTURE_DEADLINE_MS = 15e3;
4995
5253
  var CAPTURE_LOAD_MS = Math.floor(CAPTURE_DEADLINE_MS * LOAD_SHARE);
4996
5254
  function captureSlug(title) {
@@ -5383,9 +5641,10 @@ async function startServer(options) {
5383
5641
  return preview;
5384
5642
  const state = branches.state(preview.title) ?? { status: "idle" };
5385
5643
  const { url: route, ...withoutUrl } = preview;
5644
+ const branchUrl = branches.url(preview.title);
5386
5645
  return state.status === "ready" ? {
5387
5646
  ...withoutUrl,
5388
- url: `${state.worktree.url}${route}`,
5647
+ url: `${branchUrl ?? state.worktree.url}${route}`,
5389
5648
  state: publicBranchState(state)
5390
5649
  } : { ...withoutUrl, state: publicBranchState(state) };
5391
5650
  };
@@ -5394,7 +5653,7 @@ async function startServer(options) {
5394
5653
  if (preview.branch === void 0)
5395
5654
  return preview;
5396
5655
  const state = branches.state(preview.title);
5397
- return state?.status === "ready" ? { ...preview, url: `${state.worktree.url}${preview.url}` } : null;
5656
+ return state?.status === "ready" ? { ...preview, url: `${branches.url(preview.title) ?? state.worktree.url}${preview.url}` } : null;
5398
5657
  };
5399
5658
  if (options.pool === void 0) {
5400
5659
  void reapOrphanedBrowsers().catch(() => {
@@ -5457,7 +5716,7 @@ async function startServer(options) {
5457
5716
  errors.push(notice);
5458
5717
  return void readLocalPreviews(cwd).then(({ previews: local, errors: localErrors }) => {
5459
5718
  if (localErrors.length > 0) {
5460
- return sendJson(res, 200, {
5719
+ return sendConditionalJson(req, res, {
5461
5720
  project,
5462
5721
  devServer: target,
5463
5722
  scanPreviews: config?.scanPreviews ?? true,
@@ -5470,7 +5729,7 @@ async function startServer(options) {
5470
5729
  const currentBoot = boot.filter((preview) => preview.local !== true || localTitles.has(preview.title));
5471
5730
  const known = new Set(currentBoot.map((preview) => preview.title));
5472
5731
  const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
5473
- sendJson(res, 200, {
5732
+ sendConditionalJson(req, res, {
5474
5733
  project,
5475
5734
  devServer: target,
5476
5735
  scanPreviews: config?.scanPreviews ?? true,
@@ -5478,7 +5737,7 @@ async function startServer(options) {
5478
5737
  errors,
5479
5738
  warnings: configWarnings
5480
5739
  });
5481
- }).catch(() => sendJson(res, 200, {
5740
+ }).catch(() => sendConditionalJson(req, res, {
5482
5741
  project,
5483
5742
  devServer: target,
5484
5743
  scanPreviews: config?.scanPreviews ?? true,
@@ -5913,7 +6172,7 @@ async function startServer(options) {
5913
6172
  waiting: null,
5914
6173
  failedIds: []
5915
6174
  };
5916
- return void readRequests(cwd).then((requests) => sendJson(res, 200, {
6175
+ return void readRequests(cwd).then((requests) => sendConditionalJson(req, res, {
5917
6176
  requests: requests.map(({ id, title, intent, mode, status, failure, notes }) => ({
5918
6177
  id,
5919
6178
  title,
@@ -6016,7 +6275,7 @@ async function startServer(options) {
6016
6275
  });
6017
6276
  }
6018
6277
  if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
6019
- return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
6278
+ return void readAnnotations(cwd).then((annotations) => sendConditionalJson(req, res, { annotations }));
6020
6279
  }
6021
6280
  if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
6022
6281
  if (!hasJsonBody(req)) {
@@ -6142,7 +6401,7 @@ async function startServer(options) {
6142
6401
  });
6143
6402
  }
6144
6403
  if (path === `${LEGLAS_PREFIX}/api/health`) {
6145
- return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable, cwd }));
6404
+ return void probe(target).then((reachable) => sendConditionalJson(req, res, { devServer: target, reachable, cwd }));
6146
6405
  }
6147
6406
  if (path.startsWith(`${FILES_PREFIX}/`)) {
6148
6407
  const rest = path.slice(FILES_PREFIX.length + 1);
@@ -6594,6 +6853,12 @@ out of the ignored directory, deletes the rest of the exploration, and drops
6594
6853
  them from the rail. Then change their component to use the kept component
6595
6854
  instead of the switcher.
6596
6855
 
6856
+ Keeping also writes what the exploration was into \`design-log/\`, which is
6857
+ committed. Before exploring a surface, read \`npx leglas log --json\` and any
6858
+ entry for that surface: it says what was already tried there, in the user's own
6859
+ words, and which direction won. Proposing something that was already rejected
6860
+ wastes their time, and the record is there so you do not have to ask.
6861
+
6597
6862
  Useful to know:
6598
6863
 
6599
6864
  - \`.leglas/\` is gitignored. Exploration is disposable and nothing in there
@@ -6693,7 +6958,7 @@ async function runInit(options, deps) {
6693
6958
 
6694
6959
  // src/run-keep.ts
6695
6960
  import { existsSync as existsSync4 } from "fs";
6696
- import { mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
6961
+ import { copyFile as copyFile2, mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
6697
6962
  import { dirname as dirname9, join as join15 } from "path";
6698
6963
 
6699
6964
  // src/keep.ts
@@ -6776,6 +7041,29 @@ function renameExport(source, to) {
6776
7041
  to
6777
7042
  );
6778
7043
  }
7044
+ async function writeLogEntry(options) {
7045
+ const entry = composeEntry({
7046
+ surface: options.surface,
7047
+ won: options.won,
7048
+ previews: options.previews,
7049
+ requests: await readRequests(options.cwd),
7050
+ annotations: await readAnnotations(options.cwd),
7051
+ date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
7052
+ });
7053
+ const dir = join15(options.cwd, options.logDir);
7054
+ await mkdir9(dir, { recursive: true });
7055
+ const file = join15(dir, `${entry.slug}.md`);
7056
+ await writeFile10(file, entry.markdown, "utf8");
7057
+ if (entry.pictures.length > 0) {
7058
+ const pictureDir = join15(dir, entry.slug);
7059
+ await mkdir9(pictureDir, { recursive: true });
7060
+ for (const picture of entry.pictures) {
7061
+ await copyFile2(join15(options.cwd, picture.from), join15(pictureDir, picture.to)).catch(() => {
7062
+ });
7063
+ }
7064
+ }
7065
+ return `${options.logDir}/${entry.slug}.md`;
7066
+ }
6779
7067
  async function runKeep(options, deps) {
6780
7068
  const loaded = await loadConfig(options.cwd);
6781
7069
  const local = await readLocalPreviews(options.cwd);
@@ -6804,6 +7092,20 @@ async function runKeep(options, deps) {
6804
7092
  const source = await readFile13(from, "utf8");
6805
7093
  await mkdir9(dirname9(to), { recursive: true });
6806
7094
  await writeFile10(to, renameExport(source, plan.exportName), "utf8");
7095
+ const surface = plan.removeDir.slice(plan.removeDir.lastIndexOf("/") + 1);
7096
+ let logged = null;
7097
+ let logError = null;
7098
+ try {
7099
+ logged = await writeLogEntry({
7100
+ cwd: options.cwd,
7101
+ logDir: loaded.config?.logDir ?? DEFAULT_LOG_DIR,
7102
+ surface,
7103
+ won: { title: resolved.title, to: plan.move.to },
7104
+ previews: previews.filter((preview) => plan.dropTitles.includes(preview.title))
7105
+ });
7106
+ } catch (error) {
7107
+ logError = error instanceof Error ? error.message : String(error);
7108
+ }
6807
7109
  await rm5(join15(options.cwd, plan.removeDir), { recursive: true, force: true });
6808
7110
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
6809
7111
  if (options.json) {
@@ -6815,6 +7117,8 @@ async function runKeep(options, deps) {
6815
7117
  exportName: plan.exportName,
6816
7118
  removed: plan.removeDir,
6817
7119
  droppedPreviews: dropped,
7120
+ logged,
7121
+ logError,
6818
7122
  instructions: plan.instructions
6819
7123
  })
6820
7124
  );
@@ -6822,6 +7126,8 @@ async function runKeep(options, deps) {
6822
7126
  }
6823
7127
  deps.log(` kept ${plan.move.to}`);
6824
7128
  deps.log(` removed ${plan.removeDir}`);
7129
+ if (logged !== null) deps.log(` logged ${logged}`);
7130
+ if (logError !== null) deps.error(` The decision log could not be written: ${logError}`);
6825
7131
  if (dropped > 0) {
6826
7132
  deps.log(` dropped ${dropped} direction${dropped === 1 ? "" : "s"} from the rail`);
6827
7133
  }
@@ -6918,17 +7224,67 @@ async function runNew(options, deps) {
6918
7224
  return { exitCode: 0, written };
6919
7225
  }
6920
7226
 
6921
- // src/run-previews.ts
6922
- import { readFile as readFile15, writeFile as writeFile12 } from "fs/promises";
7227
+ // src/run-log.ts
7228
+ import { readFile as readFile15, readdir as readdir4 } from "fs/promises";
6923
7229
  import { join as join17 } from "path";
7230
+ function headline(markdown) {
7231
+ const first = markdown.split("\n", 1)[0] ?? "";
7232
+ return first.replace(/^#\s*/, "").trim();
7233
+ }
7234
+ async function runLog(options, deps) {
7235
+ const loaded = await loadConfig(options.cwd);
7236
+ const dir = loaded.config?.logDir ?? DEFAULT_LOG_DIR;
7237
+ let names;
7238
+ try {
7239
+ names = (await readdir4(join17(options.cwd, dir))).filter((name) => name.endsWith(".md")).sort().reverse();
7240
+ } catch {
7241
+ names = [];
7242
+ }
7243
+ if (options.entry !== null) {
7244
+ const wanted = options.entry.replace(/\.md$/, "");
7245
+ const found = names.find((name) => name === `${wanted}.md`);
7246
+ if (found === void 0) {
7247
+ const error = `No entry called ${JSON.stringify(options.entry)} in ${dir}.`;
7248
+ if (options.json) deps.log(JSON.stringify({ ok: false, error }));
7249
+ else deps.error(error);
7250
+ return { exitCode: 1 };
7251
+ }
7252
+ const markdown = await readFile15(join17(options.cwd, dir, found), "utf8");
7253
+ if (options.json) deps.log(JSON.stringify({ ok: true, entry: wanted, markdown }));
7254
+ else deps.log(markdown.trimEnd());
7255
+ return { exitCode: 0 };
7256
+ }
7257
+ const entries = await Promise.all(
7258
+ names.map(async (name) => ({
7259
+ entry: name.replace(/\.md$/, ""),
7260
+ title: headline(await readFile15(join17(options.cwd, dir, name), "utf8")),
7261
+ file: `${dir}/${name}`
7262
+ }))
7263
+ );
7264
+ if (options.json) {
7265
+ deps.log(JSON.stringify({ ok: true, dir, entries }));
7266
+ return { exitCode: 0 };
7267
+ }
7268
+ if (entries.length === 0) {
7269
+ deps.log(` No decisions recorded yet. One is written each time you run leglas keep.`);
7270
+ return { exitCode: 0 };
7271
+ }
7272
+ const width = Math.max(...entries.map((entry) => entry.entry.length));
7273
+ for (const entry of entries) deps.log(` ${entry.entry.padEnd(width)} ${entry.title}`);
7274
+ return { exitCode: 0 };
7275
+ }
7276
+
7277
+ // src/run-previews.ts
7278
+ import { readFile as readFile16, writeFile as writeFile12 } from "fs/promises";
7279
+ import { join as join18 } from "path";
6924
7280
  function envelope(deps, ok, body) {
6925
7281
  deps.log(JSON.stringify({ ok, ...body }));
6926
7282
  }
6927
7283
  async function ensureIgnored(cwd) {
6928
- const path = join17(cwd, ".gitignore");
7284
+ const path = join18(cwd, ".gitignore");
6929
7285
  let current = null;
6930
7286
  try {
6931
- current = await readFile15(path, "utf8");
7287
+ current = await readFile16(path, "utf8");
6932
7288
  } catch {
6933
7289
  current = null;
6934
7290
  }
@@ -7255,15 +7611,15 @@ async function runShow(options, deps) {
7255
7611
 
7256
7612
  // src/run-watch.ts
7257
7613
  import { spawn as spawn3 } from "child_process";
7258
- import { mkdir as mkdir11, readFile as readFile16, writeFile as writeFile13 } from "fs/promises";
7259
- import { dirname as dirname11, join as join18 } from "path";
7614
+ import { mkdir as mkdir11, readFile as readFile17, writeFile as writeFile13 } from "fs/promises";
7615
+ import { dirname as dirname11, join as join19 } from "path";
7260
7616
  var POLL_MS2 = 2e3;
7261
7617
  var HEARTBEAT_TIMEOUT_MS = 1e3;
7262
7618
  async function saveTemplate(cwd, run4) {
7263
- const path = join18(cwd, WATCH_PATH);
7619
+ const path = join19(cwd, WATCH_PATH);
7264
7620
  let config = {};
7265
7621
  try {
7266
- const parsed2 = JSON.parse(await readFile16(path, "utf8"));
7622
+ const parsed2 = JSON.parse(await readFile17(path, "utf8"));
7267
7623
  if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) {
7268
7624
  config = parsed2;
7269
7625
  }
@@ -7412,7 +7768,7 @@ async function runWatch(options, deps) {
7412
7768
  import { existsSync as existsSync6 } from "fs";
7413
7769
  import { realpath as realpath4 } from "fs/promises";
7414
7770
  import { createRequire } from "module";
7415
- import { basename as basename6, dirname as dirname12, join as join19, relative as relative5, resolve as resolve4 } from "path";
7771
+ import { basename as basename6, dirname as dirname12, join as join20, relative as relative5, resolve as resolve4 } from "path";
7416
7772
  import { fileURLToPath } from "url";
7417
7773
 
7418
7774
  // src/dev-server-owner.ts
@@ -7498,8 +7854,8 @@ function devServerOwnerWarning(origin, projectRoot, owners) {
7498
7854
 
7499
7855
  // src/run.ts
7500
7856
  function findShellDir() {
7501
- const bundled = join19(dirname12(fileURLToPath(import.meta.url)), "shell");
7502
- if (existsSync6(join19(bundled, "index.html"))) return bundled;
7857
+ const bundled = join20(dirname12(fileURLToPath(import.meta.url)), "shell");
7858
+ if (existsSync6(join20(bundled, "index.html"))) return bundled;
7503
7859
  try {
7504
7860
  const require2 = createRequire(import.meta.url);
7505
7861
  return dirname12(require2.resolve("@leglas/shell/dist/index.html"));
@@ -7513,7 +7869,7 @@ function shellWord(value) {
7513
7869
  return `'${value.replaceAll("'", `'\\''`)}'`;
7514
7870
  }
7515
7871
  function embeddedLeglasCommand() {
7516
- const entry = join19(dirname12(fileURLToPath(import.meta.url)), "bin.js");
7872
+ const entry = join20(dirname12(fileURLToPath(import.meta.url)), "bin.js");
7517
7873
  if (!existsSync6(entry)) return "npx -y leglas";
7518
7874
  return [process.execPath, entry].map(shellWord).join(" ");
7519
7875
  }
@@ -7545,7 +7901,7 @@ async function run3(options, deps) {
7545
7901
  const fileMounts = /* @__PURE__ */ new Map();
7546
7902
  for (const preview of merged?.previews ?? []) {
7547
7903
  if (preview.file !== void 0) {
7548
- const absolute = join19(options.cwd, preview.file);
7904
+ const absolute = join20(options.cwd, preview.file);
7549
7905
  if (!existsSync6(absolute)) {
7550
7906
  previewErrors.push(
7551
7907
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -7667,6 +8023,7 @@ Usage
7667
8023
  leglas classify Decide where a direction should live
7668
8024
  leglas add --title T --url U Register a preview on this machine
7669
8025
  leglas list Show every preview, shared and local
8026
+ leglas log [entry] What past explorations decided
7670
8027
  leglas show <title> Everything Leglas knows about one direction
7671
8028
  leglas requests Show change requests made from the interface
7672
8029
  leglas watch --run "<cmd>" Hand each request to your agent as it arrives
@@ -7804,6 +8161,13 @@ if (parsed.kind === "watch") {
7804
8161
  );
7805
8162
  process.exit(outcome.exitCode);
7806
8163
  }
8164
+ if (parsed.kind === "log") {
8165
+ const outcome = await runLog(
8166
+ { entry: parsed.entry, json: parsed.json, cwd: process.cwd() },
8167
+ previewDeps
8168
+ );
8169
+ process.exit(outcome.exitCode);
8170
+ }
7807
8171
  if (parsed.kind === "list") {
7808
8172
  const outcome = await runList({ json: parsed.json, cwd: process.cwd() }, previewDeps);
7809
8173
  process.exit(outcome.exitCode);
package/dist/index.js CHANGED
@@ -281,6 +281,19 @@ function parseArgs(argv) {
281
281
  }
282
282
  return { kind: "requests", json: rest.includes("--json"), clear: rest.includes("--clear") };
283
283
  }
284
+ if (argv[0] === "log") {
285
+ const rest = argv.slice(1);
286
+ const flags = rest.filter((argument) => argument.startsWith("--"));
287
+ const unknown = flags.find((flag) => flag !== "--json");
288
+ if (unknown !== void 0) {
289
+ return { kind: "error", message: `leglas log does not take ${unknown}.` };
290
+ }
291
+ const names = rest.filter((argument) => !argument.startsWith("--"));
292
+ if (names.length > 1) {
293
+ return { kind: "error", message: "leglas log takes one entry at most." };
294
+ }
295
+ return { kind: "log", entry: names[0] ?? null, json: flags.includes("--json") };
296
+ }
284
297
  if (argv[0] === "list") {
285
298
  const rest = argv.slice(1);
286
299
  const unknown = rest.find((argument) => argument !== "--json");
@@ -725,6 +738,12 @@ out of the ignored directory, deletes the rest of the exploration, and drops
725
738
  them from the rail. Then change their component to use the kept component
726
739
  instead of the switcher.
727
740
 
741
+ Keeping also writes what the exploration was into \`design-log/\`, which is
742
+ committed. Before exploring a surface, read \`npx leglas log --json\` and any
743
+ entry for that surface: it says what was already tried there, in the user's own
744
+ words, and which direction won. Proposing something that was already rejected
745
+ wastes their time, and the record is there so you do not have to ask.
746
+
728
747
  Useful to know:
729
748
 
730
749
  - \`.leglas/\` is gitignored. Exploration is disposable and nothing in there
@@ -785,6 +804,84 @@ ${AGENTS_SECTION}`
785
804
  // src/keep.ts
786
805
  import { basename as basename4, extname as extname4, normalize as normalize2 } from "path";
787
806
 
807
+ // ../server/dist/log.js
808
+ var DEFAULT_LOG_DIR = "design-log";
809
+ function slugify(value) {
810
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
811
+ }
812
+ function frameFor(title, requests) {
813
+ let found = null;
814
+ for (const request of requests) {
815
+ if (request.title !== title)
816
+ continue;
817
+ for (const attachment of request.attachments ?? []) {
818
+ if (attachment.kind === "frame")
819
+ found = attachment;
820
+ }
821
+ }
822
+ return found;
823
+ }
824
+ function askedOf(title, requests) {
825
+ return requests.filter((request) => request.title === title && request.status !== "failed" && request.intent.trim() !== "").map((request) => request.intent.trim());
826
+ }
827
+ function composeEntry(input) {
828
+ const slug = `${input.date}-${slugify(input.surface)}`;
829
+ const pictures = [];
830
+ const lines2 = [];
831
+ lines2.push(`# ${input.surface}, ${input.date}`);
832
+ lines2.push("");
833
+ lines2.push(`**${input.won.title}** won and became \`${input.won.to}\`. ${input.previews.length === 1 ? "It was the only direction." : `${input.previews.length} directions were compared.`}`);
834
+ lines2.push("");
835
+ for (const preview of input.previews) {
836
+ const won = preview.title === input.won.title;
837
+ lines2.push(`## ${preview.title}${won ? " \u2014 kept" : ""}`);
838
+ lines2.push("");
839
+ if (preview.note !== void 0 && preview.note.trim() !== "") {
840
+ lines2.push(preview.note.trim());
841
+ lines2.push("");
842
+ }
843
+ const frame = frameFor(preview.title, input.requests);
844
+ if (frame !== null) {
845
+ const name = `${slugify(preview.title)}.png`;
846
+ pictures.push({ from: frame.file, to: name });
847
+ lines2.push(`![${preview.title}](${slug}/${name})`);
848
+ lines2.push("");
849
+ }
850
+ if (preview.basedOn !== void 0) {
851
+ lines2.push(`A variant of ${preview.basedOn}.`);
852
+ lines2.push("");
853
+ }
854
+ const asked = askedOf(preview.title, input.requests);
855
+ if (asked.length > 0) {
856
+ lines2.push("Asked for:");
857
+ lines2.push("");
858
+ for (const words of asked)
859
+ lines2.push(`- ${words}`);
860
+ lines2.push("");
861
+ }
862
+ const notes = input.annotations.filter((note) => note.title === preview.title);
863
+ if (notes.length > 0) {
864
+ lines2.push("Marked on the design:");
865
+ lines2.push("");
866
+ for (const note of notes)
867
+ lines2.push(`- ${note.note}`);
868
+ lines2.push("");
869
+ }
870
+ }
871
+ const failed = input.requests.filter((request) => request.status === "failed");
872
+ if (failed.length > 0) {
873
+ lines2.push("## Changes that did not land");
874
+ lines2.push("");
875
+ for (const request of failed) {
876
+ const why = request.failure?.message;
877
+ lines2.push(`- ${request.title}: ${request.intent}${why === void 0 ? "" : ` (${why})`}`);
878
+ }
879
+ lines2.push("");
880
+ }
881
+ return { slug, markdown: `${lines2.join("\n").trimEnd()}
882
+ `, pictures };
883
+ }
884
+
788
885
  // ../server/dist/config.js
789
886
  var DEFAULT_DEV_SERVER = "http://localhost:3000";
790
887
  var DEFAULT_INSTALL_COMMAND = "npm install";
@@ -903,6 +1000,10 @@ function normalizeConfig(raw, options = {}) {
903
1000
  if (requireDevCommand && previews.some((preview) => preview.branch !== void 0) && devCommand === void 0) {
904
1001
  errors.push("A preview names a branch, so devCommand is required: Leglas has to start that checkout itself.");
905
1002
  }
1003
+ const logDir = source["logDir"] ?? DEFAULT_LOG_DIR;
1004
+ if (typeof logDir !== "string" || logDir.trim() === "") {
1005
+ errors.push("logDir must be a non-empty string.");
1006
+ }
906
1007
  const installCommand = source["installCommand"] ?? DEFAULT_INSTALL_COMMAND;
907
1008
  if (typeof installCommand !== "string" || installCommand.trim() === "") {
908
1009
  errors.push("installCommand must be a non-empty string.");
@@ -919,7 +1020,8 @@ function normalizeConfig(raw, options = {}) {
919
1020
  previews,
920
1021
  scanPreviews,
921
1022
  devCommand: typeof devCommand === "string" ? devCommand : void 0,
922
- installCommand
1023
+ installCommand,
1024
+ logDir
923
1025
  },
924
1026
  errors: []
925
1027
  };
@@ -1697,6 +1799,7 @@ import net from "net";
1697
1799
  function createProxyHandler(options) {
1698
1800
  const target = new URL(options.target);
1699
1801
  const host = target.hostname;
1802
+ const dialHost = host.replace(/^\[|\]$/g, "");
1700
1803
  const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
1701
1804
  const authority = target.port ? `${host}:${target.port}` : host;
1702
1805
  function upstreamHeaders(req) {
@@ -1713,7 +1816,19 @@ function createProxyHandler(options) {
1713
1816
  }
1714
1817
  return {
1715
1818
  request(req, res, publicOrigin) {
1716
- const upstream = http.request({ host, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
1819
+ options.onActivity?.();
1820
+ options.onOpen?.();
1821
+ let open = true;
1822
+ const close = () => {
1823
+ if (!open)
1824
+ return;
1825
+ open = false;
1826
+ options.onActivity?.();
1827
+ options.onClose?.();
1828
+ };
1829
+ res.once("finish", close);
1830
+ res.once("close", close);
1831
+ const upstream = http.request({ host: dialHost, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
1717
1832
  const headers = { ...upstreamRes.headers };
1718
1833
  const location = rewriteLocation(typeof headers.location === "string" ? headers.location : void 0, publicOrigin);
1719
1834
  if (location !== void 0)
@@ -1735,7 +1850,17 @@ Start it, or point Leglas somewhere else with --user-port.`);
1735
1850
  req.pipe(upstream);
1736
1851
  },
1737
1852
  upgrade(req, socket, head) {
1738
- const upstream = net.connect(port, host, () => {
1853
+ options.onActivity?.();
1854
+ options.onOpen?.();
1855
+ let open = true;
1856
+ const closeActivity = () => {
1857
+ if (!open)
1858
+ return;
1859
+ open = false;
1860
+ options.onActivity?.();
1861
+ options.onClose?.();
1862
+ };
1863
+ const upstream = net.connect(port, dialHost, () => {
1739
1864
  const headers = Object.entries(upstreamHeaders(req)).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r
1740
1865
  `).join("");
1741
1866
  upstream.write(`${req.method} ${req.url} HTTP/1.1\r
@@ -1747,6 +1872,7 @@ ${headers}\r
1747
1872
  socket.pipe(upstream);
1748
1873
  });
1749
1874
  const shutdown = () => {
1875
+ closeActivity();
1750
1876
  upstream.destroy();
1751
1877
  socket.destroy();
1752
1878
  };
@@ -1757,6 +1883,62 @@ ${headers}\r
1757
1883
  }
1758
1884
  };
1759
1885
  }
1886
+ function startProxyServer(options) {
1887
+ return new Promise((resolve5, reject) => {
1888
+ let open = 0;
1889
+ const handler = createProxyHandler({
1890
+ ...options,
1891
+ onOpen: () => {
1892
+ open += 1;
1893
+ options.onOpen?.();
1894
+ },
1895
+ onClose: () => {
1896
+ open = Math.max(0, open - 1);
1897
+ options.onClose?.();
1898
+ }
1899
+ });
1900
+ const server = http.createServer((req, res) => {
1901
+ const address = server.address();
1902
+ const port = typeof address === "object" && address !== null ? address.port : 0;
1903
+ handler.request(req, res, `http://127.0.0.1:${port}`);
1904
+ });
1905
+ const sockets = /* @__PURE__ */ new Set();
1906
+ server.on("connection", (socket) => {
1907
+ sockets.add(socket);
1908
+ socket.once("close", () => sockets.delete(socket));
1909
+ });
1910
+ server.on("upgrade", (req, socket, head) => handler.upgrade(req, socket, head));
1911
+ const onError = (error) => {
1912
+ server.removeListener("listening", onListening);
1913
+ reject(error);
1914
+ };
1915
+ const onListening = () => {
1916
+ server.removeListener("error", onError);
1917
+ const address = server.address();
1918
+ const port = typeof address === "object" && address !== null ? address.port : 0;
1919
+ let closed = null;
1920
+ resolve5({
1921
+ active: () => open > 0,
1922
+ close: () => {
1923
+ if (closed !== null)
1924
+ return closed;
1925
+ closed = new Promise((done) => {
1926
+ for (const socket of sockets)
1927
+ socket.destroy();
1928
+ sockets.clear();
1929
+ server.closeAllConnections();
1930
+ server.close(() => done());
1931
+ });
1932
+ return closed;
1933
+ },
1934
+ url: `http://127.0.0.1:${port}`
1935
+ });
1936
+ };
1937
+ server.once("error", onError);
1938
+ server.once("listening", onListening);
1939
+ server.listen(0, "127.0.0.1");
1940
+ });
1941
+ }
1760
1942
 
1761
1943
  // ../server/dist/browser.js
1762
1944
  import { randomBytes } from "crypto";
@@ -3122,6 +3304,8 @@ async function startAppProcess(options) {
3122
3304
  }
3123
3305
 
3124
3306
  // ../server/dist/branches.js
3307
+ var BRANCH_IDLE_MS = 10 * 60 * 1e3;
3308
+ var BRANCH_SWEEP_MS = 3e4;
3125
3309
  function publicBranchState(state) {
3126
3310
  if (state.status === "ready")
3127
3311
  return { status: "ready" };
@@ -3131,7 +3315,11 @@ function createBranchRegistry(options) {
3131
3315
  const states = new Map(options.previews.map((preview) => [preview.title, { status: "idle" }]));
3132
3316
  const previews = new Map(options.previews.map((preview) => [preview.title, preview]));
3133
3317
  const inflight = /* @__PURE__ */ new Map();
3318
+ const stopping = /* @__PURE__ */ new Map();
3319
+ const lastActivity = /* @__PURE__ */ new Map();
3320
+ const proxies = /* @__PURE__ */ new Map();
3134
3321
  const boot = options.startWorktree ?? startWorktree;
3322
+ const proxy = options.startProxy ?? startProxyServer;
3135
3323
  let closed = false;
3136
3324
  let stopPromise = null;
3137
3325
  const transition = (title, state) => {
@@ -3143,6 +3331,43 @@ function createBranchRegistry(options) {
3143
3331
  options.onChange?.(title, state);
3144
3332
  return state;
3145
3333
  };
3334
+ const stopReady = (title, state, toIdle) => {
3335
+ const current = stopping.get(title);
3336
+ if (current !== void 0)
3337
+ return current;
3338
+ const pending = (async () => {
3339
+ await proxies.get(title)?.close().catch(() => {
3340
+ });
3341
+ await state.worktree.stop().catch(() => {
3342
+ });
3343
+ proxies.delete(title);
3344
+ lastActivity.delete(title);
3345
+ if (toIdle && !closed && states.get(title) === state) {
3346
+ transition(title, { status: "idle" });
3347
+ }
3348
+ })().finally(() => {
3349
+ stopping.delete(title);
3350
+ });
3351
+ stopping.set(title, pending);
3352
+ return pending;
3353
+ };
3354
+ const sweep = () => {
3355
+ const now = Date.now();
3356
+ for (const [title, state] of states) {
3357
+ const branchProxy = proxies.get(title);
3358
+ if (state.status !== "ready" || branchProxy === void 0 || stopping.has(title))
3359
+ continue;
3360
+ if (branchProxy.active()) {
3361
+ lastActivity.set(title, now);
3362
+ continue;
3363
+ }
3364
+ const seen = lastActivity.get(title) ?? now;
3365
+ if (now - seen >= BRANCH_IDLE_MS)
3366
+ void stopReady(title, state, true);
3367
+ }
3368
+ };
3369
+ const sweepTimer = setInterval(sweep, BRANCH_SWEEP_MS);
3370
+ sweepTimer.unref();
3146
3371
  const begin = (title) => {
3147
3372
  const preview = previews.get(title);
3148
3373
  const current = states.get(title);
@@ -3170,9 +3395,21 @@ function createBranchRegistry(options) {
3170
3395
  } catch (error) {
3171
3396
  checkout = Promise.reject(error);
3172
3397
  }
3173
- const starting = checkout.then((worktree) => {
3398
+ const starting = checkout.then(async (worktree) => {
3174
3399
  transition(title, { status: "starting", phase: "starting" });
3175
- return transition(title, { status: "ready", worktree });
3400
+ try {
3401
+ const branchProxy = await proxy({
3402
+ target: worktree.url,
3403
+ onActivity: () => lastActivity.set(title, Date.now())
3404
+ });
3405
+ proxies.set(title, branchProxy);
3406
+ lastActivity.set(title, Date.now());
3407
+ return transition(title, { status: "ready", worktree });
3408
+ } catch (error) {
3409
+ await worktree.stop().catch(() => {
3410
+ });
3411
+ throw error;
3412
+ }
3176
3413
  }).catch((error) => transition(title, {
3177
3414
  status: "failed",
3178
3415
  reason: error instanceof Error ? error.message : String(error)
@@ -3184,15 +3421,17 @@ function createBranchRegistry(options) {
3184
3421
  };
3185
3422
  return {
3186
3423
  state: (title) => states.get(title),
3424
+ url: (title) => proxies.get(title)?.url,
3187
3425
  start: begin,
3188
3426
  stop: () => {
3189
3427
  if (stopPromise !== null)
3190
3428
  return stopPromise;
3191
3429
  closed = true;
3430
+ clearInterval(sweepTimer);
3192
3431
  stopPromise = Promise.allSettled([...inflight.values()]).then(async () => {
3193
- const worktrees = [...states.values()].filter((state) => state.status === "ready").map((state) => state.worktree);
3194
- await Promise.all(worktrees.map((worktree) => worktree.stop().catch(() => {
3195
- })));
3432
+ await Promise.allSettled([...stopping.values()]);
3433
+ const ready = [...states.entries()].filter((entry) => entry[1].status === "ready" && proxies.has(entry[0]));
3434
+ await Promise.all(ready.map(([title, state]) => stopReady(title, state, false)));
3196
3435
  });
3197
3436
  return stopPromise;
3198
3437
  }
@@ -5289,6 +5528,7 @@ function resolveTitle(input, titles, renames) {
5289
5528
 
5290
5529
  // ../server/dist/server.js
5291
5530
  import { createReadStream, existsSync as existsSync3, statSync, unwatchFile, watch as watchFs, watchFile } from "fs";
5531
+ import { createHash as createHash2 } from "crypto";
5292
5532
  import { mkdir as mkdir8, readdir as readdir3, writeFile as writeFile8 } from "fs/promises";
5293
5533
  import http2 from "http";
5294
5534
  import net3 from "net";
@@ -5367,6 +5607,30 @@ function sendJson(res, status, body) {
5367
5607
  });
5368
5608
  res.end(payload);
5369
5609
  }
5610
+ function etagMatches(value, etag) {
5611
+ if (value === void 0)
5612
+ return false;
5613
+ const values = Array.isArray(value) ? value : [value];
5614
+ return values.some((header) => header.split(",").some((candidate) => {
5615
+ const tag = candidate.trim();
5616
+ return tag === "*" || tag === etag || tag === `W/${etag}`;
5617
+ }));
5618
+ }
5619
+ function sendConditionalJson(req, res, body) {
5620
+ const payload = JSON.stringify(body);
5621
+ const etag = `"${createHash2("sha256").update(payload).digest("base64url")}"`;
5622
+ if (etagMatches(req.headers["if-none-match"], etag)) {
5623
+ res.writeHead(304, { etag, "cache-control": "private, no-cache" });
5624
+ res.end();
5625
+ return;
5626
+ }
5627
+ res.writeHead(200, {
5628
+ "content-type": "application/json; charset=utf-8",
5629
+ "cache-control": "private, no-cache",
5630
+ etag
5631
+ });
5632
+ res.end(payload);
5633
+ }
5370
5634
  var CAPTURE_DEADLINE_MS = 15e3;
5371
5635
  var CAPTURE_LOAD_MS = Math.floor(CAPTURE_DEADLINE_MS * LOAD_SHARE);
5372
5636
  function captureSlug(title) {
@@ -5759,9 +6023,10 @@ async function startServer(options) {
5759
6023
  return preview;
5760
6024
  const state = branches.state(preview.title) ?? { status: "idle" };
5761
6025
  const { url: route, ...withoutUrl } = preview;
6026
+ const branchUrl = branches.url(preview.title);
5762
6027
  return state.status === "ready" ? {
5763
6028
  ...withoutUrl,
5764
- url: `${state.worktree.url}${route}`,
6029
+ url: `${branchUrl ?? state.worktree.url}${route}`,
5765
6030
  state: publicBranchState(state)
5766
6031
  } : { ...withoutUrl, state: publicBranchState(state) };
5767
6032
  };
@@ -5770,7 +6035,7 @@ async function startServer(options) {
5770
6035
  if (preview.branch === void 0)
5771
6036
  return preview;
5772
6037
  const state = branches.state(preview.title);
5773
- return state?.status === "ready" ? { ...preview, url: `${state.worktree.url}${preview.url}` } : null;
6038
+ return state?.status === "ready" ? { ...preview, url: `${branches.url(preview.title) ?? state.worktree.url}${preview.url}` } : null;
5774
6039
  };
5775
6040
  if (options.pool === void 0) {
5776
6041
  void reapOrphanedBrowsers().catch(() => {
@@ -5833,7 +6098,7 @@ async function startServer(options) {
5833
6098
  errors.push(notice);
5834
6099
  return void readLocalPreviews(cwd).then(({ previews: local, errors: localErrors }) => {
5835
6100
  if (localErrors.length > 0) {
5836
- return sendJson(res, 200, {
6101
+ return sendConditionalJson(req, res, {
5837
6102
  project,
5838
6103
  devServer: target,
5839
6104
  scanPreviews: config?.scanPreviews ?? true,
@@ -5846,7 +6111,7 @@ async function startServer(options) {
5846
6111
  const currentBoot = boot.filter((preview) => preview.local !== true || localTitles.has(preview.title));
5847
6112
  const known = new Set(currentBoot.map((preview) => preview.title));
5848
6113
  const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
5849
- sendJson(res, 200, {
6114
+ sendConditionalJson(req, res, {
5850
6115
  project,
5851
6116
  devServer: target,
5852
6117
  scanPreviews: config?.scanPreviews ?? true,
@@ -5854,7 +6119,7 @@ async function startServer(options) {
5854
6119
  errors,
5855
6120
  warnings: configWarnings
5856
6121
  });
5857
- }).catch(() => sendJson(res, 200, {
6122
+ }).catch(() => sendConditionalJson(req, res, {
5858
6123
  project,
5859
6124
  devServer: target,
5860
6125
  scanPreviews: config?.scanPreviews ?? true,
@@ -6289,7 +6554,7 @@ async function startServer(options) {
6289
6554
  waiting: null,
6290
6555
  failedIds: []
6291
6556
  };
6292
- return void readRequests(cwd).then((requests) => sendJson(res, 200, {
6557
+ return void readRequests(cwd).then((requests) => sendConditionalJson(req, res, {
6293
6558
  requests: requests.map(({ id, title, intent, mode, status, failure, notes }) => ({
6294
6559
  id,
6295
6560
  title,
@@ -6392,7 +6657,7 @@ async function startServer(options) {
6392
6657
  });
6393
6658
  }
6394
6659
  if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
6395
- return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
6660
+ return void readAnnotations(cwd).then((annotations) => sendConditionalJson(req, res, { annotations }));
6396
6661
  }
6397
6662
  if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
6398
6663
  if (!hasJsonBody(req)) {
@@ -6518,7 +6783,7 @@ async function startServer(options) {
6518
6783
  });
6519
6784
  }
6520
6785
  if (path === `${LEGLAS_PREFIX}/api/health`) {
6521
- return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable, cwd }));
6786
+ return void probe(target).then((reachable) => sendConditionalJson(req, res, { devServer: target, reachable, cwd }));
6522
6787
  }
6523
6788
  if (path.startsWith(`${FILES_PREFIX}/`)) {
6524
6789
  const rest = path.slice(FILES_PREFIX.length + 1);
@@ -6708,7 +6973,7 @@ async function runInit(options, deps) {
6708
6973
 
6709
6974
  // src/run-keep.ts
6710
6975
  import { existsSync as existsSync4 } from "fs";
6711
- import { mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
6976
+ import { copyFile as copyFile2, mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
6712
6977
  import { dirname as dirname9, join as join14 } from "path";
6713
6978
 
6714
6979
  // src/resolve-title.ts
@@ -6736,6 +7001,29 @@ function renameExport(source, to) {
6736
7001
  to
6737
7002
  );
6738
7003
  }
7004
+ async function writeLogEntry(options) {
7005
+ const entry = composeEntry({
7006
+ surface: options.surface,
7007
+ won: options.won,
7008
+ previews: options.previews,
7009
+ requests: await readRequests(options.cwd),
7010
+ annotations: await readAnnotations(options.cwd),
7011
+ date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
7012
+ });
7013
+ const dir = join14(options.cwd, options.logDir);
7014
+ await mkdir9(dir, { recursive: true });
7015
+ const file = join14(dir, `${entry.slug}.md`);
7016
+ await writeFile10(file, entry.markdown, "utf8");
7017
+ if (entry.pictures.length > 0) {
7018
+ const pictureDir = join14(dir, entry.slug);
7019
+ await mkdir9(pictureDir, { recursive: true });
7020
+ for (const picture of entry.pictures) {
7021
+ await copyFile2(join14(options.cwd, picture.from), join14(pictureDir, picture.to)).catch(() => {
7022
+ });
7023
+ }
7024
+ }
7025
+ return `${options.logDir}/${entry.slug}.md`;
7026
+ }
6739
7027
  async function runKeep(options, deps) {
6740
7028
  const loaded = await loadConfig(options.cwd);
6741
7029
  const local = await readLocalPreviews(options.cwd);
@@ -6764,6 +7052,20 @@ async function runKeep(options, deps) {
6764
7052
  const source = await readFile13(from, "utf8");
6765
7053
  await mkdir9(dirname9(to), { recursive: true });
6766
7054
  await writeFile10(to, renameExport(source, plan.exportName), "utf8");
7055
+ const surface = plan.removeDir.slice(plan.removeDir.lastIndexOf("/") + 1);
7056
+ let logged = null;
7057
+ let logError = null;
7058
+ try {
7059
+ logged = await writeLogEntry({
7060
+ cwd: options.cwd,
7061
+ logDir: loaded.config?.logDir ?? DEFAULT_LOG_DIR,
7062
+ surface,
7063
+ won: { title: resolved.title, to: plan.move.to },
7064
+ previews: previews.filter((preview) => plan.dropTitles.includes(preview.title))
7065
+ });
7066
+ } catch (error) {
7067
+ logError = error instanceof Error ? error.message : String(error);
7068
+ }
6767
7069
  await rm5(join14(options.cwd, plan.removeDir), { recursive: true, force: true });
6768
7070
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
6769
7071
  if (options.json) {
@@ -6775,6 +7077,8 @@ async function runKeep(options, deps) {
6775
7077
  exportName: plan.exportName,
6776
7078
  removed: plan.removeDir,
6777
7079
  droppedPreviews: dropped,
7080
+ logged,
7081
+ logError,
6778
7082
  instructions: plan.instructions
6779
7083
  })
6780
7084
  );
@@ -6782,6 +7086,8 @@ async function runKeep(options, deps) {
6782
7086
  }
6783
7087
  deps.log(` kept ${plan.move.to}`);
6784
7088
  deps.log(` removed ${plan.removeDir}`);
7089
+ if (logged !== null) deps.log(` logged ${logged}`);
7090
+ if (logError !== null) deps.error(` The decision log could not be written: ${logError}`);
6785
7091
  if (dropped > 0) {
6786
7092
  deps.log(` dropped ${dropped} direction${dropped === 1 ? "" : "s"} from the rail`);
6787
7093
  }
@@ -0,0 +1,20 @@
1
+ export type LogDeps = {
2
+ log(line: string): void;
3
+ error(line: string): void;
4
+ };
5
+ /**
6
+ * Read what past explorations decided.
7
+ *
8
+ * The entries are plain markdown in a committed directory and are meant to be
9
+ * read that way, in a pull request or on GitHub. This exists because an agent
10
+ * asked to work on a surface should be able to find what was already tried
11
+ * there without being told where to look, and because a person coming back to
12
+ * a project should not have to know the directory's name.
13
+ */
14
+ export declare function runLog(options: {
15
+ entry: string | null;
16
+ json: boolean;
17
+ cwd: string;
18
+ }, deps: LogDeps): Promise<{
19
+ exitCode: number;
20
+ }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leglas",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Compare design directions inside your own running app.",
5
5
  "license": "MIT",
6
6
  "repository": {