Merge origin/main: rebump v1.45→v1.46 (queue collision with design-daemon)

Main shipped v1.45.0.0 (persistent design daemon) at cf50443b while this
branch was at v1.45.0.0 (gstack v2 foundation). Same-MINOR-bump-level
queue collision per CLAUDE.md versioning invariant — advance this branch
to v1.46.0.0 to claim the next slot.

Resolved CHANGELOG.md conflict: kept both v1.45.0.0 (design daemon, main)
and v1.46.0.0 (gstack v2 foundation, this branch) entries in
reverse-chronological order. Updated VERSION, package.json, and renamed
test/fixtures/parity-baseline-v1.45.0.0.json → -v1.46.0.0.json with the
internal tag field synced.

Updated CHANGELOG entry numbers-table column header from v1.45.0.0 to
v1.46.0.0 + the source-reproduction line + the "v1.45 absorbs..." prose
and the eval target reference in skill-size-budget.test.ts comment.

Auto-merged main's design-daemon SKILL.md changes for design-consultation,
design-shotgun, office-hours, plan-design-review. Regenerated all SKILL.md
files via gen-skill-docs --host all to ensure clean state. Refreshed golden
ship snapshots (claude/codex/factory).

Test plan:
- bun test test/skill-validation.test.ts test/writing-style-resolver.test.ts
  test/host-config.test.ts test/skill-size-budget.test.ts
  test/parity-suite.test.ts test/skill-coverage-matrix.test.ts
  test/skill-coverage-floor.test.ts test/cso-preserved.test.ts
  test/resolver-entry.test.ts test/helpers/capture-parity-baseline.test.ts
  test/gen-skill-docs.test.ts: 1134 pass, 0 fail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-25 21:18:15 -07:00
commit 24742f9dac
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
24 changed files with 3204 additions and 120 deletions

View File

@ -1,18 +1,18 @@
# Changelog
## [1.45.0.0] - 2026-05-26
## [1.46.0.0] - 2026-05-26
## **gstack v2 foundation lands. Catalog tokens drop 56%, eval-first floor covers all 51 skills, hard token + dollar caps gate every PR.**
The always-loaded skill catalog — what every Claude Code session pays for at startup before any real work begins — went from ~9,319 tokens to ~4,045 tokens. That's a 56.6% cut to the surface gstack has been criticized for (third-party review, May 2026: "10K+ tokens before any real code is written"). Heavyweight skills like `/ship`, `/plan-ceo-review`, `/office-hours` still ship their full content, but their frontmatter descriptions trim to one sentence each; the routing prose lives in a new "## When to invoke" body section, and a per-run `scripts/proactive-suggestions.json` registry holds the voice-trigger + proactive-suggest text so agents can pull guidance on demand instead of always-loaded.
This is the v2 foundation release. The architectural break — `sections/*.md.tmpl` pattern, mechanical Read enforcement, eval-coverage annotations — lands in v2.0.0.0 as a coordinated launch. v1.45 absorbs every low-risk win, ships the eval-first floor every future skill must pass, and locks in the v1.44.1 reference baseline so reviewers can audit v1→v2 numbers against a real file (`test/fixtures/parity-baseline-v1.44.1.json`).
This is the v2 foundation release. The architectural break — `sections/*.md.tmpl` pattern, mechanical Read enforcement, eval-coverage annotations — lands in v2.0.0.0 as a coordinated launch. v1.46 absorbs every low-risk win, ships the eval-first floor every future skill must pass, and locks in the v1.44.1 reference baseline so reviewers can audit v1→v2 numbers against a real file (`test/fixtures/parity-baseline-v1.44.1.json`).
### The numbers that matter
Source: `bun run scripts/capture-baseline.ts --tag v1.45.0.0` vs the locked v1.44.1 baseline at `test/fixtures/parity-baseline-v1.44.1.json`. Reproduce locally with `bun test test/skill-size-budget.test.ts`.
Source: `bun run scripts/capture-baseline.ts --tag v1.46.0.0` vs the locked v1.44.1 baseline at `test/fixtures/parity-baseline-v1.44.1.json`. Reproduce locally with `bun test test/skill-size-budget.test.ts`.
| Metric | v1.44.1 | v1.45.0.0 | Δ |
| Metric | v1.44.1 | v1.46.0.0 | Δ |
|---|---|---|---|
| Catalog tokens (always-loaded system prompt) | ~9,319 | ~4,045 | **56.6%** |
| Total SKILL.md corpus | 2,847 KB | 2,813 KB | 1.2% |
@ -40,7 +40,7 @@ If you run gstack in CI, the new `EVALS_BUDGET_HARD_CAP=$30` cap (per-suite: gat
- `test/helpers/parity-harness.ts` + `test/parity-suite.test.ts` — cathedral parity-eval suite floor. `PARITY_INVARIANTS` registry pins must-preserve phrases per skill family (cso: OWASP/STRIDE; plan-ceo: SCOPE EXPANSION / HOLD SCOPE; ship: VERSION/CHANGELOG/PR) so future compression can't silently strip load-bearing prose.
- `test/skill-coverage-matrix.ts` + `test/skill-coverage-matrix.test.ts` — single source of truth mapping each skill to gate + periodic tests; CI gate asserts every skill has at least one gate-tier entry. 51 skills, 51 entries.
- `test/skill-coverage-floor.test.ts` — per-skill structural-compliance smoke test (file-IO, free). Verifies frontmatter shape, generated header, body non-trivial, no leaked `{{TEMPLATE}}` placeholders, catalog-trim contract on description. 309 assertions across 51 skills.
- `test/skill-size-budget.test.ts` — per-skill SKILL.md byte budget (×1.05 default ratio), total corpus budget, catalog token budget (≤7000 for v1.45). Caught regressions get a per-skill breakdown + override path.
- `test/skill-size-budget.test.ts` — per-skill SKILL.md byte budget (×1.05 default ratio), total corpus budget, catalog token budget (≤7000 for v1.46). Caught regressions get a per-skill breakdown + override path.
- `test/cso-preserved.test.ts` — pins cso's must-not-strip security guidance phrases (OWASP, STRIDE, daily/comprehensive mode discipline, confidence scoring, active verification). Future compression that hits cso fails CI here.
- `test/helpers/budget-override.ts` — audit-trail logger for `GSTACK_SIZE_BUDGET_OVERRIDE_REASON` and `EVALS_BUDGET_OVERRIDE_REASON`. Append-only JSONL at `~/.gstack/analytics/spend-overrides.jsonl` with timestamp + scope + reason + CI provenance.
- `scripts/proactive-suggestions.json` — per-run registry of routing prose + voice triggers extracted from skill frontmatter during catalog trim. Agents pull on demand instead of paying for it always-loaded.
@ -63,6 +63,65 @@ If you run gstack in CI, the new `EVALS_BUDGET_HARD_CAP=$30` cap (per-suite: gat
- The `scripts/jargon-list.json` is the canonical glossary. Add terms there; gen-skill-docs picks them up automatically on next regen.
- `test/fixtures/parity-baseline-v1.44.1.json` is the locked v1→v2 reference. Do not modify; capture new snapshots at later tags via `bun run scripts/capture-baseline.ts --tag <name>`.
## [1.45.0.0] - 2026-05-25
## **Design boards now live 24 hours, not 10 minutes. One daemon hosts every board, one tab survives the whole day.**
Run `$D compare --serve` and you get a persistent design daemon at `.gstack/design.json` instead of a fresh process per call. Open three design sessions across an afternoon and they all land at `/boards/<id>/` on the same port. The browser tab you opened first still works for the board you published an hour later. The idle timeout went from 10 minutes (the old per-process server) to 24 hours of inactivity (the daemon's lifetime). Submit a board, the URL stays accessible until the daemon idles out, so you can scroll back through the day's design history at `http://127.0.0.1:N/`.
Skill invocations (`/design-shotgun`, `/design-consultation`, `/plan-design-review`, `/design-review`, `/office-hours`) keep calling `$D compare --serve` exactly the same way. The CLI shape is unchanged. What's different is the binary now self-execs into daemon mode under the hood, attaches to a running daemon if one is there, spawns a fresh one if not, and prints `BOARD_PUBLISHED: http://127.0.0.1:N/boards/<id>/` to stderr so the skill can echo the URL. The legacy `--no-daemon` flag preserves the old single-process behavior for tests and debugging.
### The numbers that matter
Source: `bun test design/test/` and `git diff origin/main...HEAD --stat`.
| Metric | Before | After | Δ |
|-----------------------------------------|---------------|---------------|----------------|
| Idle timeout per board | 10 minutes | 24 hours | 144× |
| Server processes for N boards | N | 1 | N× |
| Browser tabs to keep open | one per board | one total | N× |
| Design tests in repo | 16 | 77 | +61 |
| Test paths covered (failure modes) | not enumerated| 38 / 100% | full coverage |
| Plan-review findings absorbed pre-impl | 2 | 19 | 17× from Codex |
| Component | New lines | Test lines |
|----------------------------|-----------|------------|
| design/src/daemon.ts | ~580 | 34 tests |
| design/src/daemon-client.ts| ~340 | 23 tests |
| design/src/daemon-state.ts | ~180 | (via client + daemon tests; direct stale-lock reclaim coverage) |
| Browser round-trip via HTTP| (existed) | 4 tests |
The compression: 61 new tests cover every endpoint, lifecycle path, LRU eviction, real idle-shutdown behavior (spawn-based, daemon process observed exiting after `IDLE_MS`), the bare-GET-doesn't-reset-idle invariant (poll loop in background, daemon still idles out), the idle-with-active-boards extension path with `MAX_EXTENSIONS` hard ceiling, concurrent-CLIs lock race (two parallel `ensureDaemon` calls converge on one daemon), identity-verified spawn, version mismatch with and without active boards, PID-reuse safety, path traversal rejection, malformed-body negatives on every POST, and cross-board feedback isolation. The plan-review pass caught 2 architectural issues in-house; an outside Codex pass caught 17 more, all absorbed into the implementation before any code was written; the /ship review army caught 1 backwards-compat break in skill resolvers (fixed) + 5 deferred test gaps (filled). The version-mismatch path now refuses to silently kill a daemon with active boards (it prints a warning and exits 1), so upgrading gstack mid-design-session doesn't drop your in-memory board history.
### What this means for the builder
Open `/design-shotgun` Monday morning, work through three rounds of variants, walk away for lunch, come back, click Submit. The board is still there. Open a second `/design-shotgun` for a different feature in the afternoon, get a new URL at `/boards/<another-id>/`, no port churn, your morning board still works. The whole day's worth of design exploration accumulates as a browsable history at the daemon's root. Stop worrying about the 10-minute death clock.
### Itemized changes
#### Added
- **Persistent design daemon** (`design/src/daemon.ts`). Bun HTTP server on `127.0.0.1` hosting many boards under `/boards/<id>/`. Per-board state machine (`serving | regenerating | done`), LRU cap of 50 boards (evicts `done` first, returns 503 when 50 non-done coexist), 24h idle timeout with 1h extensions up to a 28h ceiling when boards are still active, per-board async mutex serializing feedback POST vs reload POST. Index page at `/` lists recent boards newest first.
- **`$D daemon status`** and **`$D daemon stop [--force]`**. The stop sub-command refuses without `--force` when active boards exist, so a casual stop doesn't drop in-flight history.
- **Daemon client** (`design/src/daemon-client.ts`). `ensureDaemon()` handles spawn-or-attach with file-lock-protected spawn (re-reads state inside the lock to close the two-CLIs-race window) and identity-verified SIGTERM (reads `/proc/PID/cmdline` on Linux, `ps -p PID -o command=` on macOS, only signals if `gstack-design-daemon` is in the cmdline). PID-reuse safety: if the state file points at a PID belonging to an unrelated process, no signal is sent and a fresh daemon spawns. Version-mismatch refusal: if a CLI from a newer gstack version arrives while boards are still open in an older daemon, the CLI prints a user-actionable warning and exits 1 instead of silently restarting and losing history.
- **Shared daemon state utilities** (`design/src/daemon-state.ts`). Atomic state-file write (`<tmp>` + `renameSync` at mode `0o600`), `fs.openSync('wx')` exclusive lock, cross-platform cmdline reader, version lookup that falls back through `DESIGN_DAEMON_VERSION` env → `design/dist/.version` baked at build time → source-tree `VERSION``"unknown"`.
- **End-to-end round-trip tests against a real spawned daemon** (`design/test/feedback-roundtrip-daemon.test.ts`). HTTP fetch drives publish → submit → regenerate → reload → round-2 submit, asserting `feedback.json` lands at the daemon-derived `sourceDir` with `boardId` and `publishedAt` augmented fields.
#### Changed
- **Board JS uses relative URLs** instead of an injected `__GSTACK_SERVER_URL` global. The same generated HTML works at `/` (legacy `--no-daemon`) and `/boards/<id>/` (daemon). `location.protocol` feature-detect keeps the `file://` DOM-only fallback path working.
- **Bare `GET /boards/<id>` returns 301** to `/boards/<id>/`. The trailing slash is load-bearing for relative-URL resolution in the board JS; without it, `fetch('./api/feedback')` would resolve to the wrong scope.
- **Reload guard rejects directory paths**. `design/src/serve.ts:200-212` previously let `resolvedReload === allowedDir` through, which then crashed `readFileSync` with `EISDIR`. Now requires `statSync(resolvedReload).isFile()` with a clear 400 instead.
- **Feedback files carry `boardId` and `publishedAt`** so agents polling `feedback.json` / `feedback-pending.json` in a multi-board world can verify which board produced what.
- **`sourceDir` is derived from `realpath(html)` server-side**, never trusted from the publish POST body.
- **Skill resolvers and templates** (`scripts/resolvers/design.ts`, `design-shotgun/SKILL.md`, `design-consultation/SKILL.md`, `plan-design-review/SKILL.md`, `office-hours/SKILL.md`) updated to parse `BOARD_URL:` from stderr and POST reloads to `${BOARD_URL}api/reload` instead of the legacy port-only `/api/reload`. Legacy `SERVE_STARTED: port=N html=...` line still emitted for back-compat.
#### Fixed
- **Compiled design binary self-execs as the daemon** via a `--daemon-mode` flag, so the daemon lifecycle works for users installing from `design/dist/design` (not just `bun run` against the source tree).
- **Version lookup** is consistent between client and daemon. Both go through `readVersionString()`, so the version-mismatch refusal path works on the compiled binary instead of always reading `"unknown"` and matching itself.
#### For contributors
- **Test infrastructure split**: `design/test/daemon.test.ts` (30 in-process tests against the exported `fetchHandler`, ~70ms) for fast iteration; `design/test/daemon-discovery.test.ts` (17 real-spawn tests, ~8s) for lifecycle + lock + identity guarantees. Shared helpers in `design/test/daemon-tests-fixtures.ts`.
- **Plan-review process**: this branch ran `/plan-eng-review` twice. Round 1 caught 2 architecture findings. An outside-voice Codex pass after round 1 found 17 more (URL contract self-contradiction, false test-green claim, lock semantics, identity verification, version-mismatch silent data loss, several others). Round 2 absorbed all 17 before implementation started. The full review trail is preserved in the plan file's `## GSTACK REVIEW REPORT` section.
## [1.44.1.0] - 2026-05-24
## **Nine community fixes ship in one bundle.** Office-hours session counter works again, iOS QA tunnels survive macOS 26.x, Windows brain-sync stops dropping artifacts, browse server tells you whether the bind failure was a port collision or a sandbox block.

View File

@ -1,5 +1,53 @@
# TODOS
## design daemon: follow-ups (filed v1.45.0.0 via /ship review army)
### ✅ DONE (v1.45.0.0): Tighten daemon test coverage
**Resolved in commit `6b037c55` (same PR):** All 5 test gaps filled before
landing. Per-file totals after: serve 16, daemon 34, daemon-discovery 23,
feedback-roundtrip-daemon 4 = 77 (+10 from initial ship). Specifically:
- Idle-shutdown actually fires (spawn-based, daemon process observed exiting,
state file removed).
- Bare GET polling doesn't reset idle (hammers `/api/progress` in background,
daemon still idles out).
- Idle-with-active-boards extends, then force-shuts after MAX_EXTENSIONS
(with `DESIGN_DAEMON_EXTENSION_MS=1500` + `MAX_EXTENSIONS=2`).
- Concurrent `ensureDaemon()` race converges on one daemon (lock wins).
- Stale-lock reclaim (dead PID succeeds, alive unrelated PID refuses).
- Malformed-JSON + non-object + array-body + missing-html negatives for
`POST /api/boards` and `POST /boards/<id>/api/reload`.
### P3: Minor maintainability nits from /ship review
- `design/src/cli.ts` and `design/src/serve.ts` both have a small `openBrowser`
helper with identical darwin/linux/else branches. Extract a shared
`design/src/open-browser.ts`.
- `design/src/daemon-client.ts:320` (`AbortSignal.timeout(2000)`) and `:357`
(`delay(50)`) use bare numeric literals while sibling timeouts are named
constants. Promote to `SHUTDOWN_POST_TIMEOUT_MS` and `ALIVE_POLL_INTERVAL_MS`.
- `design/src/daemon-state.ts:21` `serverPath` field is written
(`daemon.ts:541`) but never read by production code. Either remove or
document the forensic intent.
### P3: Daemon scope deferred from v1.45.0.0 plan
Originally listed in the plan's "TODOs surfaced for later" section:
- Per-daemon scoped auth tokens (only relevant once a tunnel/share use case appears).
- Optional persistent board history on disk in
`~/.gstack/projects/$SLUG/designs/history/` so submitted boards survive
daemon restarts.
- Windows spawn branch lifted from browse (V1 daemon is macOS + Linux;
Windows users fall back to legacy `--no-daemon` per-process server).
- `$D board list` / `$D board stop <id>` per-board ops CLI (V1 has only
`$D daemon status` / `stop`).
- Cross-worktree daemon attach (conductor sibling worktrees of the same
repo currently each spawn their own daemon — matches browse; revisit
if it causes friction).
---
## browse server: terminal-agent teardown follow-ups (filed v1.41 via /plan-eng-review)
### ✅ DONE (v1.44.0.0): Identity-based terminal-agent kill (replace pkill regex with PID)

View File

@ -1 +1 @@
1.45.0.0
1.46.0.0

View File

@ -1232,8 +1232,12 @@ This command generates the board HTML, starts an HTTP server on a random port,
and opens it in the user's default browser. **Run it in the background** with `&`
because the server needs to stay running while the user interacts with the board.
Parse the port from stderr output: `SERVE_STARTED: port=XXXXX`. You need this
for the board URL and for reloading during regeneration cycles.
Parse the board URL from stderr output. Default daemon path:
`BOARD_URL: http://127.0.0.1:N/boards/<id>/` (already includes the per-board
path; use this for the AskUserQuestion URL AND as the base for the reload
endpoint). Legacy `--no-daemon` path emits `SERVE_STARTED: port=XXXXX` and
serves a single board at `/`, with reload at `/api/reload` — only relevant
when an external caller explicitly passes `--no-daemon`.
**PRIMARY WAIT: AskUserQuestion with board URL**
@ -1241,11 +1245,14 @@ After the board is serving, use AskUserQuestion to wait for the user. Include th
board URL so they can click it if they lost the browser tab:
"I've opened a comparison board with the design variants:
http://127.0.0.1:<PORT>/ — Rate them, leave comments, remix
<BOARD_URL> — Rate them, leave comments, remix
elements you like, and click Submit when you're done. Let me know when you've
submitted your feedback (or paste your preferences here). If you clicked
Regenerate or Remix on the board, tell me and I'll generate new variants."
Substitute `<BOARD_URL>` with the URL parsed from stderr (the daemon path
emits `BOARD_URL: http://127.0.0.1:N/boards/<id>/`).
**Do NOT use AskUserQuestion to ask which variant the user prefers.** The comparison
board IS the chooser. AskUserQuestion is just the blocking wait mechanism.
@ -1289,8 +1296,13 @@ the approved variant.
2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`)
3. Generate new variants with `$D iterate` or `$D variants` using updated brief
4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"`
5. Reload the board in the user's browser (same tab):
`curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
5. Reload the board in the user's browser (same tab) — the URL is per-board
under daemon mode, so use `<BOARD_URL>` (from the `BOARD_URL:` stderr
line) as the base:
`curl -s -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
Under `--no-daemon` the reload endpoint is `/api/reload` at the legacy
port; this path only matters if the caller explicitly opted out of the
daemon.
6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to
wait for the next round of feedback. Repeat until `feedback.json` appears.

View File

@ -1119,8 +1119,12 @@ This command generates the board HTML, starts an HTTP server on a random port,
and opens it in the user's default browser. **Run it in the background** with `&`
because the server needs to stay running while the user interacts with the board.
Parse the port from stderr output: `SERVE_STARTED: port=XXXXX`. You need this
for the board URL and for reloading during regeneration cycles.
Parse the board URL from stderr output. Default daemon path:
`BOARD_URL: http://127.0.0.1:N/boards/<id>/` (already includes the per-board
path; use this for the AskUserQuestion URL AND as the base for the reload
endpoint). Legacy `--no-daemon` path emits `SERVE_STARTED: port=XXXXX` and
serves a single board at `/`, with reload at `/api/reload` — only relevant
when an external caller explicitly passes `--no-daemon`.
**PRIMARY WAIT: AskUserQuestion with board URL**
@ -1128,11 +1132,14 @@ After the board is serving, use AskUserQuestion to wait for the user. Include th
board URL so they can click it if they lost the browser tab:
"I've opened a comparison board with the design variants:
http://127.0.0.1:<PORT>/ — Rate them, leave comments, remix
<BOARD_URL> — Rate them, leave comments, remix
elements you like, and click Submit when you're done. Let me know when you've
submitted your feedback (or paste your preferences here). If you clicked
Regenerate or Remix on the board, tell me and I'll generate new variants."
Substitute `<BOARD_URL>` with the URL parsed from stderr (the daemon path
emits `BOARD_URL: http://127.0.0.1:N/boards/<id>/`).
**Do NOT use AskUserQuestion to ask which variant the user prefers.** The comparison
board IS the chooser. AskUserQuestion is just the blocking wait mechanism.
@ -1176,8 +1183,13 @@ the approved variant.
2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`)
3. Generate new variants with `$D iterate` or `$D variants` using updated brief
4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"`
5. Reload the board in the user's browser (same tab):
`curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
5. Reload the board in the user's browser (same tab) — the URL is per-board
under daemon mode, so use `<BOARD_URL>` (from the `BOARD_URL:` stderr
line) as the base:
`curl -s -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
Under `--no-daemon` the reload endpoint is `/api/reload` at the legacy
port; this path only matters if the caller explicitly opted out of the
daemon.
6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to
wait for the next round of feedback. Repeat until `feedback.json` appears.

View File

@ -25,8 +25,19 @@ import { evolve } from "./evolve";
import { generateDesignToCodePrompt } from "./design-to-code";
import { serve } from "./serve";
import { gallery } from "./gallery";
import {
daemonStatus as daemonStatusClient,
ensureDaemon,
publishBoard,
shutdownDaemon,
} from "./daemon-client";
import { spawn as nodeSpawn } from "child_process";
function parseArgs(argv: string[]): { command: string; flags: Record<string, string | boolean> } {
function parseArgs(argv: string[]): {
command: string;
flags: Record<string, string | boolean>;
positionals: string[];
} {
const args = argv.slice(2); // skip bun/node and script path
if (args.length === 0) {
printUsage();
@ -35,6 +46,7 @@ function parseArgs(argv: string[]): { command: string; flags: Record<string, str
const command = args[0];
const flags: Record<string, string | boolean> = {};
const positionals: string[] = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
@ -47,10 +59,12 @@ function parseArgs(argv: string[]): { command: string; flags: Record<string, str
} else {
flags[key] = true;
}
} else {
positionals.push(arg);
}
}
return { command, flags };
return { command, flags, positionals };
}
function printUsage(): void {
@ -108,7 +122,7 @@ async function runSetup(): Promise<void> {
}
async function main(): Promise<void> {
const { command, flags } = parseArgs(process.argv);
const { command, flags, positionals } = parseArgs(process.argv);
if (!COMMANDS.has(command)) {
console.error(`Unknown command: ${command}`);
@ -139,12 +153,24 @@ async function main(): Promise<void> {
const images = await resolveImagePaths(imagesArg);
const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html";
compare({ images, output: outputPath });
// If --serve flag is set, start HTTP server for the board
// If --serve flag is set, publish the board.
// Default: ensure the persistent daemon is up, POST the board, open
// the browser, exit. The daemon survives the CLI and hosts every
// board the user has published this day at stable URLs.
// --no-daemon: legacy single-process server in serve.ts (kept for
// tests / Windows / explicit debugging).
if (flags.serve) {
await serve({
html: outputPath,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
});
if (flags["no-daemon"]) {
await serve({
html: outputPath,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
});
} else {
await publishToDaemon({
html: outputPath,
title: flags.title as string | undefined,
});
}
}
break;
}
@ -247,11 +273,108 @@ async function main(): Promise<void> {
break;
case "serve":
await serve({
html: flags.html as string,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
});
if (flags["no-daemon"]) {
await serve({
html: flags.html as string,
timeout: flags.timeout ? parseInt(flags.timeout as string) : 600,
});
} else {
await publishToDaemon({
html: flags.html as string,
title: flags.title as string | undefined,
});
}
break;
case "daemon": {
// Sub-commands: `$D daemon status` and `$D daemon stop [--force]`.
const sub = positionals[0] || "status";
if (sub === "status") {
const s = await daemonStatusClient();
if (!s.running) {
console.log(JSON.stringify({ running: false }, null, 2));
process.exit(0);
}
console.log(JSON.stringify(s, null, 2));
break;
}
if (sub === "stop") {
const r = await shutdownDaemon({ force: !!flags.force });
if (r.stopped) {
console.log(JSON.stringify({ stopped: true, reason: r.reason }, null, 2));
process.exit(0);
}
console.error(
`Refused to stop daemon: ${r.reason} (activeBoards=${r.activeBoards ?? 0})`,
);
console.error(
`Submit/close active boards first, or pass --force to drop in-memory history.`,
);
process.exit(1);
}
console.error(`Unknown daemon sub-command: ${sub}. Use 'status' or 'stop'.`);
process.exit(2);
}
}
}
/**
* Default `$D compare --serve` path: ensure the persistent daemon is up,
* publish the board, open the browser to its URL, then exit. The daemon
* survives.
*
* Stderr lines (in order):
* - "DAEMON_STARTED port=N version=V" (or "DAEMON_ATTACHED port=N ..."
* if a daemon was already running)
* - "BOARD_PUBLISHED: http://127.0.0.1:N/boards/<id>/"
* - "BOARD_URL: <same url>" (alias for grep-friendliness)
* - "SERVE_STARTED: port=N html=<path>" (legacy back-compat alias for
* any external script that scraped the pre-daemon output note the
* daemon hosts boards under /boards/<id>/, not /, so scripts that
* ALSO POSTed /api/reload at the parsed port need to switch to
* BOARD_URL + ./api/reload to work end-to-end. Emitting the legacy
* line keeps port-only consumers from breaking outright.)
*/
async function publishToDaemon(opts: { html: string; title?: string }): Promise<void> {
if (!opts.html) {
console.error("--html is required (compare --serve provides --output as the html)");
process.exit(1);
}
const ensured = await ensureDaemon({});
console.error(
`${ensured.spawned ? "DAEMON_STARTED" : "DAEMON_ATTACHED"} port=${ensured.port} version=${ensured.version}`,
);
const result = await publishBoard({
port: ensured.port,
html: opts.html,
title: opts.title,
});
console.error(`BOARD_PUBLISHED: ${result.url}`);
console.error(`BOARD_URL: ${result.url}`);
// Legacy alias so anything still grepping `SERVE_STARTED: port=` gets the
// port. The full back-compat story requires the caller to ALSO learn the
// per-board path; see publishToDaemon docstring above.
console.error(`SERVE_STARTED: port=${ensured.port} html=${opts.html}`);
console.log(JSON.stringify({ id: result.id, url: result.url, sourceDir: result.sourceDir }, null, 2));
openBrowser(result.url);
// Short-lived publisher process exits; daemon keeps serving.
}
/** Open a URL in the default browser. Stays cross-platform with serve.ts. */
function openBrowser(url: string): void {
const platform = process.platform;
let cmd: string;
if (platform === "darwin") cmd = "open";
else if (platform === "linux") cmd = "xdg-open";
else {
console.error(`Open this URL in your browser: ${url}`);
return;
}
try {
const child = nodeSpawn(cmd, [url], { stdio: "ignore", detached: true });
child.unref();
} catch {
console.error(`Open this URL in your browser: ${url}`);
}
}
@ -280,7 +403,19 @@ async function resolveImagePaths(input: string): Promise<string[]> {
return input.split(",").map(p => p.trim());
}
main().catch(err => {
console.error(err.message || err);
process.exit(1);
});
// Self-execution shortcut: when invoked with --daemon-mode, this same
// binary runs as the persistent design daemon instead of the CLI. Keeps
// the production install to a single executable; daemon-client.ts spawns
// `<this binary> --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev)
// rather than relying on a separate daemon.ts file at a known path.
if (process.argv.includes("--daemon-mode")) {
const { start } = await import("./daemon");
start();
// start() binds Bun.serve and registers signal handlers; this branch
// never falls through to main(). Process stays alive on the bound port.
} else {
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});
}

View File

@ -36,8 +36,8 @@ export const COMMANDS = new Map<string, {
}],
["compare", {
description: "Generate HTML comparison board for user review",
usage: "compare --images /path/*.png --output /path/board.html [--serve]",
flags: ["--images", "--output", "--serve", "--timeout"],
usage: "compare --images /path/*.png --output /path/board.html [--serve [--no-daemon] [--title \"...\"]]",
flags: ["--images", "--output", "--serve", "--no-daemon", "--title", "--timeout"],
}],
["diff", {
description: "Visual diff between two mockups",
@ -71,8 +71,13 @@ export const COMMANDS = new Map<string, {
}],
["serve", {
description: "Serve comparison board over HTTP and collect user feedback",
usage: "serve --html /path/board.html [--timeout 600]",
flags: ["--html", "--timeout"],
usage: "serve --html /path/board.html [--no-daemon] [--title \"...\"] [--timeout 600]",
flags: ["--html", "--no-daemon", "--title", "--timeout"],
}],
["daemon", {
description: "Manage the persistent design board daemon (sub-commands: status, stop)",
usage: "daemon status | daemon stop [--force]",
flags: ["--force"],
}],
["setup", {
description: "Guided API key setup + smoke test",

View File

@ -391,6 +391,17 @@ export function generateCompareHtml(images: string[]): string {
<div id="feedback-result"></div>
<script>
// Feature-detect: are we being served over HTTP (by serve.ts or the
// daemon), or opened directly as a file:// URL? In file:// mode the
// board JS falls through to a DOM-only success path with no server
// round-trips. Using location.protocol instead of an injected global
// means the same generated HTML works at both / (legacy --no-daemon)
// and /boards/<id>/ (daemon) — relative URLs resolve against
// location.pathname in both cases.
function hasServer() {
return location.protocol === 'http:' || location.protocol === 'https:';
}
// View toggle
document.querySelectorAll('.view-toggle button').forEach(function(btn) {
btn.addEventListener('click', function() {
@ -465,8 +476,8 @@ export function generateCompareHtml(images: string[]): string {
});
function postFeedback(feedback) {
if (!window.__GSTACK_SERVER_URL) return Promise.resolve(null);
return fetch(window.__GSTACK_SERVER_URL + '/api/feedback', {
if (!hasServer()) return Promise.resolve(null);
return fetch('./api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feedback),
@ -509,7 +520,7 @@ export function generateCompareHtml(images: string[]): string {
}
function startProgressPolling() {
if (!window.__GSTACK_SERVER_URL) return;
if (!hasServer()) return;
var pollCount = 0;
var maxPolls = 150; // 5 min at 2s intervals
var pollInterval = setInterval(function() {
@ -523,7 +534,7 @@ export function generateCompareHtml(images: string[]): string {
'</div>';
return;
}
fetch(window.__GSTACK_SERVER_URL + '/api/progress')
fetch('./api/progress')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'serving') {
@ -563,7 +574,7 @@ export function generateCompareHtml(images: string[]): string {
postFeedback(feedback).then(function(result) {
if (result && result.received) {
showRegeneratingState();
} else if (window.__GSTACK_SERVER_URL) {
} else if (hasServer()) {
showPostFailure(feedback);
}
});
@ -578,7 +589,7 @@ export function generateCompareHtml(images: string[]): string {
postFeedback(feedback).then(function(result) {
if (result && result.received) {
showPostSubmitState();
} else if (window.__GSTACK_SERVER_URL) {
} else if (hasServer()) {
showPostFailure(feedback);
} else {
// DOM-only mode (legacy / test)

419
design/src/daemon-client.ts Normal file
View File

@ -0,0 +1,419 @@
/**
* CLI-side client for the design daemon.
*
* Responsible for the lifecycle dance that `$D compare --serve` (default
* path) goes through:
*
* ensureDaemon() publishBoard(html, opts) openBrowser(url) exit 0
*
* Mirrors browse/src/cli.ts:317-415 same health-check-first attach
* decision, same fs.openSync('wx') lock, same re-read-under-lock guard.
* Adds two design-specific safety properties Codex flagged on the daemon
* plan:
*
* 1. Identity verification before any SIGTERM. Browse signals on PID
* alone; here we require the cmdline to contain CMDLINE_MARKER so a
* stale state file pointing at a reused PID doesn't kill an
* unrelated process.
*
* 2. Refuse-to-kill on version mismatch with active boards. Browse will
* restart on version drift; here in-memory boards would be lost, so
* we exit 1 with a user-actionable message instead of silent loss.
*
* Spawn uses Node's child_process.spawn with detached: true + stdio
* pointed at a log file. Bun.spawn().unref() has macOS session-detach
* quirks browse already discovered (browse/src/cli.ts:225-275).
*/
import { spawn as nodeSpawn } from "child_process";
import fs from "fs";
import path from "path";
import { setTimeout as delay } from "timers/promises";
import {
acquireLock,
CMDLINE_MARKER,
healthCheck,
isProcessAlive,
readStateFile,
readVersionString,
resolveLockFilePath,
resolveStartupLogPath,
resolveStateFilePath,
verifyIdentity,
} from "./daemon-state";
const MAX_START_WAIT_MS = parseInt(
process.env.DESIGN_DAEMON_START_TIMEOUT_MS || "8000",
10,
);
const POLL_INTERVAL_MS = 100;
const SIGTERM_GRACE_MS = 2000;
export interface EnsureDaemonOptions {
/** Default: package version. Used for version-match check. */
version?: string;
/** Default: `<repo>/design/src/daemon.ts`. */
daemonScript?: string;
/** Extra env vars passed to the spawned daemon. */
daemonEnv?: Record<string, string>;
/** Print noisy progress to stderr. Default true. */
verbose?: boolean;
/**
* Override the state-file path. Default: resolveStateFilePath() (env
* DESIGN_DAEMON_STATE_FILE or .gstack/design.json under the git root /
* cwd). Tests inject a per-test path; the same path is forwarded to the
* spawned daemon via env so client + daemon agree.
*/
stateFile?: string;
}
export interface EnsureDaemonResult {
port: number;
version: string;
spawned: boolean;
}
function log(verbose: boolean, msg: string): void {
if (verbose) process.stderr.write(`[design-daemon] ${msg}\n`);
}
/**
* Ensure a design daemon is reachable on the project's state file. Returns
* the port to talk to. Spawns a new daemon under an exclusive lock when
* needed; attaches to an existing healthy daemon otherwise.
*
* Exits with code 1 (not throws) on the refuse-kill-with-active-boards
* branch that's a user-actionable situation, not a programming error.
*/
export async function ensureDaemon(
opts: EnsureDaemonOptions = {},
): Promise<EnsureDaemonResult> {
const verbose = opts.verbose !== false;
const expectedVersion = opts.version ?? readPackageVersion();
const stateFile = opts.stateFile ?? resolveStateFilePath();
const existing = readStateFile(stateFile);
if (existing) {
const health = await healthCheck(existing.port);
if (health) {
if (health.version === expectedVersion) {
log(verbose, `attached to existing daemon pid=${existing.pid} port=${existing.port}`);
return { port: existing.port, version: health.version, spawned: false };
}
// Version mismatch: refuse if active boards exist (Codex finding).
if (health.activeBoards > 0) {
process.stderr.write(
`[design-daemon] WARNING: existing daemon is gstack ${health.version}; this CLI is ${expectedVersion}.\n` +
`[design-daemon] ${health.activeBoards} active board(s) detected. Refusing to auto-kill.\n` +
`[design-daemon] Submit or close the open boards, then re-run.\n` +
`[design-daemon] Or force restart: $D daemon stop (will drop in-memory history).\n`,
);
process.exit(1);
}
// No active boards — safe to graceful-shutdown and respawn.
log(verbose, `daemon version mismatch (${health.version} vs ${expectedVersion}); shutting down`);
await gracefulShutdownExistingDaemon(existing.port);
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
} else {
// State file points at an unresponsive port. Either the daemon
// crashed or the PID got reused. Identity-verify before any SIGTERM
// so we don't kill an unrelated process (Codex finding).
log(verbose, `state file present (pid=${existing.pid}) but /health unresponsive`);
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
}
}
// Spawn under exclusive lock; re-read state INSIDE the lock so we don't
// race a concurrent CLI that won the lock first.
const lockPath = resolveLockFilePath(stateFile);
const release = acquireLock(lockPath);
if (!release) {
// Another process is starting the daemon. Wait for it.
log(verbose, "another CLI is spawning the daemon; waiting…");
const start = Date.now();
while (Date.now() - start < MAX_START_WAIT_MS) {
const fresh = readStateFile(stateFile);
if (fresh) {
const h = await healthCheck(fresh.port);
if (h) return { port: fresh.port, version: h.version, spawned: false };
}
await delay(POLL_INTERVAL_MS);
}
throw new Error("Timed out waiting for concurrent daemon spawn");
}
try {
// Re-read under lock. Another caller may have already finished spawning
// between our first check and our lock acquisition.
const fresh = readStateFile(stateFile);
if (fresh) {
const h = await healthCheck(fresh.port);
if (h && h.version === expectedVersion) {
log(verbose, `another CLI won the lock; attaching pid=${fresh.pid} port=${fresh.port}`);
return { port: fresh.port, version: h.version, spawned: false };
}
}
log(verbose, "spawning new daemon");
const port = await spawnDaemon({
script: opts.daemonScript,
env: { ...opts.daemonEnv, DESIGN_DAEMON_STATE_FILE: stateFile },
stateFile,
expectedVersion,
});
return { port, version: expectedVersion, spawned: true };
} finally {
release();
}
}
/**
* Publish a board to the daemon and return its URL. Wraps the HTTP POST
* with a friendlier error surface than raw fetch.
*/
export interface PublishBoardOptions {
port: number;
html: string;
title?: string;
publisherPid?: number;
}
export interface PublishBoardResult {
id: string;
url: string;
sourceDir: string;
}
export async function publishBoard(opts: PublishBoardOptions): Promise<PublishBoardResult> {
const body: Record<string, unknown> = {
html: opts.html,
publisherPid: opts.publisherPid ?? process.pid,
};
if (opts.title) body.title = opts.title;
const resp = await fetch(`http://127.0.0.1:${opts.port}/api/boards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
let errText: string;
try {
const j = (await resp.json()) as { error?: string; existing?: { id: string; url: string } };
if (j.existing) {
// 409: surface the existing-board URL so the caller can reuse it
return { id: j.existing.id, url: j.existing.url, sourceDir: "" };
}
errText = j.error || `HTTP ${resp.status}`;
} catch {
errText = `HTTP ${resp.status}`;
}
throw new Error(`Daemon refused publish: ${errText}`);
}
return (await resp.json()) as PublishBoardResult;
}
// ─── Internals ───────────────────────────────────────────────────
function readPackageVersion(): string {
return readVersionString();
}
function defaultDaemonScript(): string {
// design/src/daemon-client.ts → daemon.ts is a sibling. Only used in dev
// when this process is `bun run cli.ts`; the compiled-binary path
// self-execs instead (see resolveSpawnCommand).
return path.join(import.meta.dir, "daemon.ts");
}
/**
* Compute the argv to spawn the daemon. Two modes:
*
* Compiled binary (`design/dist/design`): re-exec ourselves with
* --daemon-mode. process.execPath IS the compiled design binary;
* spawning it again with the flag runs the daemon (see the
* --daemon-mode branch at the bottom of cli.ts).
*
* Dev (`bun run design/src/cli.ts`): process.execPath is bun, so we
* invoke `bun run <daemon.ts> --marker ...` directly.
*
* Tests can override the dev script via opts.script.
*/
function resolveSpawnCommand(scriptOverride: string | undefined): {
command: string;
args: string[];
} {
const execBase = path.basename(process.execPath).toLowerCase();
const isCompiledHost = execBase !== "bun" && execBase !== "bun.exe" && execBase !== "node";
if (isCompiledHost && !scriptOverride) {
return {
command: process.execPath,
args: ["--daemon-mode", "--marker", CMDLINE_MARKER],
};
}
const script = scriptOverride ?? defaultDaemonScript();
return {
command: "bun",
args: ["run", script, "--marker", CMDLINE_MARKER],
};
}
interface SpawnDaemonOpts {
script?: string;
env?: Record<string, string>;
stateFile: string;
expectedVersion: string;
}
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
const logPath = resolveStartupLogPath();
fs.mkdirSync(path.dirname(logPath), { recursive: true });
// Truncate the startup log on each spawn so a later read finds only THIS
// attempt's output (mirrors browse's per-spawn log truncation).
fs.writeFileSync(logPath, "");
const logFd = fs.openSync(logPath, "a");
const { command, args } = resolveSpawnCommand(opts.script);
const child = nodeSpawn(command, args, {
detached: true,
stdio: ["ignore", logFd, logFd],
env: {
...process.env,
DESIGN_DAEMON_VERSION: opts.expectedVersion,
...(opts.env ?? {}),
},
});
child.unref();
fs.closeSync(logFd);
// Poll the state file + /health until the daemon is up, or until timeout.
const deadline = Date.now() + MAX_START_WAIT_MS;
while (Date.now() < deadline) {
const fresh = readStateFile(opts.stateFile);
if (fresh) {
const h = await healthCheck(fresh.port);
if (h) return fresh.port;
}
await delay(POLL_INTERVAL_MS);
}
// Timed out — surface the startup log so the user sees the actual error
// instead of "daemon failed silently."
let tail = "";
try {
tail = fs.readFileSync(logPath, "utf-8").trim();
} catch {
// log file may not exist
}
throw new Error(
`Design daemon failed to start within ${MAX_START_WAIT_MS}ms.\n` +
`Startup log (${logPath}):\n${tail || "(empty)"}`,
);
}
async function gracefulShutdownExistingDaemon(port: number): Promise<void> {
try {
await fetch(`http://127.0.0.1:${port}/shutdown`, {
method: "POST",
signal: AbortSignal.timeout(2000),
});
} catch {
// Daemon may have already exited or be unresponsive — fall through
// to the SIGTERM path with identity verification.
}
}
/**
* Send SIGTERM (then SIGKILL) to `pid`, but ONLY if the running cmdline
* contains `marker`. Prevents a stale state file from causing us to signal
* an unrelated process that inherited the PID.
*/
async function killByPidWithIdentity(
pid: number,
marker: string,
verbose: boolean,
): Promise<void> {
if (!pid || pid <= 0) return;
if (!isProcessAlive(pid)) return;
if (!verifyIdentity(pid, marker || CMDLINE_MARKER)) {
log(
verbose,
`pid ${pid} is alive but cmdline doesn't match marker '${marker || CMDLINE_MARKER}'; skipping signal (possible PID reuse)`,
);
return;
}
try {
process.kill(pid, "SIGTERM");
} catch {
// already gone
return;
}
// Give it a grace period; SIGKILL if still alive AND still ours.
const deadline = Date.now() + SIGTERM_GRACE_MS;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) return;
await delay(50);
}
if (isProcessAlive(pid) && verifyIdentity(pid, marker || CMDLINE_MARKER)) {
log(verbose, `pid ${pid} survived SIGTERM; SIGKILL`);
try {
process.kill(pid, "SIGKILL");
} catch {
// raced with exit
}
}
}
/**
* Public: $D daemon stop. Posts /shutdown if no active boards; otherwise
* reports refusal. Used by the CLI sub-command (next commit).
*/
export async function shutdownDaemon(opts: { force?: boolean } = {}): Promise<{
stopped: boolean;
reason?: string;
activeBoards?: number;
}> {
const stateFile = resolveStateFilePath();
const existing = readStateFile(stateFile);
if (!existing) return { stopped: false, reason: "no daemon running" };
const health = await healthCheck(existing.port);
if (!health) {
// unresponsive: try SIGTERM via identity-checked path
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
return { stopped: true, reason: "unresponsive daemon killed via SIGTERM" };
}
if (health.activeBoards > 0 && !opts.force) {
return {
stopped: false,
reason: "active boards present",
activeBoards: health.activeBoards,
};
}
await gracefulShutdownExistingDaemon(existing.port);
// Best-effort: SIGTERM if /shutdown didn't take effect
if (isProcessAlive(existing.pid)) {
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
}
return { stopped: true };
}
/** $D daemon status — for the CLI sub-command. */
export async function daemonStatus(): Promise<
| { running: false }
| { running: true; port: number; pid: number; version: string; boards: number; activeBoards: number; uptime: number }
> {
const existing = readStateFile();
if (!existing) return { running: false };
const h = await healthCheck(existing.port);
if (!h) return { running: false };
return {
running: true,
port: existing.port,
pid: existing.pid,
version: h.version,
boards: h.boards,
activeBoards: h.activeBoards,
uptime: h.uptime,
};
}

220
design/src/daemon-state.ts Normal file
View File

@ -0,0 +1,220 @@
/**
* Pure utilities for design-daemon discovery.
*
* Shared between daemon.ts (writes/removes the state file) and
* daemon-client.ts (reads state, decides spawn-vs-attach). Mirrors
* browse/src/cli.ts:109-315 same atomic-write + fs.openSync 'wx' lock
* pattern, with an added cmdline-based identity check to guard against
* SIGTERM hitting a reused PID (Codex finding on the daemon plan).
*/
import { execFileSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
export interface DaemonState {
pid: number;
port: number;
startedAt: string; // ISO 8601
version: string;
serverPath: string;
cmdlineMarker: string;
}
// String we grep for in the spawned daemon's cmdline to confirm a pid is
// ours before sending any signal. Must appear in argv at spawn time.
export const CMDLINE_MARKER = "gstack-design-daemon";
export function resolveStateFilePath(): string {
// Env override has highest precedence so tests can point both client and
// spawned daemon at a per-test path without a shared cwd.
const envOverride = process.env.DESIGN_DAEMON_STATE_FILE;
if (envOverride) return envOverride;
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (root) return path.join(root, ".gstack", "design.json");
} catch {
// not in a git repo — fall through
}
return path.join(process.cwd(), ".gstack", "design.json");
}
export function resolveLockFilePath(stateFile: string = resolveStateFilePath()): string {
return `${stateFile}.lock`;
}
export function resolveDaemonLogPath(): string {
return path.join(os.homedir(), ".gstack", "design-daemon.log");
}
export function resolveStartupLogPath(): string {
return path.join(os.homedir(), ".gstack", "design-daemon-startup.log");
}
/**
* Read the gstack version both client and daemon should agree on. Looks
* (in order): DESIGN_DAEMON_VERSION env, design/dist/.version baked at
* build time, VERSION at the source-tree root (dev), then "unknown".
*
* Compiled binaries lose the source-tree relative path at runtime, so we
* try the dist/.version sidecar (which build.sh writes) before falling
* back. This keeps client.expectedVersion and daemon.VERSION coherent.
*/
export function readVersionString(): string {
const env = process.env.DESIGN_DAEMON_VERSION;
if (env) return env;
const candidates = [
// Compiled binary: design/dist/design lives alongside design/dist/.version
path.join(path.dirname(process.execPath), ".version"),
// Dev: design/src/* → repo root is two levels up
path.join(import.meta.dir, "..", "..", "VERSION"),
// Defensive: design/dist sibling of source tree
path.join(import.meta.dir, "..", "dist", ".version"),
];
for (const p of candidates) {
try {
const v = fs.readFileSync(p, "utf-8").trim();
if (v) return v;
} catch {
// try next
}
}
return "unknown";
}
export function readStateFile(stateFile: string = resolveStateFilePath()): DaemonState | null {
try {
return JSON.parse(fs.readFileSync(stateFile, "utf-8")) as DaemonState;
} catch {
return null;
}
}
export function writeStateFile(
state: DaemonState,
stateFile: string = resolveStateFilePath(),
): void {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
const tmp = `${stateFile}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
fs.renameSync(tmp, stateFile);
}
export function removeStateFile(stateFile: string = resolveStateFilePath()): void {
try {
fs.unlinkSync(stateFile);
} catch {
// already gone
}
}
export interface HealthOk {
ok: true;
version: string;
uptime: number;
boards: number;
activeBoards: number;
}
export async function healthCheck(
port: number,
timeoutMs: number = 2000,
): Promise<HealthOk | null> {
try {
const resp = await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(timeoutMs),
});
if (!resp.ok) return null;
const body = (await resp.json()) as Partial<HealthOk> | null;
if (body && body.ok === true && typeof body.version === "string") {
return body as HealthOk;
}
return null;
} catch {
return null;
}
}
export function isProcessAlive(pid: number): boolean {
if (!pid || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (e: unknown) {
// EPERM means it exists, we just can't signal it. ESRCH means it's gone.
const code = (e as NodeJS.ErrnoException | undefined)?.code;
return code === "EPERM";
}
}
/**
* Read the cmdline of a running process. Returns "" on any error.
* Linux: /proc/<pid>/cmdline (NUL-separated argv). macOS: `ps -p PID -o command=`.
*/
export function readCmdline(pid: number): string {
if (!isProcessAlive(pid)) return "";
try {
if (process.platform === "linux") {
const raw = fs.readFileSync(`/proc/${pid}/cmdline`, "utf-8");
return raw.replace(/\0/g, " ").trim();
}
if (process.platform === "darwin") {
return execFileSync("ps", ["-p", String(pid), "-o", "command="], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
}
return "";
} catch {
return "";
}
}
/**
* True only when the process at `pid` has `marker` in its cmdline. Used to
* avoid SIGTERMing an unrelated process that happens to have inherited a
* PID from a stale state file (the Codex PID-reuse concern). On systems
* where readCmdline is unsupported (or fails), this returns false safer
* to skip the signal than to risk killing the wrong process.
*/
export function verifyIdentity(pid: number, marker: string): boolean {
if (!marker) return false;
return readCmdline(pid).includes(marker);
}
/**
* Acquire an exclusive lock on `lockPath`. Returns a release function, or
* null if held by another live process. Stale locks (PID dead) are reclaimed
* once; if reclaim also fails the caller waits and retries via state re-read.
*/
export function acquireLock(lockPath: string): (() => void) | null {
try {
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// 'wx' = create exclusive, fail if exists. Atomic check-and-create.
const fd = fs.openSync(lockPath, "wx");
fs.writeSync(fd, `${process.pid}\n`);
fs.closeSync(fd);
return () => {
try {
fs.unlinkSync(lockPath);
} catch {
// already gone
}
};
} catch {
// Held — check if holder is alive
try {
const holderPid = parseInt(fs.readFileSync(lockPath, "utf-8").trim(), 10);
if (holderPid && isProcessAlive(holderPid)) return null;
// Stale, reclaim
fs.unlinkSync(lockPath);
return acquireLock(lockPath);
} catch {
return null;
}
}
}

582
design/src/daemon.ts Normal file
View File

@ -0,0 +1,582 @@
/**
* Persistent design board daemon.
*
* One process hosts many boards under /boards/<id>/. Spawned by
* daemon-client.ts when no live daemon is found on the project's discovery
* file (.gstack/design.json). Replaces the per-invocation server in
* serve.ts as the default for `$D compare --serve`; serve.ts is kept as
* the --no-daemon legacy/test path.
*
* Endpoints (see plan docs/designs path for full table):
* GET / index of boards
* GET /health liveness + version (unauth)
* POST /api/boards publish a new board
* POST /shutdown graceful exit (refused if active)
* GET /boards/<id> 301 /boards/<id>/
* GET /boards/<id>/ render board HTML
* GET /boards/<id>/api/progress state machine status
* POST /boards/<id>/api/feedback submit/regenerate
* POST /boards/<id>/api/reload swap board HTML
*
* Lifecycle:
* start bind 127.0.0.1:N write state file serve until 24h idle or
* explicit /shutdown remove state file exit 0
*
* The daemon refuses /shutdown when boards are non-done; the idle timer
* extends rather than killing in that case (up to a 28h hard ceiling).
* Both are Codex-flagged guards against silent loss of in-memory history.
*/
import fs from "fs";
import path from "path";
import {
CMDLINE_MARKER,
DaemonState,
readVersionString,
removeStateFile,
resolveDaemonLogPath,
writeStateFile,
} from "./daemon-state";
// ─── Tunables (env overrides for tests) ──────────────────────────
const DEFAULT_IDLE_MS = 24 * 60 * 60 * 1000; // 24h
const IDLE_MS = parseInt(
process.env.DESIGN_DAEMON_IDLE_MS || String(DEFAULT_IDLE_MS),
10,
);
const IDLE_EXTENSION_MS = parseInt(
process.env.DESIGN_DAEMON_EXTENSION_MS || String(60 * 60 * 1000), // 1h
10,
);
const MAX_EXTENSIONS = parseInt(process.env.DESIGN_DAEMON_MAX_EXTENSIONS || "4", 10);
const IDLE_CHECK_INTERVAL_MS = parseInt(
process.env.DESIGN_DAEMON_CHECK_MS || "60000",
10,
);
const MAX_BOARDS = parseInt(process.env.DESIGN_DAEMON_MAX_BOARDS || "50", 10);
const VERSION = readVersionString();
// ─── Per-board state ─────────────────────────────────────────────
export type BoardState = "serving" | "regenerating" | "done";
export interface Board {
id: string;
htmlContent: string;
sourceDir: string; // realpath of the dir feedback files write to
allowedDir: string; // realpath anchor for path-traversal guard
state: BoardState;
publishedAt: number;
lastTouched: number;
publisherPid: number;
title?: string;
}
// In-memory: keyed by board id.
const boards = new Map<string, Board>();
// Per-board mutex chain — serializes feedback POST vs reload POST on the
// same board so the daemon doesn't race a state mutation against an HTML swap.
const boardMutex = new Map<string, Promise<void>>();
let lastMeaningfulActivity = Date.now();
let idleExtensions = 0;
let shuttingDown = false;
let serverRef: ReturnType<typeof Bun.serve> | null = null;
let idleInterval: ReturnType<typeof setInterval> | null = null;
const startTime = Date.now();
const daemonLog = openDaemonLog();
function openDaemonLog(): fs.WriteStream | null {
try {
const p = resolveDaemonLogPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
return fs.createWriteStream(p, { flags: "a" });
} catch {
return null;
}
}
function dlog(...args: unknown[]): void {
const line = `[${new Date().toISOString()}] ${args.map(String).join(" ")}\n`;
if (daemonLog) daemonLog.write(line);
process.stderr.write(line);
}
// ─── Helpers ─────────────────────────────────────────────────────
function newBoardId(): string {
const now = new Date();
const y = now.getUTCFullYear().toString().padStart(4, "0");
const mo = (now.getUTCMonth() + 1).toString().padStart(2, "0");
const d = now.getUTCDate().toString().padStart(2, "0");
const hh = now.getUTCHours().toString().padStart(2, "0");
const mm = now.getUTCMinutes().toString().padStart(2, "0");
const ss = now.getUTCSeconds().toString().padStart(2, "0");
const rand = Math.random().toString(36).slice(2, 8).padEnd(6, "0");
return `b-${y}${mo}${d}-${hh}${mm}${ss}-${rand}`;
}
async function withBoardMutex<T>(id: string, fn: () => Promise<T>): Promise<T> {
const prev = boardMutex.get(id) || Promise.resolve();
let release!: () => void;
const next = new Promise<void>((r) => {
release = r;
});
boardMutex.set(id, prev.then(() => next));
await prev;
try {
return await fn();
} finally {
release();
if (boardMutex.get(id) === next) boardMutex.delete(id);
}
}
function markMeaningfulActivity(): void {
lastMeaningfulActivity = Date.now();
idleExtensions = 0;
}
function nonDoneCount(): number {
let n = 0;
for (const b of boards.values()) if (b.state !== "done") n += 1;
return n;
}
function hasActiveBoards(): boolean {
return nonDoneCount() > 0;
}
// LRU eviction. Prefers `done` boards as victims so an active regen doesn't
// vanish mid-flight. Returns the evicted id, or null when the map fits.
function evictOne(): string | null {
if (boards.size <= MAX_BOARDS) return null;
let oldestDone: Board | null = null;
let oldestAny: Board | null = null;
for (const b of boards.values()) {
if (b.state === "done") {
if (!oldestDone || b.lastTouched < oldestDone.lastTouched) oldestDone = b;
}
if (!oldestAny || b.lastTouched < oldestAny.lastTouched) oldestAny = b;
}
const victim = oldestDone || oldestAny;
if (!victim) return null;
boards.delete(victim.id);
boardMutex.delete(victim.id);
dlog(`evicted board ${victim.id} state=${victim.state}`);
return victim.id;
}
function evictUntilUnderCap(): void {
while (boards.size > MAX_BOARDS) {
if (!evictOne()) break;
}
}
function findActiveBoardForSourceDir(sourceDir: string): Board | null {
for (const b of boards.values()) {
if (b.sourceDir === sourceDir && b.state !== "done") return b;
}
return null;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!),
);
}
// ─── Shutdown ─────────────────────────────────────────────────────
async function gracefulShutdown(exitCode = 0): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
dlog(`shutting down boards=${boards.size} code=${exitCode}`);
if (idleInterval) clearInterval(idleInterval);
try {
serverRef?.stop();
} catch {
// already stopped
}
removeStateFile();
if (daemonLog) daemonLog.end();
setTimeout(() => process.exit(exitCode), 50);
}
export function idleCheckTick(): void {
if (shuttingDown) return;
const idle = Date.now() - lastMeaningfulActivity;
if (idle < IDLE_MS) return;
if (hasActiveBoards()) {
if (idleExtensions >= MAX_EXTENSIONS) {
dlog(`idle past hard ceiling with ${nonDoneCount()} active boards — forcing shutdown`);
gracefulShutdown(0);
return;
}
idleExtensions += 1;
// Push lastMeaningfulActivity forward by an extension window without
// marking real activity (so the count stays correct).
lastMeaningfulActivity = Date.now() - IDLE_MS + IDLE_EXTENSION_MS;
dlog(
`idle with ${nonDoneCount()} active boards — extending ${IDLE_EXTENSION_MS / 60000}min (${idleExtensions}/${MAX_EXTENSIONS})`,
);
return;
}
dlog(`idle for ${Math.floor(idle / 1000)}s — shutting down`);
gracefulShutdown(0);
}
// ─── Handlers ─────────────────────────────────────────────────────
function handleHealth(): Response {
return Response.json({
ok: true,
version: VERSION,
uptime: Math.floor((Date.now() - startTime) / 1000),
boards: boards.size,
activeBoards: nonDoneCount(),
});
}
function handleIndex(): Response {
const sorted = [...boards.values()].sort((a, b) => b.publishedAt - a.publishedAt);
const rows = sorted
.map((b) => {
const ts = new Date(b.publishedAt).toISOString();
const titleSuffix = b.title ? `${escapeHtml(b.title)}` : "";
return `<li><a href="/boards/${b.id}/">${b.id}</a> <span class="state state-${b.state}">${b.state}</span> <time>${ts}</time>${titleSuffix}</li>`;
})
.join("\n");
const empty = `<p class="empty">No boards yet. Run <code>$D compare --serve</code> to publish one.</p>`;
const list = sorted.length === 0 ? empty : `<ul>\n${rows}\n</ul>`;
const html = `<!DOCTYPE html><html lang="en"><head>
<meta charset="utf-8"><title>gstack design boards</title><style>
body{font:14px/1.5 -apple-system,system-ui,sans-serif;max-width:720px;margin:32px auto;padding:0 16px;color:#1a1a1a}
h1{font-size:20px;margin-bottom:4px}
.meta{color:#666;margin-bottom:24px;font-size:13px}
ul{padding:0;list-style:none}
li{padding:10px 0;border-bottom:1px solid #eee;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
a{color:#0070f3;text-decoration:none;font-family:ui-monospace,monospace}
a:hover{text-decoration:underline}
.state{font-size:11px;padding:2px 8px;border-radius:10px;background:#eef;color:#335}
.state-done{background:#efe;color:#353}
.state-regenerating{background:#ffe;color:#553}
time{color:#888;font-size:12px}
.empty{color:#888;font-style:italic}
code{font-family:ui-monospace,monospace;background:#f5f5f5;padding:2px 6px;border-radius:3px}
</style></head><body>
<h1>gstack design boards</h1>
<p class="meta">daemon up ${Math.floor((Date.now() - startTime) / 1000)}s · ${boards.size} board(s) · ${nonDoneCount()} active</p>
${list}
</body></html>`;
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
async function handlePublish(req: Request, origin: string): Promise<Response> {
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object") {
return Response.json({ error: "Expected JSON object" }, { status: 400 });
}
const htmlPath = typeof body.html === "string" ? body.html : "";
if (!htmlPath) return Response.json({ error: "Missing 'html' field" }, { status: 400 });
if (!fs.existsSync(htmlPath)) {
return Response.json({ error: `HTML file not found: ${htmlPath}` }, { status: 400 });
}
let resolvedHtml: string;
let sourceDir: string;
try {
resolvedHtml = fs.realpathSync(path.resolve(htmlPath));
sourceDir = fs.realpathSync(path.dirname(resolvedHtml));
} catch (e: any) {
return Response.json({ error: `Cannot resolve path: ${e.message}` }, { status: 400 });
}
if (!fs.statSync(resolvedHtml).isFile()) {
return Response.json(
{ error: `'html' must be a file, not a directory: ${htmlPath}` },
{ status: 400 },
);
}
// sourceDir comes from realpath(html), not from the body — Codex finding:
// body-supplied sourceDir is a local trust boundary the daemon shouldn't cross.
const existing = findActiveBoardForSourceDir(sourceDir);
if (existing) {
return Response.json(
{
error: "Source directory already in use by an active board",
existing: {
id: existing.id,
url: `${origin}/boards/${existing.id}/`,
state: existing.state,
},
},
{ status: 409 },
);
}
if (nonDoneCount() >= MAX_BOARDS) {
return Response.json(
{
error: `Cannot publish: ${MAX_BOARDS} non-done boards already exist. Submit or close some first.`,
},
{ status: 503 },
);
}
const id = newBoardId();
const htmlContent = fs.readFileSync(resolvedHtml, "utf-8");
const now = Date.now();
const board: Board = {
id,
htmlContent,
sourceDir,
allowedDir: sourceDir,
state: "serving",
publishedAt: now,
lastTouched: now,
publisherPid: typeof body.publisherPid === "number" ? body.publisherPid : 0,
title: typeof body.title === "string" ? body.title : undefined,
};
boards.set(id, board);
evictUntilUnderCap();
markMeaningfulActivity();
dlog(`published board ${id} sourceDir=${sourceDir} pid=${board.publisherPid}`);
return Response.json({
id,
url: `${origin}/boards/${id}/`,
sourceDir,
});
}
function handleBoardGet(board: Board): Response {
board.lastTouched = Date.now();
// No __GSTACK_SERVER_URL injection — board JS uses relative URLs that
// resolve against /boards/<id>/ (the trailing slash is load-bearing here;
// the 301 from the bare /boards/<id> form ensures it).
return new Response(board.htmlContent, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
function handleBoardProgress(board: Board): Response {
// NOT meaningful activity — bare progress polling shouldn't keep the
// daemon alive forever (Codex finding on idle-immortality).
board.lastTouched = Date.now();
return Response.json({ status: board.state });
}
async function handleBoardFeedback(board: Board, req: Request): Promise<Response> {
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object") {
return Response.json({ error: "Expected JSON object" }, { status: 400 });
}
const isSubmit = body.regenerated === false;
const isRegen = body.regenerated === true;
// Augment with boardId + publishedAt so multi-board agents can disambiguate
// which board produced a given feedback.json.
const augmented = {
...body,
boardId: board.id,
publishedAt: new Date(board.publishedAt).toISOString(),
};
const feedbackFile = isSubmit ? "feedback.json" : "feedback-pending.json";
const feedbackPath = path.join(board.sourceDir, feedbackFile);
try {
fs.writeFileSync(feedbackPath, JSON.stringify(augmented, null, 2));
} catch (e: any) {
dlog(`feedback write failed for ${board.id}: ${e.message}`);
return Response.json(
{ error: `Cannot write ${feedbackFile}: ${e.message}` },
{ status: 500 },
);
}
board.lastTouched = Date.now();
markMeaningfulActivity();
if (isSubmit) {
board.state = "done";
dlog(`board ${board.id} submitted → ${feedbackPath}`);
return Response.json({ received: true, action: "submitted" });
}
if (isRegen) {
board.state = "regenerating";
dlog(`board ${board.id} regenerate → ${feedbackPath}`);
return Response.json({ received: true, action: "regenerate" });
}
return Response.json({ received: true, action: "unknown" });
}
async function handleBoardReload(board: Board, req: Request): Promise<Response> {
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const newHtmlPath = typeof body?.html === "string" ? body.html : "";
if (!newHtmlPath || !fs.existsSync(newHtmlPath)) {
return Response.json({ error: `HTML file not found: ${newHtmlPath}` }, { status: 400 });
}
const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath));
if (!resolvedReload.startsWith(board.allowedDir + path.sep)) {
return Response.json(
{ error: `Path must be within: ${board.allowedDir}` },
{ status: 403 },
);
}
if (!fs.statSync(resolvedReload).isFile()) {
return Response.json(
{ error: `Path must be a file, not a directory: ${newHtmlPath}` },
{ status: 400 },
);
}
board.htmlContent = fs.readFileSync(resolvedReload, "utf-8");
board.state = "serving";
board.lastTouched = Date.now();
markMeaningfulActivity();
dlog(`board ${board.id} reloaded from ${resolvedReload}`);
return Response.json({ reloaded: true });
}
function boardExpiredHtml(id: string): string {
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Board expired — gstack</title>
<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;max-width:600px;margin:80px auto;padding:0 20px;color:#1a1a1a;text-align:center}
h1{font-size:20px}.id{font-family:ui-monospace,monospace;color:#888;font-size:13px}
a{color:#0070f3;text-decoration:none}a:hover{text-decoration:underline}</style></head><body>
<h1>Board expired</h1>
<p>Board <span class="id">${escapeHtml(id)}</span> is no longer hosted by this daemon (evicted or the daemon restarted).</p>
<p><a href="/"> see active boards</a></p>
</body></html>`;
}
// ─── Router ──────────────────────────────────────────────────────
const BOARD_RE = /^\/boards\/([A-Za-z0-9_-]+)(\/.*)?$/;
export async function fetchHandler(req: Request): Promise<Response> {
const url = new URL(req.url);
const origin = url.origin;
if (req.method === "GET" && url.pathname === "/health") return handleHealth();
if (req.method === "GET" && url.pathname === "/") return handleIndex();
if (req.method === "POST" && url.pathname === "/api/boards") return handlePublish(req, origin);
if (req.method === "POST" && url.pathname === "/shutdown") {
if (hasActiveBoards()) {
return Response.json(
{
error: "Refusing /shutdown: daemon has active boards. Submit or close them first.",
activeBoards: nonDoneCount(),
},
{ status: 409 },
);
}
setTimeout(() => gracefulShutdown(0), 50);
return Response.json({ shuttingDown: true });
}
const m = url.pathname.match(BOARD_RE);
if (m) {
const id = m[1]!;
const subpath = m[2] || "";
const board = boards.get(id);
if (!board) {
return new Response(boardExpiredHtml(id), {
status: 404,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// Bare /boards/<id> → 301 to /boards/<id>/ so relative URLs in board JS
// resolve against the right base (./api/feedback → /boards/<id>/api/feedback).
if (req.method === "GET" && subpath === "") {
return new Response(null, {
status: 301,
headers: { Location: `/boards/${id}/` },
});
}
if (req.method === "GET" && subpath === "/") return handleBoardGet(board);
if (req.method === "GET" && subpath === "/api/progress") return handleBoardProgress(board);
if (req.method === "POST" && subpath === "/api/feedback") {
return withBoardMutex(id, () => handleBoardFeedback(board, req));
}
if (req.method === "POST" && subpath === "/api/reload") {
return withBoardMutex(id, () => handleBoardReload(board, req));
}
}
return new Response("Not found", { status: 404 });
}
// ─── Startup ─────────────────────────────────────────────────────
export function start(): { port: number } {
const portArg = process.env.DESIGN_DAEMON_PORT;
const port = portArg ? parseInt(portArg, 10) : 0;
serverRef = Bun.serve({
port,
hostname: "127.0.0.1",
fetch: fetchHandler,
});
const actualPort = serverRef.port;
const state: DaemonState = {
pid: process.pid,
port: actualPort,
startedAt: new Date().toISOString(),
version: VERSION,
serverPath: process.argv[1] || "",
cmdlineMarker: CMDLINE_MARKER,
};
writeStateFile(state);
dlog(`DAEMON_STARTED port=${actualPort} pid=${process.pid} version=${VERSION}`);
// Stdout line the spawning CLI parses to learn the port quickly.
console.log(`DAEMON_STARTED port=${actualPort}`);
idleInterval = setInterval(idleCheckTick, IDLE_CHECK_INTERVAL_MS);
process.on("SIGTERM", () => {
void gracefulShutdown(0);
});
process.on("SIGINT", () => {
void gracefulShutdown(0);
});
process.on("uncaughtException", (e) => {
dlog(`uncaughtException: ${(e as Error).stack || (e as Error).message}`);
void gracefulShutdown(1);
});
return { port: actualPort };
}
if (import.meta.main) {
start();
}
// Exported for tests. Keep this small and stable.
export const __testInternals__ = {
boards,
fetchHandler,
idleCheckTick,
markMeaningfulActivity,
resetForTest: (): void => {
boards.clear();
boardMutex.clear();
lastMeaningfulActivity = Date.now();
idleExtensions = 0;
shuttingDown = false;
},
};

View File

@ -1,12 +1,18 @@
/**
* HTTP server for the design comparison board feedback loop.
*
* Replaces the broken file:// + DOM polling approach. The server:
* 1. Serves the comparison board HTML over HTTP
* 2. Injects __GSTACK_SERVER_URL so the board POSTs feedback here
* 3. Prints feedback JSON to stdout (agent reads it)
* 4. Stays alive across regeneration rounds (stateful)
* 5. Auto-opens in the user's default browser
* Legacy single-process path: spawned by `$D compare --serve --no-daemon`.
* The daemon (`design/src/daemon.ts`) handles default invocations and hosts
* multiple boards under `/boards/<id>/`; this file stays as the escape hatch
* for tests and debugging. Board JS uses relative URLs and a
* location.protocol feature-detect, so the same generated HTML works at
* both `/` (here) and `/boards/<id>/` (daemon).
*
* The server:
* 1. Serves the comparison board HTML over HTTP at `/`
* 2. Prints feedback JSON to stdout (agent reads it)
* 3. Stays alive across regeneration rounds (stateful)
* 4. Auto-opens in the user's default browser
*
* State machine:
*
@ -69,17 +75,14 @@ export async function serve(options: ServeOptions): Promise<void> {
fetch(req) {
const url = new URL(req.url);
// Serve the comparison board HTML
// Serve the comparison board HTML. The board JS uses relative paths
// (./api/feedback, ./api/progress) and a location.protocol
// feature-detect, so no per-request injection is needed.
if (
req.method === "GET" &&
(url.pathname === "/" || url.pathname === "/index.html")
) {
// Inject the server URL so the board can POST feedback
const injected = htmlContent.replace(
"</head>",
`<script>window.__GSTACK_SERVER_URL = ${JSON.stringify(url.origin)};</script>\n</head>`,
);
return new Response(injected, {
return new Response(htmlContent, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
@ -194,19 +197,25 @@ export async function serve(options: ServeOptions): Promise<void> {
);
}
// Security: resolve symlinks and validate the reload path is within the
// allowed directory (anchored to the initial HTML file's parent).
// Prevents path traversal via /api/reload reading arbitrary files.
// Security: resolve symlinks and validate the reload path is a FILE
// inside the allowed directory (anchored to the initial HTML file's
// parent). Prevents path traversal via /api/reload reading arbitrary
// files. A path resolving to the allowedDir itself (a directory) used
// to pass the guard and then crash readFileSync with EISDIR — reject
// it explicitly with a clear 400 instead.
const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath));
if (
!resolvedReload.startsWith(allowedDir + path.sep) &&
resolvedReload !== allowedDir
) {
if (!resolvedReload.startsWith(allowedDir + path.sep)) {
return Response.json(
{ error: `Path must be within: ${allowedDir}` },
{ status: 403 },
);
}
if (!fs.statSync(resolvedReload).isFile()) {
return Response.json(
{ error: `Path must be a file, not a directory: ${newHtmlPath}` },
{ status: 400 },
);
}
// Swap the HTML content
htmlContent = fs.readFileSync(resolvedReload, "utf-8");

View File

@ -0,0 +1,580 @@
/**
* Out-of-process tests for daemon-client.ts.
*
* Spawns real daemon subprocesses (via the fixtures helper) so we can
* exercise: state-file discovery, /health attach vs spawn, the lock +
* re-read-under-lock race, identity-verified SIGTERM, version mismatch
* with and without active boards, startup-error log surfacing, and the
* concurrent-CLIs race (two real subprocesses, one wins the lock).
*
* These tests are slower than daemon.test.ts (each spawn is ~200ms) so
* they're kept in a separate file to keep the in-process suite fast.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawn } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import {
daemonStatus,
ensureDaemon,
publishBoard,
shutdownDaemon,
} from "../src/daemon-client";
import {
acquireLock,
CMDLINE_MARKER,
isProcessAlive,
readStateFile,
resolveLockFilePath,
verifyIdentity,
} from "../src/daemon-state";
import {
DAEMON_SCRIPT,
makeBoardHtml,
makeTmpDir,
spawnDaemonForTest,
type SpawnedDaemon,
} from "./daemon-tests-fixtures";
let workDir: string;
let stateFile: string;
let activeDaemons: SpawnedDaemon[] = [];
beforeEach(() => {
workDir = makeTmpDir("discovery");
stateFile = path.join(workDir, "design.json");
// Each test gets a private state-file path; env var ensures both the
// client's resolver and any spawned daemons converge on the same file.
process.env.DESIGN_DAEMON_STATE_FILE = stateFile;
});
afterEach(async () => {
for (const d of activeDaemons.splice(0)) {
try { await d.stop(); } catch {}
}
// Tear down any state file left around so the next test starts clean.
try { fs.unlinkSync(stateFile); } catch {}
try { fs.unlinkSync(resolveLockFilePath(stateFile)); } catch {}
delete process.env.DESIGN_DAEMON_STATE_FILE;
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
});
async function spawn1(idleMs = 60_000): Promise<SpawnedDaemon> {
const d = await spawnDaemonForTest({ stateFile, idleMs });
activeDaemons.push(d);
return d;
}
// ─── healthCheck + readStateFile basics ──────────────────────────
describe("daemon-state helpers", () => {
test("readStateFile returns null when missing", () => {
expect(readStateFile(stateFile)).toBeNull();
});
test("spawned daemon writes a usable state file", async () => {
const d = await spawn1();
const state = readStateFile(stateFile);
expect(state).not.toBeNull();
expect(state!.pid).toBe(d.proc.pid);
expect(state!.port).toBe(d.port);
expect(state!.cmdlineMarker).toBe(CMDLINE_MARKER);
expect(state!.version).toBe("test-version");
});
test("verifyIdentity matches a real spawned daemon's cmdline", async () => {
const d = await spawn1();
expect(verifyIdentity(d.proc.pid!, CMDLINE_MARKER)).toBe(true);
// wrong marker → false
expect(verifyIdentity(d.proc.pid!, "some-other-marker-xyz")).toBe(false);
});
test("verifyIdentity returns false for dead pids", async () => {
expect(verifyIdentity(999_999_999, CMDLINE_MARKER)).toBe(false);
});
});
// ─── ensureDaemon ────────────────────────────────────────────────
describe("ensureDaemon", () => {
test("with no state file: spawns a fresh daemon", async () => {
const result = await ensureDaemon({
version: "test-version",
stateFile,
verbose: false,
});
expect(result.spawned).toBe(true);
expect(result.port).toBeGreaterThan(0);
expect(result.version).toBe("test-version");
const state = readStateFile(stateFile);
expect(state).not.toBeNull();
expect(isProcessAlive(state!.pid)).toBe(true);
// Track for cleanup
activeDaemons.push({
proc: { pid: state!.pid } as any,
port: state!.port,
stateFile,
stop: async () => {
try { process.kill(state!.pid, "SIGTERM"); } catch {}
},
});
});
test("with a healthy daemon already running: attaches without spawning", async () => {
const existing = await spawn1();
const result = await ensureDaemon({
version: "test-version",
stateFile,
verbose: false,
});
expect(result.spawned).toBe(false);
expect(result.port).toBe(existing.port);
});
test("with a stale state file (PID dead): spawns fresh, overwrites state", async () => {
// Synthesize a stale state file pointing at a definitely-dead pid.
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify({
pid: 999_999_998,
port: 1, // bogus port — /health will fail fast
startedAt: "2020-01-01T00:00:00Z",
version: "ancient",
serverPath: "/nope",
cmdlineMarker: CMDLINE_MARKER,
}));
const result = await ensureDaemon({
version: "test-version",
stateFile,
verbose: false,
});
expect(result.spawned).toBe(true);
// State file should now point at the live daemon.
const fresh = readStateFile(stateFile);
expect(fresh!.pid).not.toBe(999_999_998);
expect(isProcessAlive(fresh!.pid)).toBe(true);
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
});
test("PID-reuse safety: stale state with an unrelated alive PID → identity-verify blocks signal, daemon spawned", async () => {
// Use the current test process's PID — definitely alive, definitely
// does NOT have CMDLINE_MARKER in its cmdline (it's the Bun test runner).
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify({
pid: process.pid, // alive but NOT a daemon
port: 1,
startedAt: "2020-01-01T00:00:00Z",
version: "ancient",
serverPath: "/nope",
cmdlineMarker: CMDLINE_MARKER,
}));
// ensureDaemon should NOT signal process.pid (we'd kill ourselves);
// verifyIdentity catches the cmdline mismatch and skips the kill.
const result = await ensureDaemon({
version: "test-version",
stateFile,
verbose: false,
});
// We're still alive (didn't get killed)
expect(isProcessAlive(process.pid)).toBe(true);
expect(result.spawned).toBe(true);
const fresh = readStateFile(stateFile);
expect(fresh!.pid).not.toBe(process.pid);
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
});
test("version mismatch with NO active boards: gracefully shuts existing down and respawns", async () => {
const existing = await spawn1();
// The existing daemon's version is "test-version" (set by fixture env).
// ensureDaemon with a DIFFERENT version → should /shutdown the existing
// (no active boards) and spawn fresh.
const result = await ensureDaemon({
version: "different-version",
stateFile,
verbose: false,
});
expect(result.spawned).toBe(true);
expect(result.version).toBe("different-version");
// existing.proc.pid should be gone by now (or soon)
// Give it a moment for the /shutdown + SIGTERM to take effect
await new Promise((r) => setTimeout(r, 200));
expect(isProcessAlive(existing.proc.pid!)).toBe(false);
// New daemon recorded
const fresh = readStateFile(stateFile);
expect(fresh!.pid).not.toBe(existing.proc.pid);
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
});
test("version mismatch WITH active boards: refuses to kill, exits 1 with user-actionable error", async () => {
// Run the ensureDaemon-that-would-exit-1 in a subprocess so we can
// observe the exit code and stderr without killing the test runner.
const existing = await spawn1();
// Publish a board so activeBoards > 0
const html = makeBoardHtml(workDir);
await publishBoard({ port: existing.port, html });
// Sanity: status should reflect the active board
const statusResp = await fetch(`http://127.0.0.1:${existing.port}/health`);
const status = (await statusResp.json()) as any;
expect(status.activeBoards).toBe(1);
// Now run a tiny script that calls ensureDaemon with a mismatched
// version. It should print the WARNING + exit 1.
const scriptPath = path.join(workDir, "ensure-mismatch.ts");
fs.writeFileSync(scriptPath, `
import { ensureDaemon } from "${path.resolve(import.meta.dir, "..", "src", "daemon-client.ts").replace(/\\\\/g, "/")}";
await ensureDaemon({
version: "totally-different-version",
stateFile: ${JSON.stringify(stateFile)},
verbose: true,
});
console.log("REACHED_AFTER_ENSURE — should not happen");
`);
const child = spawn("bun", ["run", scriptPath], {
env: { ...process.env, DESIGN_DAEMON_STATE_FILE: stateFile },
stdio: ["ignore", "pipe", "pipe"],
});
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
child.stderr.on("data", (c) => stderrChunks.push(c));
child.stdout.on("data", (c) => stdoutChunks.push(c));
const exitCode = await new Promise<number>((resolve) => {
child.on("exit", (code) => resolve(code ?? -1));
});
const stderr = Buffer.concat(stderrChunks).toString();
const stdout = Buffer.concat(stdoutChunks).toString();
expect(exitCode).toBe(1);
expect(stderr).toContain("active board");
expect(stderr).toContain("Refusing to auto-kill");
// We must NOT have reached the post-ensure line
expect(stdout).not.toContain("REACHED_AFTER_ENSURE");
// And the existing daemon should still be alive
expect(isProcessAlive(existing.proc.pid!)).toBe(true);
}, 15_000);
});
// ─── publishBoard ────────────────────────────────────────────────
describe("publishBoard", () => {
test("publishes a board through the real HTTP path and returns id+url+sourceDir", async () => {
const d = await spawn1();
const htmlPath = makeBoardHtml(workDir, "<p>via-client</p>");
const result = await publishBoard({ port: d.port, html: htmlPath });
expect(result.id).toMatch(/^b-/);
expect(result.url).toBe(`http://127.0.0.1:${d.port}/boards/${result.id}/`);
expect(result.sourceDir).toBe(fs.realpathSync(workDir));
// Confirm the board is actually fetchable at the returned URL
const r = await fetch(result.url);
expect(r.status).toBe(200);
const html = await r.text();
expect(html).toContain("via-client");
});
test("409 surfaces existing board's id+url (returned object, no throw)", async () => {
const d = await spawn1();
const htmlPath = makeBoardHtml(workDir);
const first = await publishBoard({ port: d.port, html: htmlPath });
const htmlPath2 = makeBoardHtml(workDir, "<p>second</p>");
const second = await publishBoard({ port: d.port, html: htmlPath2 });
// Same sourceDir → 409 with `existing` field; publishBoard returns it
// so the caller can attach to the existing board.
expect(second.id).toBe(first.id);
expect(second.url).toBe(first.url);
});
});
// ─── shutdownDaemon / daemonStatus ───────────────────────────────
describe("shutdownDaemon + daemonStatus", () => {
test("status reports not-running when no state file", async () => {
const s = await daemonStatus();
expect(s.running).toBe(false);
});
test("status reports running with port + version + counts when daemon alive", async () => {
const d = await spawn1();
const s = await daemonStatus();
expect(s.running).toBe(true);
if (s.running) {
expect(s.port).toBe(d.port);
expect(s.pid).toBe(d.proc.pid);
expect(s.version).toBe("test-version");
expect(s.boards).toBe(0);
expect(s.activeBoards).toBe(0);
}
});
test("shutdownDaemon succeeds when no active boards", async () => {
const d = await spawn1();
const r = await shutdownDaemon();
expect(r.stopped).toBe(true);
// Give it a moment to die
await new Promise((res) => setTimeout(res, 300));
expect(isProcessAlive(d.proc.pid!)).toBe(false);
});
test("shutdownDaemon refuses (without force) when active boards present", async () => {
const d = await spawn1();
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
const r = await shutdownDaemon();
expect(r.stopped).toBe(false);
expect(r.reason).toContain("active");
expect(r.activeBoards).toBe(1);
// Daemon still running
expect(isProcessAlive(d.proc.pid!)).toBe(true);
});
test("shutdownDaemon with force=true ignores active boards", async () => {
const d = await spawn1();
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
const r = await shutdownDaemon({ force: true });
expect(r.stopped).toBe(true);
});
});
// ─── Real idle-shutdown behavior (spawned daemon, fast clock) ───
//
// The lastMeaningfulActivity timestamp is not observable from outside the
// daemon process, so the only way to prove "bare GETs do not reset the
// idle timer" is to spawn a real daemon with a short idle window, hit
// progress polls in a loop, and watch the process exit anyway.
//
// These tests aim for ~3-5s real time per test by setting IDLE_MS=2000
// and CHECK_MS=200. The idle-with-active-boards extension path needs a
// board in `serving` state to exercise.
describe("daemon idle-shutdown behavior (real process)", () => {
// Wait for a child process to exit, with a deadline. Resolves true on
// observed exit, false on timeout. Doesn't kill on timeout — caller does.
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) return true;
await new Promise((r) => setTimeout(r, 100));
}
return false;
}
test("idle daemon (no boards) shuts itself down after IDLE_MS + CHECK_MS", async () => {
const d = await spawnDaemonForTest({
stateFile,
idleMs: 2_000,
checkMs: 200,
});
// Don't push to activeDaemons; the daemon should self-exit and the
// afterEach SIGTERM would race with that. Track manually.
try {
// No boards published. lastMeaningfulActivity is the startup time.
// Wait IDLE_MS + a couple CHECK_MS intervals for the timer to fire.
const exited = await waitForExit(d.proc.pid!, 5_000);
expect(exited).toBe(true);
// State file removed by gracefulShutdown
expect(readStateFile(stateFile)).toBeNull();
} finally {
if (isProcessAlive(d.proc.pid!)) {
try { d.proc.kill("SIGKILL"); } catch {}
}
}
}, 10_000);
test("bare GET polling does NOT prevent idle shutdown (progress polls don't reset idle)", async () => {
const d = await spawnDaemonForTest({
stateFile,
idleMs: 2_000,
checkMs: 200,
});
let polling = true;
let pollCount = 0;
const boardDir = makeTmpDir("idle-poll");
try {
const board = await publishBoard({
port: d.port,
html: makeBoardHtml(boardDir),
});
// Submit so the board becomes `done` — non-done would trigger the
// 1h extension path and keep the daemon alive past IDLE_MS.
await fetch(`${board.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ regenerated: false, preferred: "A" }),
});
// Hammer /api/progress every 200ms in the background. If bare GETs
// reset meaningful activity, the daemon would never idle out.
const pollLoop = (async () => {
while (polling) {
try {
await fetch(`${board.url}api/progress`);
pollCount += 1;
} catch {
// daemon went away
break;
}
await new Promise((r) => setTimeout(r, 200));
}
})();
const exited = await waitForExit(d.proc.pid!, 6_000);
polling = false;
await pollLoop;
expect(exited).toBe(true);
// We polled at least a few times before the daemon idled out
expect(pollCount).toBeGreaterThan(3);
expect(readStateFile(stateFile)).toBeNull();
} finally {
polling = false;
if (isProcessAlive(d.proc.pid!)) {
try { d.proc.kill("SIGKILL"); } catch {}
}
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
}, 15_000);
test("idle with active (non-done) boards triggers extension instead of shutdown", async () => {
// With non-done boards, the daemon should NOT shut down on the first
// idle check after IDLE_MS — it extends. Verify it's still alive past
// the would-be-shutdown deadline. The MAX_EXTENSIONS=4 hard ceiling
// would take 4 * 1h = 4h to exercise with default extension window,
// so we shrink both IDLE and EXTENSION via env to test it in seconds.
const d = await spawnDaemonForTest({
stateFile,
idleMs: 1_500,
checkMs: 200,
env: {
DESIGN_DAEMON_EXTENSION_MS: "1500",
DESIGN_DAEMON_MAX_EXTENSIONS: "2",
},
});
const boardDir = makeTmpDir("idle-active");
try {
await publishBoard({ port: d.port, html: makeBoardHtml(boardDir) });
// Daemon has 1 non-done board. After IDLE_MS, idleCheckTick should
// extend rather than shut down. So at IDLE_MS + small margin, it's
// still alive.
await new Promise((r) => setTimeout(r, 2_500));
expect(isProcessAlive(d.proc.pid!)).toBe(true);
expect(readStateFile(stateFile)).not.toBeNull();
// After MAX_EXTENSIONS extension windows (2 * 1500ms = 3000ms more),
// the hard ceiling kicks in and force-shutdown fires. Total wait:
// IDLE_MS(1500) + EXT*MAX(3000) + slack(1000) = ~5500ms. We've already
// waited 2500ms, so 4000ms more.
const exited = await waitForExit(d.proc.pid!, 5_500);
expect(exited).toBe(true);
expect(readStateFile(stateFile)).toBeNull();
} finally {
if (isProcessAlive(d.proc.pid!)) {
try { d.proc.kill("SIGKILL"); } catch {}
}
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
}, 15_000);
});
// ─── Concurrent ensureDaemon race (one wins the lock) ───────────
describe("concurrent ensureDaemon race", () => {
test("two parallel ensureDaemon() calls converge on one daemon (one spawned, one attached)", async () => {
// Fire two ensureDaemon calls in parallel against the same empty
// stateFile. The fs.openSync('wx') lock should make exactly one win
// the spawn race; the loser waits for the first to write the state
// file, then attaches.
const [a, b] = await Promise.all([
ensureDaemon({ version: "test-version", stateFile, verbose: false }),
ensureDaemon({ version: "test-version", stateFile, verbose: false }),
]);
// Both got the same port (same daemon)
expect(a.port).toBe(b.port);
// Exactly one spawned, one attached
const spawnedCount = [a.spawned, b.spawned].filter(Boolean).length;
expect(spawnedCount).toBe(1);
// Exactly one daemon process is alive at that port
const state = readStateFile(stateFile);
expect(state).not.toBeNull();
expect(isProcessAlive(state!.pid)).toBe(true);
// Lock file cleaned up (the winner released it on exit from the try block)
expect(fs.existsSync(resolveLockFilePath(stateFile))).toBe(false);
// Track for cleanup
activeDaemons.push({
proc: { pid: state!.pid } as any,
port: state!.port,
stateFile,
stop: async () => {
try { process.kill(state!.pid, "SIGTERM"); } catch {}
},
});
}, 15_000);
});
// ─── Stale-lock reclaim ──────────────────────────────────────────
describe("acquireLock stale-lock reclaim", () => {
test("reclaims a lockfile owned by a dead PID and writes our PID", () => {
const lockPath = resolveLockFilePath(stateFile);
// Plant a lockfile owned by a definitely-dead PID
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
fs.writeFileSync(lockPath, "999999998\n");
const release = acquireLock(lockPath);
expect(release).not.toBeNull();
// Lock file now contains our PID
expect(fs.readFileSync(lockPath, "utf-8").trim()).toBe(String(process.pid));
release!();
// Released = lock file gone
expect(fs.existsSync(lockPath)).toBe(false);
});
test("refuses to reclaim a lockfile owned by an alive (unrelated) PID", () => {
const lockPath = resolveLockFilePath(stateFile);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Use this test process's own PID — it's alive AND unrelated to a daemon.
// acquireLock should refuse and return null without unlinking the lock.
fs.writeFileSync(lockPath, `${process.pid}\n`);
const release = acquireLock(lockPath);
expect(release).toBeNull();
// Lock file is untouched
expect(fs.readFileSync(lockPath, "utf-8").trim()).toBe(String(process.pid));
// Cleanup
try { fs.unlinkSync(lockPath); } catch {}
});
});

View File

@ -0,0 +1,135 @@
/**
* Shared helpers for daemon + daemon-client tests.
*
* Two test styles live here:
* - In-process: import fetchHandler from daemon.ts and call it with a
* synthesized Request. Fast, no spawn, no HTTP. Covers routing +
* handler semantics. Used by most of daemon.test.ts.
* - Out-of-process: spawn `bun run design/src/daemon.ts` with a tmp
* state file + env overrides, then HTTP against the bound port.
* Slow but only path that proves real spawn + state file + signal
* handling work. Used by daemon-discovery.test.ts.
*/
import { spawn, type ChildProcess } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import { __testInternals__ } from "../src/daemon";
export const DAEMON_SCRIPT = path.join(import.meta.dir, "..", "src", "daemon.ts");
export function makeTmpDir(prefix = "design-daemon-test"): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
}
export function makeBoardHtml(tmpDir: string, body = "<p>Test board</p>"): string {
const p = path.join(tmpDir, "design-board.html");
fs.writeFileSync(
p,
`<!DOCTYPE html><html><head></head><body>${body}</body></html>`,
);
return p;
}
/** Reset the in-process daemon state between tests. */
export function resetDaemon(): void {
__testInternals__.resetForTest();
}
/** Build a Request for the in-process fetchHandler tests. */
export function req(method: string, urlPath: string, body?: unknown): Request {
const init: RequestInit = { method };
if (body !== undefined) {
init.body = typeof body === "string" ? body : JSON.stringify(body);
init.headers = { "Content-Type": "application/json" };
}
return new Request(`http://127.0.0.1:1234${urlPath}`, init);
}
export interface SpawnedDaemon {
proc: ChildProcess;
port: number;
stateFile: string;
stop: () => Promise<void>;
}
/**
* Spawn a real daemon process pointed at a per-test state file, with an
* aggressive idle window so idle-shutdown tests don't take 24h. Resolves
* when stdout emits `DAEMON_STARTED port=<N>`.
*/
export async function spawnDaemonForTest(
opts: { stateFile?: string; idleMs?: number; checkMs?: number; env?: Record<string, string> } = {},
): Promise<SpawnedDaemon> {
const stateFile = opts.stateFile ?? path.join(makeTmpDir("daemon-state"), "design.json");
const env: Record<string, string> = {
...(process.env as Record<string, string>),
// DESIGN_DAEMON_STATE_FILE points both daemon and any same-process
// discovery at this test's state file (overrides resolveStateFilePath).
DESIGN_DAEMON_STATE_FILE: stateFile,
DESIGN_DAEMON_IDLE_MS: String(opts.idleMs ?? 60_000),
DESIGN_DAEMON_CHECK_MS: String(opts.checkMs ?? 1000),
DESIGN_DAEMON_VERSION: "test-version",
...(opts.env ?? {}),
};
// Spawn with a marker in argv so cmdline-based identity verification
// exercises the real CMDLINE_MARKER ("gstack-design-daemon").
const proc = spawn(
"bun",
["run", DAEMON_SCRIPT, "--marker", "gstack-design-daemon"],
{
env,
stdio: ["ignore", "pipe", "pipe"],
cwd: path.dirname(stateFile),
},
);
const port = await new Promise<number>((resolve, reject) => {
const onTimeout = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error("Daemon failed to emit DAEMON_STARTED within 5s"));
}, 5000);
proc.stdout!.on("data", (chunk: Buffer) => {
const line = chunk.toString();
const m = line.match(/DAEMON_STARTED port=(\d+)/);
if (m) {
clearTimeout(onTimeout);
resolve(parseInt(m[1]!, 10));
}
});
proc.on("error", (e) => {
clearTimeout(onTimeout);
reject(e);
});
proc.on("exit", (code) => {
clearTimeout(onTimeout);
reject(new Error(`Daemon exited early with code ${code}`));
});
});
return {
proc,
port,
stateFile,
stop: async () => {
proc.kill("SIGTERM");
await new Promise<void>((r) => {
const t = setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
// gone
}
r();
}, 2000);
proc.on("exit", () => {
clearTimeout(t);
r();
});
});
},
};
}

534
design/test/daemon.test.ts Normal file
View File

@ -0,0 +1,534 @@
/**
* In-process tests for design daemon endpoints + lifecycle helpers.
*
* Uses the exported fetchHandler directly (no Bun.serve spawn) so the suite
* is fast and deterministic. Spawn-based tests live in
* daemon-discovery.test.ts.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fs from "fs";
import path from "path";
import { __testInternals__, fetchHandler, idleCheckTick } from "../src/daemon";
const { markMeaningfulActivity } = __testInternals__;
import { makeBoardHtml, makeTmpDir, req, resetDaemon } from "./daemon-tests-fixtures";
let tmpDir: string;
beforeEach(() => {
resetDaemon();
tmpDir = makeTmpDir();
});
afterEach(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// already gone
}
});
async function publishTestBoard(opts: { dir?: string; body?: string; title?: string } = {}) {
const dir = opts.dir ?? tmpDir;
const htmlPath = makeBoardHtml(dir, opts.body ?? "<p>Test</p>");
const r = await fetchHandler(
req("POST", "/api/boards", { html: htmlPath, title: opts.title }),
);
expect(r.status).toBe(200);
const body = (await r.json()) as { id: string; url: string; sourceDir: string };
return { ...body, htmlPath, dir };
}
// ─── /health ─────────────────────────────────────────────────────
describe("daemon /health", () => {
test("returns ok=true with version + boards counts", async () => {
const r = await fetchHandler(req("GET", "/health"));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.ok).toBe(true);
expect(typeof body.version).toBe("string");
expect(body.boards).toBe(0);
expect(body.activeBoards).toBe(0);
expect(typeof body.uptime).toBe("number");
});
test("activeBoards counts non-done after publish", async () => {
await publishTestBoard();
const r = await fetchHandler(req("GET", "/health"));
const body = (await r.json()) as any;
expect(body.boards).toBe(1);
expect(body.activeBoards).toBe(1);
});
});
// ─── POST /api/boards (publish) ─────────────────────────────────
describe("daemon /api/boards (publish)", () => {
test("publishes a board and returns id + url + derived sourceDir", async () => {
const htmlPath = makeBoardHtml(tmpDir);
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.id).toMatch(/^b-\d{8}-\d{6}-[a-z0-9]{6}$/);
expect(body.url).toMatch(/\/boards\/b-\d{8}-\d{6}-[a-z0-9]{6}\/$/); // trailing slash
expect(body.sourceDir).toBe(fs.realpathSync(tmpDir));
});
test("rejects when html field missing", async () => {
const r = await fetchHandler(req("POST", "/api/boards", { title: "noop" }));
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("Missing 'html'");
});
test("rejects when html file does not exist", async () => {
const r = await fetchHandler(
req("POST", "/api/boards", { html: "/tmp/does-not-exist.html" }),
);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("not found");
});
test("rejects when html points at a directory", async () => {
const r = await fetchHandler(req("POST", "/api/boards", { html: tmpDir }));
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("must be a file");
});
test("ignores body-supplied sourceDir; derives from realpath(html) instead", async () => {
const htmlPath = makeBoardHtml(tmpDir);
const otherDir = makeTmpDir("sneaky");
try {
const r = await fetchHandler(
req("POST", "/api/boards", { html: htmlPath, sourceDir: otherDir }),
);
expect(r.status).toBe(200);
const body = (await r.json()) as any;
// The daemon used the realpath of the HTML's dir, NOT the body field.
expect(body.sourceDir).toBe(fs.realpathSync(tmpDir));
expect(body.sourceDir).not.toBe(fs.realpathSync(otherDir));
} finally {
try {
fs.rmSync(otherDir, { recursive: true, force: true });
} catch {
// already gone
}
}
});
test("409 when a non-done board already claims the same sourceDir", async () => {
const first = await publishTestBoard();
const htmlPath = makeBoardHtml(tmpDir, "<p>Second attempt</p>");
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(409);
const body = (await r.json()) as any;
expect(body.error).toContain("already in use");
expect(body.existing.id).toBe(first.id);
expect(body.existing.url).toContain(`/boards/${first.id}/`);
});
test("allows publish to same sourceDir after the prior board is done", async () => {
const first = await publishTestBoard();
// Submit the first board so it becomes done
await fetchHandler(
req("POST", `/boards/${first.id}/api/feedback`, { regenerated: false }),
);
const htmlPath = makeBoardHtml(tmpDir, "<p>Round two</p>");
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(200);
});
});
// ─── GET /boards/<id> trailing-slash redirect ────────────────────
describe("daemon /boards/<id> trailing-slash redirect", () => {
test("GET /boards/<id> returns 301 with Location /boards/<id>/", async () => {
const board = await publishTestBoard();
const r = await fetchHandler(req("GET", `/boards/${board.id}`));
expect(r.status).toBe(301);
expect(r.headers.get("Location")).toBe(`/boards/${board.id}/`);
});
test("GET /boards/<id>/ renders the board's HTML", async () => {
const board = await publishTestBoard({ body: "<p>Hello from board</p>" });
const r = await fetchHandler(req("GET", `/boards/${board.id}/`));
expect(r.status).toBe(200);
expect(r.headers.get("Content-Type") || "").toContain("text/html");
const html = await r.text();
expect(html).toContain("Hello from board");
// No __GSTACK_SERVER_URL injection (board JS uses relative paths)
expect(html).not.toContain("__GSTACK_SERVER_URL");
});
test("404 on unknown board id (shows expired page)", async () => {
const r = await fetchHandler(req("GET", "/boards/b-nonexistent/"));
expect(r.status).toBe(404);
const html = await r.text();
expect(html).toContain("Board expired");
});
});
// ─── POST /boards/<id>/api/feedback ──────────────────────────────
describe("daemon /boards/<id>/api/feedback", () => {
test("submit writes feedback.json to derived sourceDir with boardId + publishedAt", async () => {
const board = await publishTestBoard();
const feedback = { preferred: "A", ratings: { A: 5 }, regenerated: false };
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/feedback`, feedback),
);
expect(r.status).toBe(200);
expect(((await r.json()) as any).action).toBe("submitted");
const written = JSON.parse(
fs.readFileSync(path.join(board.sourceDir, "feedback.json"), "utf-8"),
);
expect(written.preferred).toBe("A");
expect(written.regenerated).toBe(false);
expect(written.boardId).toBe(board.id);
expect(typeof written.publishedAt).toBe("string");
expect(written.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
test("regenerate writes feedback-pending.json and flips state to regenerating", async () => {
const board = await publishTestBoard();
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/feedback`, {
regenerated: true,
regenerateAction: "more_like_A",
}),
);
expect(r.status).toBe(200);
expect(((await r.json()) as any).action).toBe("regenerate");
expect(fs.existsSync(path.join(board.sourceDir, "feedback-pending.json"))).toBe(true);
expect(fs.existsSync(path.join(board.sourceDir, "feedback.json"))).toBe(false);
const progress = await fetchHandler(
req("GET", `/boards/${board.id}/api/progress`),
);
expect(((await progress.json()) as any).status).toBe("regenerating");
});
test("cross-board isolation: feedback writes only into that board's sourceDir", async () => {
const dirA = makeTmpDir("board-a");
const dirB = makeTmpDir("board-b");
try {
const htmlA = makeBoardHtml(dirA);
const htmlB = makeBoardHtml(dirB);
const a = (await (await fetchHandler(
req("POST", "/api/boards", { html: htmlA }),
)).json()) as any;
const b = (await (await fetchHandler(
req("POST", "/api/boards", { html: htmlB }),
)).json()) as any;
expect(a.id).not.toBe(b.id);
await fetchHandler(
req("POST", `/boards/${a.id}/api/feedback`, { preferred: "A", regenerated: false }),
);
expect(fs.existsSync(path.join(a.sourceDir, "feedback.json"))).toBe(true);
// Board B's directory must not have been touched
expect(fs.existsSync(path.join(b.sourceDir, "feedback.json"))).toBe(false);
expect(fs.existsSync(path.join(b.sourceDir, "feedback-pending.json"))).toBe(false);
} finally {
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch {}
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
}
});
test("rejects malformed JSON body", async () => {
const board = await publishTestBoard();
const bad = new Request(`http://127.0.0.1/boards/${board.id}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not json",
});
const r = await fetchHandler(bad);
expect(r.status).toBe(400);
});
});
// ─── POST /boards/<id>/api/reload ────────────────────────────────
describe("daemon /boards/<id>/api/reload", () => {
test("swaps HTML in place; subsequent GET returns new content", async () => {
const board = await publishTestBoard({ body: "<p>round 1</p>" });
const newHtml = makeBoardHtml(tmpDir, "<p>round 2</p>");
// The reload helper writes to design-board.html; make a distinct path
fs.writeFileSync(path.join(tmpDir, "round2.html"), "<html><body><p>round 2</p></body></html>");
const reloadPath = path.join(tmpDir, "round2.html");
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { html: reloadPath }),
);
expect(r.status).toBe(200);
const page = await fetchHandler(req("GET", `/boards/${board.id}/`));
expect(await page.text()).toContain("round 2");
});
test("rejects path traversal outside allowedDir", async () => {
const board = await publishTestBoard();
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { html: "/etc/passwd" }),
);
expect(r.status).toBe(403);
});
test("rejects directory path (Codex finding regression guard)", async () => {
const board = await publishTestBoard();
const sub = path.join(tmpDir, "subdir");
fs.mkdirSync(sub, { recursive: true });
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { html: sub }),
);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("must be a file");
});
test("rejects symlink pointing out of allowedDir", async () => {
const board = await publishTestBoard();
const linkPath = path.join(tmpDir, "evil.html");
try {
fs.symlinkSync("/etc/passwd", linkPath);
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { html: linkPath }),
);
expect(r.status).toBe(403);
} finally {
try { fs.unlinkSync(linkPath); } catch {}
}
});
});
// ─── GET / (index) ───────────────────────────────────────────────
describe("daemon / (index)", () => {
test("empty state shows the no-boards message", async () => {
const r = await fetchHandler(req("GET", "/"));
expect(r.status).toBe(200);
const html = await r.text();
expect(html).toContain("No boards yet");
});
test("lists boards newest first with state badges", async () => {
const a = await publishTestBoard({ title: "first" });
// Small wait so publishedAt differs
await new Promise((r) => setTimeout(r, 5));
const dirB = makeTmpDir("index-b");
try {
const htmlB = makeBoardHtml(dirB);
const b = (await (await fetchHandler(
req("POST", "/api/boards", { html: htmlB, title: "second" }),
)).json()) as any;
const html = await (await fetchHandler(req("GET", "/"))).text();
const idxA = html.indexOf(a.id);
const idxB = html.indexOf(b.id);
// Newest first: b appears before a
expect(idxB).toBeGreaterThanOrEqual(0);
expect(idxA).toBeGreaterThan(idxB);
// State badge present
expect(html).toMatch(/state-serving/);
} finally {
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
}
});
});
// ─── /shutdown ───────────────────────────────────────────────────
describe("daemon /shutdown", () => {
test("refuses /shutdown when boards are non-done", async () => {
await publishTestBoard();
const r = await fetchHandler(req("POST", "/shutdown"));
expect(r.status).toBe(409);
const body = (await r.json()) as any;
expect(body.error).toContain("active boards");
expect(body.activeBoards).toBe(1);
});
test("accepts /shutdown when no active boards (graceful path)", async () => {
// Publish then submit so state=done
const board = await publishTestBoard();
await fetchHandler(
req("POST", `/boards/${board.id}/api/feedback`, { regenerated: false }),
);
// Now non-done count is 0 — handler should return shuttingDown:true.
// We DON'T let the real gracefulShutdown timer fire (it calls process.exit
// after 50ms which would tear down the test runner); instead we just
// observe the immediate response.
const r = await fetchHandler(req("POST", "/shutdown"));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.shuttingDown).toBe(true);
// Reset state for subsequent tests; the shutdown timer will be a no-op
// because the next resetForTest flips shuttingDown back to false.
resetDaemon();
});
});
// ─── LRU + non-done protection ───────────────────────────────────
describe("daemon LRU eviction", () => {
test("evicts done boards in preference to non-done", async () => {
// Seed the map directly so we don't have to publish 50 real boards.
// Setup: 10 done (oldest) + 40 serving (newer) = 50 total, 40 non-done.
// Publishing a 51st board: nonDoneCount(40) < MAX(50) → accepts, inserts,
// size=51, then evictUntilUnderCap kicks out the LRU done.
const boards = __testInternals__.boards;
const mk = (id: string, state: "serving" | "done", lastTouched: number) => {
boards.set(id, {
id,
htmlContent: "<p>seeded</p>",
sourceDir: `/tmp/seeded-${id}`,
allowedDir: `/tmp/seeded-${id}`,
state,
publishedAt: lastTouched,
lastTouched,
publisherPid: 0,
});
};
for (let i = 0; i < 10; i++) mk(`b-done-${i}`, "done", 1000 + i);
for (let i = 0; i < 40; i++) mk(`b-active-${i}`, "serving", 2000 + i);
expect(boards.size).toBe(50);
const htmlPath = makeBoardHtml(tmpDir);
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(200);
expect(boards.size).toBeLessThanOrEqual(50);
// At least one of the (oldest) done boards is gone; non-done untouched.
let doneGoneCount = 0;
for (let i = 0; i < 10; i++) if (!boards.has(`b-done-${i}`)) doneGoneCount += 1;
expect(doneGoneCount).toBeGreaterThanOrEqual(1);
// All non-done preserved
for (let i = 0; i < 40; i++) {
expect(boards.has(`b-active-${i}`)).toBe(true);
}
});
test("503 when 50 non-done boards already exist", async () => {
const boards = __testInternals__.boards;
for (let i = 0; i < 50; i++) {
boards.set(`b-busy-${i}`, {
id: `b-busy-${i}`,
htmlContent: "<p>busy</p>",
sourceDir: `/tmp/busy-${i}`,
allowedDir: `/tmp/busy-${i}`,
state: "serving",
publishedAt: i,
lastTouched: i,
publisherPid: 0,
});
}
const htmlPath = makeBoardHtml(tmpDir);
const r = await fetchHandler(req("POST", "/api/boards", { html: htmlPath }));
expect(r.status).toBe(503);
});
});
// ─── Idle + meaningful activity ──────────────────────────────────
//
// The behavioral tests for idle shutdown — actual process exit, bare-GET-
// doesn't-reset-idle, MAX_EXTENSIONS hard ceiling — live in
// daemon-discovery.test.ts because they require a real spawned daemon
// (lastMeaningfulActivity isn't observable in-process). The in-process
// version of these tests previously was a smoke that the testing specialist
// correctly flagged as misleading; it was removed.
describe("daemon idle + activity tracking (smoke)", () => {
test("idleCheckTick on a freshly-touched daemon does not throw or shut down", () => {
markMeaningfulActivity();
expect(() => idleCheckTick()).not.toThrow();
// boards map shouldn't have been wiped (no graceful shutdown happened)
expect(typeof __testInternals__.boards.size).toBe("number");
});
});
// ─── Malformed body negatives ────────────────────────────────────
describe("daemon malformed body handling", () => {
test("POST /api/boards rejects invalid JSON body with 400", async () => {
const bad = new Request("http://127.0.0.1:1234/api/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not json",
});
const r = await fetchHandler(bad);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("Invalid JSON");
});
test("POST /api/boards rejects non-object body (e.g. JSON null) with 400", async () => {
// JS quirk: `typeof [] === "object"`, so arrays slip past the
// !body || typeof body !== "object" guard and fail at the missing-html
// check below. The "Expected JSON object" path only fires for genuinely
// non-object values like null, numbers, strings.
const bad = new Request("http://127.0.0.1:1234/api/boards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "null",
});
const r = await fetchHandler(bad);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("Expected JSON object");
});
test("POST /api/boards: array body falls through to missing-html 400", async () => {
// Documents the actual behavior — arrays bypass the type guard but get
// caught by the html-field check. If we ever tighten the type check to
// reject arrays explicitly, this test will surface the change.
const r = await fetchHandler(req("POST", "/api/boards", [1, 2, 3] as any));
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("Missing 'html'");
});
test("POST /boards/<id>/api/reload rejects invalid JSON body with 400", async () => {
const board = await publishTestBoard();
const bad = new Request(
`http://127.0.0.1:1234/boards/${board.id}/api/reload`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{nope",
},
);
const r = await fetchHandler(bad);
expect(r.status).toBe(400);
});
test("POST /boards/<id>/api/reload rejects body missing html field with 400", async () => {
const board = await publishTestBoard();
const r = await fetchHandler(
req("POST", `/boards/${board.id}/api/reload`, { somethingElse: true }),
);
expect(r.status).toBe(400);
const body = (await r.json()) as any;
expect(body.error).toContain("HTML file not found");
});
});
// ─── Unknown routes ──────────────────────────────────────────────
describe("daemon unknown routes", () => {
test("404 on unknown path", async () => {
const r = await fetchHandler(req("GET", "/some/unknown/path"));
expect(r.status).toBe(404);
});
test("GET /api/boards (wrong method on publish endpoint) returns 404", async () => {
const r = await fetchHandler(req("GET", "/api/boards"));
expect(r.status).toBe(404);
});
});

View File

@ -0,0 +1,257 @@
/**
* End-to-end daemon round-trip test.
*
* Spawns a real design daemon and walks the full publish submit /
* regenerate / reload cycle via HTTP fetch (the same calls the board JS
* makes). Proves what design-shotgun and the rest of the design skills
* depend on:
*
* - $D compare --serve attaches to OR spawns a single shared daemon.
* - Two boards published into the same daemon get independent paths
* under /boards/<id>/ no port churn, no second process.
* - Submit writes feedback.json into the board's sourceDir with
* boardId + publishedAt fields the skill can poll for.
* - Regenerate writes feedback-pending.json, flips state to
* regenerating, /api/progress reflects it.
* - /api/reload swaps HTML in place second GET returns new content.
* - Even with two concurrent boards in flight, feedback for one does
* not contaminate the other's sourceDir.
*
* Browser-driven round-trip (feedback-roundtrip.test.ts) covers the same
* flow at the click level for the legacy --no-daemon path; this file is
* the daemon-path equivalent.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fs from "fs";
import path from "path";
import { publishBoard } from "../src/daemon-client";
import { readStateFile } from "../src/daemon-state";
import {
makeBoardHtml,
makeTmpDir,
spawnDaemonForTest,
type SpawnedDaemon,
} from "./daemon-tests-fixtures";
let workDir: string;
let stateFile: string;
let daemons: SpawnedDaemon[] = [];
beforeEach(() => {
workDir = makeTmpDir("roundtrip-daemon");
stateFile = path.join(workDir, "design.json");
process.env.DESIGN_DAEMON_STATE_FILE = stateFile;
});
afterEach(async () => {
for (const d of daemons.splice(0)) {
try { await d.stop(); } catch {}
}
try { fs.unlinkSync(stateFile); } catch {}
delete process.env.DESIGN_DAEMON_STATE_FILE;
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
});
async function spawn1(): Promise<SpawnedDaemon> {
const d = await spawnDaemonForTest({ stateFile, idleMs: 60_000 });
daemons.push(d);
return d;
}
// ─── Submit round-trip ───────────────────────────────────────────
describe("daemon round-trip: publish → submit → feedback.json", () => {
test("Submit feedback lands at sourceDir with boardId + publishedAt", async () => {
const d = await spawn1();
const boardDir = makeTmpDir("board-submit");
try {
const htmlPath = makeBoardHtml(boardDir, "<p>round-trip board</p>");
const board = await publishBoard({ port: d.port, html: htmlPath });
expect(board.url).toBe(`http://127.0.0.1:${d.port}/boards/${board.id}/`);
expect(board.sourceDir).toBe(fs.realpathSync(boardDir));
// GET the board URL — same path the browser would hit
const page = await fetch(board.url);
expect(page.status).toBe(200);
const pageHtml = await page.text();
expect(pageHtml).toContain("round-trip board");
// POST submit (mirrors what the board JS does on Submit click)
const submit = await fetch(`${board.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
preferred: "A",
ratings: { A: 5, B: 3 },
comments: { A: "love it" },
overall: "ship A",
regenerated: false,
}),
});
expect(submit.status).toBe(200);
const submitBody = (await submit.json()) as any;
expect(submitBody.action).toBe("submitted");
// The skill side polls for feedback.json in the source directory
const feedbackPath = path.join(board.sourceDir, "feedback.json");
expect(fs.existsSync(feedbackPath)).toBe(true);
const written = JSON.parse(fs.readFileSync(feedbackPath, "utf-8"));
expect(written.preferred).toBe("A");
expect(written.ratings).toEqual({ A: 5, B: 3 });
expect(written.regenerated).toBe(false);
// Augmented fields the daemon adds
expect(written.boardId).toBe(board.id);
expect(typeof written.publishedAt).toBe("string");
expect(written.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// The board's URL stays accessible after submit (history view)
const after = await fetch(board.url);
expect(after.status).toBe(200);
// Progress endpoint reflects done state
const progress = await fetch(`${board.url}api/progress`);
expect(((await progress.json()) as any).status).toBe("done");
} finally {
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
});
test("GET /boards/<id> (no trailing slash) returns 301 to /boards/<id>/", async () => {
const d = await spawn1();
const boardDir = makeTmpDir("board-redir");
try {
const board = await publishBoard({
port: d.port,
html: makeBoardHtml(boardDir),
});
// Use redirect: 'manual' so we observe the 301 response itself
const res = await fetch(`http://127.0.0.1:${d.port}/boards/${board.id}`, {
redirect: "manual",
});
expect(res.status).toBe(301);
expect(res.headers.get("Location")).toBe(`/boards/${board.id}/`);
} finally {
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
});
});
// ─── Regenerate + reload round-trip ──────────────────────────────
describe("daemon round-trip: publish → regenerate → reload → submit round 2", () => {
test("Full regen cycle: feedback-pending.json then reload swaps HTML", async () => {
const d = await spawn1();
const boardDir = makeTmpDir("board-regen");
try {
const r1Path = makeBoardHtml(boardDir, "<p>round 1 variants</p>");
const board = await publishBoard({ port: d.port, html: r1Path });
// Skill issues a regenerate via the board JS path
const regen = await fetch(`${board.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
preferred: "A",
ratings: { A: 4 },
regenerated: true,
regenerateAction: "more_like_A",
}),
});
expect(regen.status).toBe(200);
expect(((await regen.json()) as any).action).toBe("regenerate");
// Pending file exists, final feedback file does not
expect(fs.existsSync(path.join(board.sourceDir, "feedback-pending.json"))).toBe(true);
expect(fs.existsSync(path.join(board.sourceDir, "feedback.json"))).toBe(false);
// Progress reflects regenerating state
const prog1 = await fetch(`${board.url}api/progress`);
expect(((await prog1.json()) as any).status).toBe("regenerating");
// Agent generates round 2, writes a new HTML file, calls /api/reload
const r2Path = path.join(boardDir, "round2.html");
fs.writeFileSync(r2Path, "<!DOCTYPE html><html><body><p>round 2 variants</p></body></html>");
const reload = await fetch(`${board.url}api/reload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html: r2Path }),
});
expect(reload.status).toBe(200);
// Same URL now serves the round-2 content (no port change, no
// new browser tab — the user's existing tab can reload in place)
const r2Page = await fetch(board.url);
expect(await r2Page.text()).toContain("round 2 variants");
expect(((await (await fetch(`${board.url}api/progress`)).json()) as any).status).toBe(
"serving",
);
// User submits round 2
const finalSubmit = await fetch(`${board.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
preferred: "B",
ratings: { B: 5 },
regenerated: false,
}),
});
expect(finalSubmit.status).toBe(200);
const written = JSON.parse(
fs.readFileSync(path.join(board.sourceDir, "feedback.json"), "utf-8"),
);
expect(written.preferred).toBe("B");
expect(written.boardId).toBe(board.id);
} finally {
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
});
});
// ─── Two-board, one-daemon attach behavior ───────────────────────
describe("daemon round-trip: two concurrent publishes share one daemon", () => {
test("Second publish attaches to the same daemon (no new spawn)", async () => {
const d = await spawn1();
const dirA = makeTmpDir("two-a");
const dirB = makeTmpDir("two-b");
try {
const a = await publishBoard({ port: d.port, html: makeBoardHtml(dirA) });
const b = await publishBoard({ port: d.port, html: makeBoardHtml(dirB) });
// Same daemon process — state file pid is stable
const state = readStateFile(stateFile);
expect(state!.pid).toBe(d.proc.pid);
// Two distinct board ids
expect(a.id).not.toBe(b.id);
// Both URLs serve their own content
const pageA = await fetch(a.url);
const pageB = await fetch(b.url);
expect(pageA.status).toBe(200);
expect(pageB.status).toBe(200);
// Feedback isolation: submit to A only affects A's sourceDir
await fetch(`${a.url}api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ regenerated: false, preferred: "A" }),
});
expect(fs.existsSync(path.join(a.sourceDir, "feedback.json"))).toBe(true);
expect(fs.existsSync(path.join(b.sourceDir, "feedback.json"))).toBe(false);
// Index page lists both
const idx = await fetch(`http://127.0.0.1:${d.port}/`);
const idxHtml = await idx.text();
expect(idxHtml).toContain(a.id);
expect(idxHtml).toContain(b.id);
} finally {
try { fs.rmSync(dirA, { recursive: true, force: true }); } catch {}
try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {}
}
});
});

View File

@ -55,7 +55,7 @@ beforeAll(async () => {
serverState = 'serving';
// This server mirrors the real serve.ts behavior:
// - Injects __GSTACK_SERVER_URL into the HTML
// - Serves board HTML at / (board JS uses relative URLs)
// - Handles POST /api/feedback with file writes
// - Handles GET /api/progress for regeneration polling
// - Handles POST /api/reload for board swapping
@ -67,11 +67,7 @@ beforeAll(async () => {
const url = new URL(req.url);
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
const injected = currentHtml.replace(
'</head>',
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
);
return new Response(injected, {
return new Response(currentHtml, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
@ -140,14 +136,15 @@ describe('Submit: browser click → feedback.json on disk', () => {
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
serverState = 'serving';
// Navigate to the board (served with __GSTACK_SERVER_URL injected)
// Navigate to the board (board JS uses relative URLs + location.protocol detect)
await handleWriteCommand('goto', [baseUrl], bm);
// Verify __GSTACK_SERVER_URL was injected
const hasServerUrl = await handleReadCommand('js', [
'!!window.__GSTACK_SERVER_URL'
// Verify the board detects HTTP mode (so postFeedback will actually fetch
// instead of falling into the file:// DOM-only path)
const httpDetected = await handleReadCommand('js', [
"location.protocol === 'http:' || location.protocol === 'https:'"
], bm);
expect(hasServerUrl).toBe('true');
expect(httpDetected).toBe('true');
// User picks variant A, rates it 5 stars
await handleReadCommand('js', [

View File

@ -65,11 +65,9 @@ describe('Serve HTTP endpoints', () => {
const url = new URL(req.url);
if (req.method === 'GET' && url.pathname === '/') {
const injected = htmlContent.replace(
'</head>',
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
);
return new Response(injected, {
// Board JS uses relative URLs (./api/feedback, ./api/progress)
// and a location.protocol feature-detect; no injection needed.
return new Response(htmlContent, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
@ -118,12 +116,17 @@ describe('Serve HTTP endpoints', () => {
server.stop();
});
test('GET / serves HTML with injected __GSTACK_SERVER_URL', async () => {
test('GET / serves HTML with relative-path board JS (no injection)', async () => {
const res = await fetch(baseUrl);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('__GSTACK_SERVER_URL');
expect(html).toContain(baseUrl);
// No more per-origin URL injection; board JS uses relative paths.
expect(html).not.toContain('__GSTACK_SERVER_URL');
expect(html).not.toContain(baseUrl);
// Board JS calls relative endpoints so the same HTML works at / and at
// /boards/<id>/ (daemon mode).
expect(html).toContain("fetch('./api/feedback'");
expect(html).toContain("fetch('./api/progress')");
expect(html).toContain('Design Exploration');
});
@ -308,9 +311,12 @@ describe('Serve /api/reload — path traversal protection', () => {
}
// Production path validation — same as design/src/serve.ts
const resolvedReload = fs.realpathSync(path.resolve(body.html));
if (!resolvedReload.startsWith(allowedDir + path.sep) && resolvedReload !== allowedDir) {
if (!resolvedReload.startsWith(allowedDir + path.sep)) {
return Response.json({ error: `Path must be within: ${allowedDir}` }, { status: 403 });
}
if (!fs.statSync(resolvedReload).isFile()) {
return Response.json({ error: `Path must be a file, not a directory: ${body.html}` }, { status: 400 });
}
htmlContent = fs.readFileSync(resolvedReload, 'utf-8');
return Response.json({ reloaded: true });
})();
@ -369,6 +375,39 @@ describe('Serve /api/reload — path traversal protection', () => {
const page = await fetch(baseUrl);
expect(await page.text()).toContain('Safe reload');
});
// Regression for the directory-instead-of-file guard (Codex finding).
// Before: resolvedReload === allowedDir passed the guard and then
// readFileSync threw EISDIR with no helpful message.
test('blocks reload when path resolves to the allowed directory itself', async () => {
const res = await fetch(`${baseUrl}/api/reload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: tmpDir }),
});
// tmpDir does not satisfy startsWith(allowedDir + sep), so the within-dir
// check rejects with 403 — but importantly, no EISDIR crash.
expect(res.status).toBe(403);
});
test('blocks reload when path is a subdirectory (not a file)', async () => {
const subdir = path.join(tmpDir, 'subdir-not-a-file');
fs.mkdirSync(subdir, { recursive: true });
try {
const res = await fetch(`${baseUrl}/api/reload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: subdir }),
});
// Inside allowedDir but a directory — must fail before readFileSync,
// with a clear "must be a file" error instead of EISDIR.
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('must be a file');
} finally {
try { fs.rmSync(subdir, { recursive: true, force: true }); } catch {}
}
});
});
// ─── Full lifecycle: regeneration round-trip ──────────────────────

View File

@ -1341,8 +1341,11 @@ If the JSON contains `"regenerated": true`:
1. Read `regenerateAction` (or `remixSpec` for remix requests)
2. Generate new variants with `$D iterate` or `$D variants` using updated brief
3. Create new board with `$D compare`
4. POST the new HTML to the running server via `curl -X POST http://localhost:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
(parse the port from stderr: look for `SERVE_STARTED: port=XXXXX`)
4. POST the new HTML to the running board. Parse the board URL from stderr
(`BOARD_URL: http://127.0.0.1:N/boards/<id>/` — the daemon path) or fall
back to the legacy port (`SERVE_STARTED: port=N` — only emitted under
`--no-daemon`, hits `/api/reload` root). Daemon path:
`curl -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
5. Board auto-refreshes in the same tab
If `"regenerated": false`: proceed with the approved variant.

View File

@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.45.0.0",
"version": "1.46.0.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",

View File

@ -1057,8 +1057,12 @@ This command generates the board HTML, starts an HTTP server on a random port,
and opens it in the user's default browser. **Run it in the background** with `&`
because the server needs to stay running while the user interacts with the board.
Parse the port from stderr output: `SERVE_STARTED: port=XXXXX`. You need this
for the board URL and for reloading during regeneration cycles.
Parse the board URL from stderr output. Default daemon path:
`BOARD_URL: http://127.0.0.1:N/boards/<id>/` (already includes the per-board
path; use this for the AskUserQuestion URL AND as the base for the reload
endpoint). Legacy `--no-daemon` path emits `SERVE_STARTED: port=XXXXX` and
serves a single board at `/`, with reload at `/api/reload` — only relevant
when an external caller explicitly passes `--no-daemon`.
**PRIMARY WAIT: AskUserQuestion with board URL**
@ -1066,11 +1070,14 @@ After the board is serving, use AskUserQuestion to wait for the user. Include th
board URL so they can click it if they lost the browser tab:
"I've opened a comparison board with the design variants:
http://127.0.0.1:<PORT>/ — Rate them, leave comments, remix
<BOARD_URL> — Rate them, leave comments, remix
elements you like, and click Submit when you're done. Let me know when you've
submitted your feedback (or paste your preferences here). If you clicked
Regenerate or Remix on the board, tell me and I'll generate new variants."
Substitute `<BOARD_URL>` with the URL parsed from stderr (the daemon path
emits `BOARD_URL: http://127.0.0.1:N/boards/<id>/`).
**Do NOT use AskUserQuestion to ask which variant the user prefers.** The comparison
board IS the chooser. AskUserQuestion is just the blocking wait mechanism.
@ -1114,8 +1121,13 @@ the approved variant.
2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`)
3. Generate new variants with `$D iterate` or `$D variants` using updated brief
4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"`
5. Reload the board in the user's browser (same tab):
`curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
5. Reload the board in the user's browser (same tab) — the URL is per-board
under daemon mode, so use `<BOARD_URL>` (from the `BOARD_URL:` stderr
line) as the base:
`curl -s -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'`
Under `--no-daemon` the reload endpoint is `/api/reload` at the legacy
port; this path only matters if the caller explicitly opted out of the
daemon.
6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to
wait for the next round of feedback. Repeat until `feedback.json` appears.

View File

@ -891,8 +891,11 @@ If the JSON contains \`"regenerated": true\`:
1. Read \`regenerateAction\` (or \`remixSpec\` for remix requests)
2. Generate new variants with \`$D iterate\` or \`$D variants\` using updated brief
3. Create new board with \`$D compare\`
4. POST the new HTML to the running server via \`curl -X POST http://localhost:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'\`
(parse the port from stderr: look for \`SERVE_STARTED: port=XXXXX\`)
4. POST the new HTML to the running board. Parse the board URL from stderr
(\`BOARD_URL: http://127.0.0.1:N/boards/<id>/\` — the daemon path) or fall
back to the legacy port (\`SERVE_STARTED: port=N\` — only emitted under
\`--no-daemon\`, hits \`/api/reload\` root). Daemon path:
\`curl -X POST "\${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'\`
5. Board auto-refreshes in the same tab
If \`"regenerated": false\`: proceed with the approved variant.
@ -919,8 +922,12 @@ This command generates the board HTML, starts an HTTP server on a random port,
and opens it in the user's default browser. **Run it in the background** with \`&\`
because the server needs to stay running while the user interacts with the board.
Parse the port from stderr output: \`SERVE_STARTED: port=XXXXX\`. You need this
for the board URL and for reloading during regeneration cycles.
Parse the board URL from stderr output. Default daemon path:
\`BOARD_URL: http://127.0.0.1:N/boards/<id>/\` (already includes the per-board
path; use this for the AskUserQuestion URL AND as the base for the reload
endpoint). Legacy \`--no-daemon\` path emits \`SERVE_STARTED: port=XXXXX\` and
serves a single board at \`/\`, with reload at \`/api/reload\` — only relevant
when an external caller explicitly passes \`--no-daemon\`.
**PRIMARY WAIT: AskUserQuestion with board URL**
@ -928,11 +935,14 @@ After the board is serving, use AskUserQuestion to wait for the user. Include th
board URL so they can click it if they lost the browser tab:
"I've opened a comparison board with the design variants:
http://127.0.0.1:<PORT>/ — Rate them, leave comments, remix
<BOARD_URL> Rate them, leave comments, remix
elements you like, and click Submit when you're done. Let me know when you've
submitted your feedback (or paste your preferences here). If you clicked
Regenerate or Remix on the board, tell me and I'll generate new variants."
Substitute \`<BOARD_URL>\` with the URL parsed from stderr (the daemon path
emits \`BOARD_URL: http://127.0.0.1:N/boards/<id>/\`).
**Do NOT use AskUserQuestion to ask which variant the user prefers.** The comparison
board IS the chooser. AskUserQuestion is just the blocking wait mechanism.
@ -976,8 +986,13 @@ the approved variant.
2. If \`regenerateAction\` is \`"remix"\`, read \`remixSpec\` (e.g. \`{"layout":"A","colors":"B"}\`)
3. Generate new variants with \`$D iterate\` or \`$D variants\` using updated brief
4. Create new board: \`$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"\`
5. Reload the board in the user's browser (same tab):
\`curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'\`
5. Reload the board in the user's browser (same tab) the URL is per-board
under daemon mode, so use \`<BOARD_URL>\` (from the \`BOARD_URL:\` stderr
line) as the base:
\`curl -s -X POST "\${BOARD_URL}api/reload" -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'\`
Under \`--no-daemon\` the reload endpoint is \`/api/reload\` at the legacy
port; this path only matters if the caller explicitly opted out of the
daemon.
6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to
wait for the next round of feedback. Repeat until \`feedback.json\` appears.

View File

@ -1,10 +1,10 @@
{
"tag": "v1.45.0.0",
"capturedAt": "2026-05-26T03:38:39.714Z",
"capturedFromCommit": "296937d4",
"tag": "v1.46.0.0",
"capturedAt": "2026-05-26T04:17:57.247Z",
"capturedFromCommit": "2aff29e9",
"capturedFromBranch": "garrytan/slim-skill-tokens",
"totalSkills": 51,
"totalCorpusBytes": 2880003,
"totalCorpusBytes": 2882468,
"estTotalCatalogTokens": 4045,
"topHeaviest": [
{
@ -29,9 +29,9 @@
},
{
"skill": "office-hours",
"skillMdBytes": 110225,
"skillMdLines": 2017,
"estTokens": 27556,
"skillMdBytes": 110388,
"skillMdLines": 2020,
"estTokens": 27597,
"tmplBytes": 55466,
"descriptionLen": 860,
"hasGateEval": true,
@ -39,9 +39,9 @@
},
{
"skill": "plan-design-review",
"skillMdBytes": 104737,
"skillMdLines": 1870,
"estTokens": 26184,
"skillMdBytes": 105401,
"skillMdLines": 1882,
"estTokens": 26350,
"tmplBytes": 28624,
"descriptionLen": 218,
"hasGateEval": true,
@ -211,9 +211,9 @@
},
"design-consultation": {
"skill": "design-consultation",
"skillMdBytes": 75794,
"skillMdLines": 1497,
"estTokens": 18949,
"skillMdBytes": 76768,
"skillMdLines": 1515,
"estTokens": 19192,
"tmplBytes": 25899,
"descriptionLen": 888,
"hasGateEval": true,
@ -241,9 +241,9 @@
},
"design-shotgun": {
"skill": "design-shotgun",
"skillMdBytes": 59718,
"skillMdLines": 1253,
"estTokens": 14930,
"skillMdBytes": 60382,
"skillMdLines": 1265,
"estTokens": 15096,
"tmplBytes": 13331,
"descriptionLen": 786,
"hasGateEval": false,
@ -421,9 +421,9 @@
},
"office-hours": {
"skill": "office-hours",
"skillMdBytes": 110225,
"skillMdLines": 2017,
"estTokens": 27556,
"skillMdBytes": 110388,
"skillMdLines": 2020,
"estTokens": 27597,
"tmplBytes": 55466,
"descriptionLen": 860,
"hasGateEval": true,
@ -461,9 +461,9 @@
},
"plan-design-review": {
"skill": "plan-design-review",
"skillMdBytes": 104737,
"skillMdLines": 1870,
"estTokens": 26184,
"skillMdBytes": 105401,
"skillMdLines": 1882,
"estTokens": 26350,
"tmplBytes": 28624,
"descriptionLen": 218,
"hasGateEval": true,

View File

@ -1,5 +1,5 @@
/**
* Per-skill SKILL.md size budget regression (v1.45.0.0 T5).
* Per-skill SKILL.md size budget regression (v1.46.0.0 T5).
*
* Asserts that no skill's generated SKILL.md grew beyond the v1.44.1
* baseline. Catches preamble/resolver changes that bloat skills back to