@tekyzinc/gsd-t 3.21.11 → 3.21.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [3.21.12] - 2026-05-06
6
+
7
+ ### Fixed — dashboard orphan accumulation (M49 lazy autostart + idle-TTL + doctor-prune)
8
+
9
+ 88 dead `gsd-t-dashboard-server.js` processes accumulated under v3.21.11 (164 under v3.20.13). Root cause: `bin/headless-auto-spawn.cjs::autoSpawnHeadless()` called `ensureDashboardRunning()` on every spawn, fork-detaching a fresh dashboard for every gsd-t-execute / gsd-t-debug / gsd-t-wave invocation across any project across any session. 99% of those autostarted dashboards are never opened by the user (the live-transcript URL banner is just-in-case observability), so they accumulated on the project-scoped port range 7433–7532 until the user manually killed them.
10
+
11
+ **Changes:**
12
+ - `bin/headless-auto-spawn.cjs::autoSpawnHeadless()`: removed the `ensureDashboardRunning()` call. Spawns no longer autostart dashboards. New synchronous `_probeDashboardLazy(projectDir)` reads `.gsd-t/.dashboard.pid` and verifies the pid is alive via `process.kill(pid, 0)` (cheap; runs on every spawn). Banner is now conditional:
13
+ - Dashboard running: `▶ Live transcript: http://127.0.0.1:{port}/transcript/{spawn-id}` (existing M43 D6-T3 shape).
14
+ - No dashboard: `▶ Transcript file: {logPath}\n (to view live: gsd-t-visualize)` — points at the on-disk log + tells the user how to open the dashboard if they want it.
15
+ - `scripts/gsd-t-dashboard-server.js`: idle-TTL self-shutdown. Default 4 hours, configurable via env `GSD_T_DASHBOARD_IDLE_TTL_MS` or `--idle-ttl-ms` flag. "Idle" means zero HTTP requests AND zero active SSE connections for the full TTL window. setInterval check every 60s; on shutdown, removes `.gsd-t/.dashboard.pid` so the lazy probe sees a clean state. SSE-active dashboards never exit — `_wrapSseHandler` increments/decrements an active-connection counter on req/res `close` events.
16
+ - `bin/gsd-t.js doctor`: new `Dashboard Orphans` check + `--prune` flag. Scans for live `gsd-t-dashboard-server.js` processes via `ps -eo pid,command`; cross-references each pid against pidfiles in cwd, `GSD_T_PROJECT_DIR`, and the registered-projects list. Reports orphans (process running, pidfile missing or mismatched). With `--prune`, sends SIGTERM to each orphan. Recovery for any orphans that piled up under earlier versions.
17
+ - `commands/gsd-t-visualize.md` (unchanged): the explicit user opt-in path still calls `ensureDashboardRunning()` via `--detach` — the dashboard starts when (and only when) the user runs `/gsd-t-visualize`.
18
+
19
+ **Tests:**
20
+ - `test/m49-lazy-dashboard.test.js` (9): probe correctness across 5 pidfile states (missing / dead / live / garbage / empty), probe speed (< 50ms for 100 calls), `autoSpawnHeadless` does NOT invoke `ensureDashboardRunning` (require-cache stub), URL banner shape when running, file-path banner shape when not running.
21
+ - `test/m49-dashboard-idle-ttl.test.js` (7): `tracker.bump` resets `lastActivity`, SSE connect/disconnect counter, TTL fires when window elapses with no SSE, TTL does NOT fire while `activeSseConnections > 0`, recent `bump` prevents fire, `_wrapSseHandler` tracks idempotently on close, `startServer` accepts `idleTtlMs` opt without crashing.
22
+ - `test/m49-doctor-orphan-check.test.js` (4): no-process baseline, fake-dashboard process detected as orphan, `--prune` actually kills the orphan PID, tracked dashboard (pidfile lists pid) is NOT an orphan.
23
+ - `test/m43-url-banner.test.js`: updated for M49 — file-path banner expected by default; URL banner exercised with a pre-written pidfile pointing at the test runner's pid (proxy for "live").
24
+
25
+ **Migration:** existing autostarted dashboards stay running until they hit the 4h idle-TTL or are pruned via `gsd-t doctor --prune`. New spawns no longer add to the count. Re-running `/gsd-t-visualize` continues to work as before.
26
+
27
+ **Suite:** 2103/2105 (2 pre-existing env-sensitive flakes preserved per M47/M48 baseline). +20 new M49 tests, 0 regressions.
28
+
5
29
  ## [3.21.11] - 2026-05-06
6
30
 
7
31
  ### Fixed — viewer: 4 rendering regressions surfaced post-M47
package/bin/gsd-t.js CHANGED
@@ -2758,7 +2758,145 @@ async function checkDoctorContextMeter(projectDir) {
2758
2758
  return issues;
2759
2759
  }
2760
2760
 
2761
- async function doDoctor() {
2761
+ // M49 Detect dashboard server processes whose pidfile is missing or
2762
+ // mismatched ("orphans"). The lazy-dashboard + idle-TTL changes prevent new
2763
+ // orphans from accumulating, but recovery is still needed for any that piled
2764
+ // up under earlier versions. With `--prune`, kills the orphan PIDs.
2765
+ //
2766
+ // "Orphan" = a `gsd-t-dashboard-server.js` process whose pidfile (at
2767
+ // `{projectDir}/.gsd-t/.dashboard.pid` resolved from the process's cwd / the
2768
+ // `GSD_T_PROJECT_DIR` env / the registered-projects list) doesn't list the pid.
2769
+ //
2770
+ // Detection is best-effort across platforms — `ps` shape varies. We use:
2771
+ // - macOS / Linux: `ps -eo pid,command` (no `=`-stripped header)
2772
+ // - Windows: unsupported here (logged as N/A)
2773
+ //
2774
+ // Returns an issue count (0 if clean, 1 if orphans found and not pruned).
2775
+ function checkDoctorDashboardOrphans(opts) {
2776
+ const prune = !!(opts && opts.prune);
2777
+ let issues = 0;
2778
+ heading("Dashboard Orphans");
2779
+
2780
+ if (process.platform === "win32") {
2781
+ info("Skipping (Windows process inventory not yet supported)");
2782
+ return 0;
2783
+ }
2784
+
2785
+ let psOut;
2786
+ try {
2787
+ psOut = execFileSync("ps", ["-eo", "pid,command"], {
2788
+ encoding: "utf8",
2789
+ timeout: 5000,
2790
+ stdio: ["pipe", "pipe", "pipe"],
2791
+ });
2792
+ } catch {
2793
+ warn("Could not run `ps -eo pid,command` — orphan detection skipped");
2794
+ return 0;
2795
+ }
2796
+
2797
+ const dashPids = [];
2798
+ for (const line of psOut.split("\n")) {
2799
+ const t = line.trim();
2800
+ if (!t) continue;
2801
+ if (!t.includes("gsd-t-dashboard-server.js")) continue;
2802
+ if (t.includes("ps -eo")) continue; // never list ourselves
2803
+ if (t.includes("grep")) continue;
2804
+ const m = t.match(/^(\d+)\s+(.+)$/);
2805
+ if (!m) continue;
2806
+ const pid = parseInt(m[1], 10);
2807
+ if (!Number.isFinite(pid) || pid <= 0) continue;
2808
+ // Red Team — only treat as a dashboard process if the first argv token is
2809
+ // a `node`-flavored binary AND the second token ends with the script
2810
+ // filename. Filters out e.g. `cat gsd-t-dashboard-server.js`,
2811
+ // `vim gsd-t-dashboard-server.js`, etc. that happen to mention the path.
2812
+ const argv = m[2].trim().split(/\s+/);
2813
+ if (argv.length < 2) continue;
2814
+ const exe = argv[0];
2815
+ const script = argv[1] || "";
2816
+ const exeIsNode = /(^|\/)node([0-9.]+)?$/.test(exe);
2817
+ const scriptMatches = script.endsWith("gsd-t-dashboard-server.js");
2818
+ if (!exeIsNode || !scriptMatches) continue;
2819
+ dashPids.push({ pid, cmd: m[2] });
2820
+ }
2821
+
2822
+ if (dashPids.length === 0) {
2823
+ success("No dashboard processes running");
2824
+ return 0;
2825
+ }
2826
+
2827
+ // Collect candidate pidfiles. We probe (a) cwd + GSD_T_PROJECT_DIR for the
2828
+ // current shell, (b) each registered project. Each contributes one
2829
+ // {projectDir → pidfile pid} mapping. An orphan is a live dashboard pid
2830
+ // that doesn't match any pidfile we found.
2831
+ const projects = new Set();
2832
+ projects.add(process.cwd());
2833
+ if (process.env.GSD_T_PROJECT_DIR) projects.add(process.env.GSD_T_PROJECT_DIR);
2834
+ try {
2835
+ for (const p of getRegisteredProjects()) projects.add(p);
2836
+ } catch { /* registry may not exist */ }
2837
+
2838
+ const knownPids = new Set();
2839
+ for (const proj of projects) {
2840
+ const pidFile = path.join(proj, ".gsd-t", ".dashboard.pid");
2841
+ try {
2842
+ const raw = fs.readFileSync(pidFile, "utf8").trim();
2843
+ const pid = parseInt(raw, 10);
2844
+ if (Number.isFinite(pid) && pid > 0) knownPids.add(pid);
2845
+ } catch { /* missing or unreadable */ }
2846
+ // Also accept the older M38 pidfile (no leading dot).
2847
+ const legacyPidFile = path.join(proj, ".gsd-t", "dashboard.pid");
2848
+ try {
2849
+ const raw = fs.readFileSync(legacyPidFile, "utf8").trim();
2850
+ const pid = parseInt(raw, 10);
2851
+ if (Number.isFinite(pid) && pid > 0) knownPids.add(pid);
2852
+ } catch { /* missing */ }
2853
+ }
2854
+
2855
+ const orphans = dashPids.filter((d) => !knownPids.has(d.pid));
2856
+
2857
+ log(` Detected ${dashPids.length} dashboard process${dashPids.length === 1 ? "" : "es"} ` +
2858
+ `(${knownPids.size} tracked via pidfile, ${orphans.length} orphan${orphans.length === 1 ? "" : "s"})`);
2859
+
2860
+ if (orphans.length === 0) {
2861
+ success("No orphans");
2862
+ return 0;
2863
+ }
2864
+
2865
+ for (const o of orphans) {
2866
+ log(` ${YELLOW}orphan${RESET} pid=${o.pid}`);
2867
+ }
2868
+
2869
+ if (prune) {
2870
+ let killed = 0;
2871
+ let failed = 0;
2872
+ for (const o of orphans) {
2873
+ try {
2874
+ process.kill(o.pid, "SIGTERM");
2875
+ killed++;
2876
+ } catch (err) {
2877
+ if (err && err.code === "ESRCH") {
2878
+ // Already gone — count as success.
2879
+ killed++;
2880
+ } else {
2881
+ failed++;
2882
+ }
2883
+ }
2884
+ }
2885
+ if (failed === 0) {
2886
+ success(`Pruned ${killed} orphan${killed === 1 ? "" : "s"}`);
2887
+ } else {
2888
+ warn(`Pruned ${killed}, failed ${failed}`);
2889
+ issues++;
2890
+ }
2891
+ } else {
2892
+ info("Run `gsd-t doctor --prune` to kill orphans");
2893
+ issues++;
2894
+ }
2895
+
2896
+ return issues;
2897
+ }
2898
+
2899
+ async function doDoctor(opts) {
2762
2900
  heading("GSD-T Doctor");
2763
2901
  log("");
2764
2902
  let issues = 0;
@@ -2767,6 +2905,7 @@ async function doDoctor() {
2767
2905
  issues += checkDoctorProject();
2768
2906
  issues += checkDoctorCgc();
2769
2907
  issues += await checkDoctorContextMeter(process.cwd());
2908
+ issues += checkDoctorDashboardOrphans(opts);
2770
2909
  log("");
2771
2910
  if (issues === 0) {
2772
2911
  log(`${GREEN}${BOLD} All checks passed!${RESET}`);
@@ -3881,7 +4020,7 @@ function showHelp() {
3881
4020
  log(` ${CYAN}register${RESET} Register current directory as a GSD-T project`);
3882
4021
  log(` ${CYAN}status${RESET} Show installation status + check for updates`);
3883
4022
  log(` ${CYAN}uninstall${RESET} Remove GSD-T commands (keeps project files)`);
3884
- log(` ${CYAN}doctor${RESET} Diagnose common issues`);
4023
+ log(` ${CYAN}doctor${RESET} Diagnose common issues (use --prune to kill dashboard orphans)`);
3885
4024
  log(` ${CYAN}changelog${RESET} Open changelog in the browser`);
3886
4025
  log(` ${CYAN}graph${RESET} Code graph operations (index, status, query)`);
3887
4026
  log(` ${CYAN}headless${RESET} Non-interactive execution via claude -p + fast state queries`);
@@ -3946,6 +4085,8 @@ module.exports = {
3946
4085
  checkDoctorClaudeMd,
3947
4086
  checkDoctorSettings,
3948
4087
  checkDoctorEncoding,
4088
+ checkDoctorDashboardOrphans,
4089
+ doDoctor,
3949
4090
  mergeGsdtSection,
3950
4091
  migrateToMarkers,
3951
4092
  appendGsdtToClaudeMd,
@@ -4034,9 +4175,14 @@ if (require.main === module) {
4034
4175
  case "uninstall":
4035
4176
  doUninstall();
4036
4177
  break;
4037
- case "doctor":
4038
- doDoctor().catch((e) => { error(e.message || String(e)); process.exit(1); });
4178
+ case "doctor": {
4179
+ const doctorOpts = { prune: false };
4180
+ for (let i = 1; i < args.length; i++) {
4181
+ if (args[i] === "--prune") doctorOpts.prune = true;
4182
+ }
4183
+ doDoctor(doctorOpts).catch((e) => { error(e.message || String(e)); process.exit(1); });
4039
4184
  break;
4185
+ }
4040
4186
  case "changelog":
4041
4187
  doChangelog();
4042
4188
  break;
@@ -55,6 +55,8 @@ module.exports = {
55
55
  // it now unconditionally returns true. See headless-default-contract
56
56
  // v2.0.0 §Invariants.
57
57
  shouldSpawnHeadless: () => true,
58
+ // M49 — exported for tests. Synchronous probe; never throws.
59
+ _probeDashboardLazy,
58
60
  };
59
61
 
60
62
  // M43 D4 — one-shot deprecation banner when a caller still passes `watch`
@@ -126,16 +128,18 @@ function autoSpawnHeadless(opts) {
126
128
  ensureDir(path.join(projectDir, LOG_DIR_REL));
127
129
  ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
128
130
 
129
- // M43 D6-T4 Ensure dashboard is running (idempotent; no-op if already up).
130
- // Must happen BEFORE the URL banner print (D6-T3) so the link is live.
131
- // Never throws autostart is best-effort.
132
- let autostartInfo = null;
133
- try {
134
- const { ensureDashboardRunning } = require("../scripts/gsd-t-dashboard-autostart.cjs");
135
- autostartInfo = ensureDashboardRunning({ projectDir });
136
- } catch (_) {
137
- /* best-effort; fall through without banner port info */
138
- }
131
+ // M49Lazy dashboard. Spawns no longer autostart the dashboard. Each
132
+ // spawn would otherwise fork-detach a fresh `gsd-t-dashboard-server.js`
133
+ // (M43 D6-T4); 99% of those banners are never opened so they accumulated
134
+ // to 88+ orphans on the project-scoped port range. The dashboard now only
135
+ // starts when the user explicitly invokes `/gsd-t-visualize`. The banner
136
+ // below remains, but becomes conditional on whether a dashboard is already
137
+ // listening on the project's scoped port.
138
+ //
139
+ // Probe is sync + cheap: read `.gsd-t/.dashboard.pid` and `process.kill(pid, 0)`.
140
+ // Falls back to "no dashboard" on any error — never throws.
141
+ // See .gsd-t/contracts/headless-default-contract.md v2.0.0 §M49 (lazy banner).
142
+ const dashboardInfo = _probeDashboardLazy(projectDir);
139
143
 
140
144
  // M46 follow-up — Date + version banner. Printed before the transcript URL
141
145
  // so multi-day-old read-backs are immediately dated. Best-effort.
@@ -155,17 +159,27 @@ function autoSpawnHeadless(opts) {
155
159
  /* best-effort — never crash the spawn on banner failure */
156
160
  }
157
161
 
158
- // M43 D6-T3 Live transcript URL banner. Printed for every spawn so the
159
- // viewer at :PORT is "the" primary watching surface. Never throws.
160
- // Text is coordinated with D4 exact line shape is part of
161
- // dashboard-server-contract.md §Banner Format.
162
+ // M49Conditional transcript banner. If a dashboard is already running
163
+ // on the project's scoped port, point at the live URL (M43 D6-T3 shape).
164
+ // Otherwise, point at the on-disk log path and hint at /gsd-t-visualize so
165
+ // the user knows how to open the viewer if they want it. The viewer URL
166
+ // is no longer printed for spawns where no listener exists — that is what
167
+ // caused users to assume one was running and accumulated orphans on retry.
168
+ //
169
+ // Text is coordinated with D4 — banner format spec in
170
+ // .gsd-t/contracts/dashboard-server-contract.md v1.3.0 §Banner Format
171
+ // and headless-default-contract.md v2.0.0 §M49.
162
172
  try {
163
- let port = autostartInfo && autostartInfo.port;
164
- if (!port) {
165
- const { projectScopedDefaultPort } = require("../scripts/gsd-t-dashboard-server.js");
166
- port = projectScopedDefaultPort(projectDir);
173
+ if (dashboardInfo.running && dashboardInfo.port) {
174
+ process.stdout.write(
175
+ `▶ Live transcript: http://127.0.0.1:${dashboardInfo.port}/transcript/${id}\n`,
176
+ );
177
+ } else {
178
+ const relLog = path.relative(projectDir, logPath);
179
+ process.stdout.write(
180
+ `▶ Transcript file: ${relLog}\n (to view live: gsd-t-visualize)\n`,
181
+ );
167
182
  }
168
- process.stdout.write(`▶ Live transcript: http://127.0.0.1:${port}/transcript/${id}\n`);
169
183
  } catch (_) {
170
184
  /* best-effort — never crash the spawn on banner failure */
171
185
  }
@@ -515,6 +529,57 @@ function appendTokenLog(projectDir, entry) {
515
529
 
516
530
  // ── Helpers ──────────────────────────────────────────────────────────────────
517
531
 
532
+ /**
533
+ * M49 — Cheap synchronous probe: is a dashboard listening on this project's
534
+ * scoped port? Strategy: read `.gsd-t/.dashboard.pid`; if the pid is alive
535
+ * (`process.kill(pid, 0)` doesn't throw), assume the dashboard is up and
536
+ * resolve the port via `projectScopedDefaultPort(projectDir)`. No child
537
+ * process forking — this runs on every spawn and must be cheap.
538
+ *
539
+ * Returns `{ running: boolean, port: number|null, pid: number|null }`. Never
540
+ * throws. A return of `{ running: false }` is the safe fallback that drives
541
+ * the file-path-only banner.
542
+ *
543
+ * @param {string} projectDir
544
+ * @returns {{ running: boolean, port: number|null, pid: number|null }}
545
+ */
546
+ function _probeDashboardLazy(projectDir) {
547
+ const out = { running: false, port: null, pid: null };
548
+ try {
549
+ const pidFile = path.join(projectDir, ".gsd-t", ".dashboard.pid");
550
+ if (!fs.existsSync(pidFile)) return out;
551
+ const raw = fs.readFileSync(pidFile, "utf8").trim();
552
+ const pid = parseInt(raw, 10);
553
+ if (!pid || Number.isNaN(pid) || pid <= 0) return out;
554
+ // process.kill(pid, 0) — signal 0 only checks for existence/permission.
555
+ // Throws ESRCH if no such process; EPERM if process exists but owned by
556
+ // someone else. EPERM still implies "alive", so treat both ESRCH-only as
557
+ // dead.
558
+ try {
559
+ process.kill(pid, 0);
560
+ } catch (err) {
561
+ if (err && err.code === "EPERM") {
562
+ // Alive but not owned by us — still a live listener; treat as running.
563
+ } else {
564
+ return out; // ESRCH or unknown — treat as dead.
565
+ }
566
+ }
567
+ out.pid = pid;
568
+ out.running = true;
569
+ try {
570
+ const { projectScopedDefaultPort } = require("../scripts/gsd-t-dashboard-server.js");
571
+ out.port = projectScopedDefaultPort(projectDir);
572
+ } catch (_) {
573
+ // Without the port we can't render the URL — fall back to file banner.
574
+ out.running = false;
575
+ out.port = null;
576
+ }
577
+ } catch (_) {
578
+ /* probe is best-effort */
579
+ }
580
+ return out;
581
+ }
582
+
518
583
  function ensureDir(d) {
519
584
  if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
520
585
  }
@@ -73,8 +73,8 @@ The framework has no runtime — it is consumed entirely by Claude Code's slash
73
73
  ### Transcript Viewer as Primary Surface (M43 D6 — complete v3.16.13)
74
74
  - **Dashboard server additions** (`scripts/gsd-t-dashboard-server.js`): two new HTTP routes for the per-spawn viewer. `GET /transcript/:id/usage` → `{spawn_id, rows, truncated}` filtered from `.gsd-t/metrics/token-usage.jsonl` by `row.spawn_id === id` OR (no `spawn_id` column + `row.session_id === id` — the session-id branch covers M43 D1 Branch B in-session rows). `GET /transcript/:id/tool-cost` → proxies to `bin/gsd-t-tool-attribution.cjs::aggregateByTool` (M43 D2); returns 503 `{error: "tool-attribution library not yet available"}` when D2 isn't on disk so D6 could ship before D2 in Wave 2 without crashing callers.
75
75
  - **Transcript viewer panel** (`scripts/gsd-t-transcript.html`): collapsible "Tool Cost" sidebar panel that fetches `/transcript/:id/tool-cost` on viewer load and debounces a 2s refresh on each SSE `turn_complete` / `result` frame. Renders top-N tools sorted by attributed tokens with name, call count, tokens, and USD cost. Live badge green while SSE is open, muted otherwise. 503 → friendly "tool attribution not yet wired" row. `window.__gsdtRenderToolCostPanel` exposed for DOM tests.
76
- - **URL banner** (`bin/headless-auto-spawn.cjs`): every detached spawn prints `▶ Live transcript: http://127.0.0.1:{port}/transcript/{spawn-id}` on stdout. Port sourced from `ensureDashboardRunning().port` with `projectScopedDefaultPort(projectDir)` fallback. Best-effort — banner failure never crashes the spawn.
77
- - **Dashboard autostart** (`scripts/gsd-t-dashboard-autostart.cjs`, ~160 lines, zero deps): `ensureDashboardRunning({projectDir, port?})` probes the port synchronously via a short-lived subprocess (`_isPortBusySync` issues `net.createServer().listen(port)` host-less — matches the server's IPv6-wildcard bind on macOS dual-stack; specifying `127.0.0.1` would falsely report free). If free, fork-detaches the server with `spawn(…, {detached:true, stdio:'ignore'})` + `child.unref()` + writes `.gsd-t/.dashboard.pid` (hyphen → dot distinguishes this lifecycle from M38's `.gsd-t/dashboard.pid`). Idempotent on repeated invocation. Called at the top of `autoSpawnHeadless` so the banner printed immediately after resolves to a live listener.
76
+ - **URL banner** (`bin/headless-auto-spawn.cjs`, M49 — lazy): every detached spawn prints either `▶ Live transcript: http://127.0.0.1:{port}/transcript/{spawn-id}` (when a dashboard is already listening, detected via `_probeDashboardLazy()` reading `.gsd-t/.dashboard.pid` + `process.kill(pid, 0)`) OR `▶ Transcript file: {logPath}\n (to view live: gsd-t-visualize)` (when no dashboard is up). Pre-M49 the spawn unconditionally autostarted a dashboard via `ensureDashboardRunning()` and printed the URL — that accumulated 88+ orphan dashboard processes because 99% of those URLs are never opened. M49 removed the autostart from the spawn path; the dashboard now only starts when the user explicitly invokes `/gsd-t-visualize`. Best-effort — banner failure never crashes the spawn.
77
+ - **Dashboard autostart** (`scripts/gsd-t-dashboard-autostart.cjs`, ~160 lines, zero deps): `ensureDashboardRunning({projectDir, port?})` probes the port synchronously via a short-lived subprocess (`_isPortBusySync` issues `net.createServer().listen(port)` host-less — matches the server's IPv6-wildcard bind on macOS dual-stack; specifying `127.0.0.1` would falsely report free). If free, fork-detaches the server with `spawn(…, {detached:true, stdio:'ignore'})` + `child.unref()` + writes `.gsd-t/.dashboard.pid` (hyphen → dot distinguishes this lifecycle from M38's `.gsd-t/dashboard.pid`). Idempotent on repeated invocation. **M49 only called by `/gsd-t-visualize` now**, never by the spawn path; combined with the dashboard's idle-TTL self-shutdown (4-hour default, configurable via `GSD_T_DASHBOARD_IDLE_TTL_MS` or `--idle-ttl-ms`) this caps the long-tail orphan accumulation.
78
78
  - **Contract**: `.gsd-t/contracts/dashboard-server-contract.md` v1.2.0 — new §HTTP Endpoints entries, §Banner Format, §Autostart sections. (Bumped to v1.3.0 in M47 — see Focused Visualizer Redesign below.)
79
79
  - **Tests**: `test/m43-dashboard-tool-cost-route.test.js` (9), `test/m43-transcript-panel.test.js` (12), `test/m43-dashboard-autostart.test.js` (6), `test/m43-url-banner.test.js` (3).
80
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.21.11",
3
+ "version": "3.21.12",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -773,13 +773,106 @@ function handleSpawnPlanUpdates(req, res, projectDir) {
773
773
  req.on("close", () => { clearInterval(timer); if (dirWatcher) { try { dirWatcher.close(); } catch { /* ok */ } } });
774
774
  }
775
775
 
776
- function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath) {
776
+ // ── M49 Idle-TTL self-shutdown ────────────────────────────────────────────
777
+ //
778
+ // A dashboard with zero HTTP requests AND zero active SSE connections for the
779
+ // full TTL window self-exits cleanly. Safety net for any dashboard that
780
+ // somehow gets started and then walks away — even if a future bug lets one
781
+ // be auto-started, it dies on its own. Configurable via env
782
+ // `GSD_T_DASHBOARD_IDLE_TTL_MS` or `--idle-ttl-ms` flag.
783
+ //
784
+ // "Idle" means: zero HTTP requests AND zero active SSE connections for the
785
+ // full TTL window. `lastActivity` is bumped on every HTTP request handler
786
+ // entry and on SSE connect/disconnect. SSE-active dashboards never exit.
787
+ //
788
+ // On shutdown, removes `.gsd-t/.dashboard.pid` so the lazy probe (M49 in
789
+ // `bin/headless-auto-spawn.cjs`) sees a clean state.
790
+
791
+ const DEFAULT_IDLE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
792
+ const IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60s
793
+
794
+ function _activityTracker() {
795
+ let lastActivity = Date.now();
796
+ let activeSseConnections = 0;
797
+ return {
798
+ bump() { lastActivity = Date.now(); },
799
+ sseConnect() { activeSseConnections++; lastActivity = Date.now(); },
800
+ sseDisconnect() {
801
+ if (activeSseConnections > 0) activeSseConnections--;
802
+ lastActivity = Date.now();
803
+ },
804
+ snapshot() { return { lastActivity, activeSseConnections }; },
805
+ };
806
+ }
807
+
808
+ /**
809
+ * Wrap an SSE handler so it bumps the connect/disconnect counters.
810
+ */
811
+ function _wrapSseHandler(handler, tracker) {
812
+ return function (req, res, ...rest) {
813
+ tracker.sseConnect();
814
+ let closed = false;
815
+ const onClose = () => {
816
+ if (closed) return;
817
+ closed = true;
818
+ tracker.sseDisconnect();
819
+ };
820
+ req.on("close", onClose);
821
+ res.on("close", onClose);
822
+ res.on("finish", onClose);
823
+ return handler(req, res, ...rest);
824
+ };
825
+ }
826
+
827
+ /**
828
+ * @param {object} opts { ttlMs, intervalMs, projectDir, server }
829
+ * @returns timer handle (so callers can clearInterval in tests).
830
+ */
831
+ function _startIdleTtlTimer({ ttlMs, intervalMs, projectDir, server, tracker, onShutdown }) {
832
+ const interval = setInterval(() => {
833
+ const { lastActivity, activeSseConnections } = tracker.snapshot();
834
+ const idle = Date.now() - lastActivity;
835
+ if (activeSseConnections === 0 && idle >= ttlMs) {
836
+ clearInterval(interval);
837
+ try {
838
+ // Remove pid file so the lazy probe in headless-auto-spawn sees clean state.
839
+ if (projectDir) {
840
+ const pidFile = path.join(projectDir, ".gsd-t", ".dashboard.pid");
841
+ try { fs.unlinkSync(pidFile); } catch { /* may not exist */ }
842
+ }
843
+ } catch { /* best-effort */ }
844
+ try { if (typeof onShutdown === "function") onShutdown(); } catch { /* best-effort */ }
845
+ try {
846
+ if (server) server.close(() => process.exit(0));
847
+ else process.exit(0);
848
+ } catch { process.exit(0); }
849
+ }
850
+ }, intervalMs);
851
+ if (typeof interval.unref === "function") interval.unref();
852
+ return interval;
853
+ }
854
+
855
+ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath, opts) {
777
856
  const projDir = projectDir || path.resolve(eventsDir, "..", "..");
778
857
  const tHtmlPath = transcriptHtmlPath || path.join(path.dirname(htmlPath), "gsd-t-transcript.html");
858
+ const tracker = _activityTracker();
859
+ const ttlMs = (opts && Number.isFinite(opts.idleTtlMs))
860
+ ? opts.idleTtlMs
861
+ : (Number.parseInt(process.env.GSD_T_DASHBOARD_IDLE_TTL_MS || "", 10) || DEFAULT_IDLE_TTL_MS);
862
+ const intervalMs = (opts && Number.isFinite(opts.idleCheckIntervalMs))
863
+ ? opts.idleCheckIntervalMs
864
+ : IDLE_CHECK_INTERVAL_MS;
865
+
866
+ // Wrap the three SSE handlers with the connect/disconnect tracker.
867
+ const handleEventsSse = _wrapSseHandler(handleEvents, tracker);
868
+ const handleTranscriptStreamSse = _wrapSseHandler(handleTranscriptStream, tracker);
869
+ const handleSpawnPlanUpdatesSse = _wrapSseHandler(handleSpawnPlanUpdates, tracker);
870
+
779
871
  const server = http.createServer((req, res) => {
872
+ tracker.bump(); // bump on every HTTP request handler entry
780
873
  const url = req.url.split("?")[0];
781
874
  if (url === "/" || url === "") return handleRoot(req, res, htmlPath);
782
- if (url === "/events") return handleEvents(req, res, eventsDir);
875
+ if (url === "/events") return handleEventsSse(req, res, eventsDir);
783
876
  if (url === "/metrics") return handleMetrics(req, res, projDir);
784
877
  if (url === "/ping") return handlePing(req, res, port);
785
878
  if (url === "/stop") return handleStop(req, res, server);
@@ -788,7 +881,7 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
788
881
  if (url === "/api/main-session") return handleMainSession(req, res, projDir);
789
882
  // M44 D8 — spawn plans: GET list + SSE change stream
790
883
  if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
791
- if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdates(req, res, projDir);
884
+ if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdatesSse(req, res, projDir);
792
885
  // M44 D9 — parallelism observability (additive, read-only)
793
886
  if (url === "/api/parallelism") return handleParallelism(req, res, projDir);
794
887
  if (url === "/api/parallelism/report") return handleParallelismReport(req, res, projDir);
@@ -805,14 +898,30 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
805
898
  if (usageMatch) return handleTranscriptUsage(req, res, decodeURIComponent(usageMatch[1]), projDir);
806
899
  // /transcript/:spawnId/stream — SSE tail of per-spawn ndjson
807
900
  const streamMatch = url.match(/^\/transcript\/([^/]+)\/stream$/);
808
- if (streamMatch) return handleTranscriptStream(req, res, decodeURIComponent(streamMatch[1]), projDir);
901
+ if (streamMatch) return handleTranscriptStreamSse(req, res, decodeURIComponent(streamMatch[1]), projDir);
809
902
  // /transcript/:spawnId — HTML viewer page
810
903
  const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
811
904
  if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath, projDir);
812
905
  res.writeHead(404); res.end("Not found");
813
906
  });
814
907
  server.listen(port);
815
- return { server, url: `http://localhost:${port}` };
908
+
909
+ // M49 — install idle-TTL self-shutdown timer. Skipped only when caller
910
+ // explicitly passes `idleTtlMs: 0` (used by tests that don't want the
911
+ // server to self-exit mid-test).
912
+ let idleTimer = null;
913
+ if (ttlMs > 0) {
914
+ idleTimer = _startIdleTtlTimer({
915
+ ttlMs,
916
+ intervalMs,
917
+ projectDir: projDir,
918
+ server,
919
+ tracker,
920
+ onShutdown: opts && opts.onShutdown,
921
+ });
922
+ }
923
+
924
+ return { server, url: `http://localhost:${port}`, tracker, idleTimer };
816
925
  }
817
926
 
818
927
  module.exports = {
@@ -850,6 +959,11 @@ module.exports = {
850
959
  handleParallelism,
851
960
  handleParallelismReport,
852
961
  handleUnattendedStop,
962
+ // M49 — idle-TTL exports for tests
963
+ _activityTracker,
964
+ _wrapSseHandler,
965
+ _startIdleTtlTimer,
966
+ DEFAULT_IDLE_TTL_MS,
853
967
  };
854
968
 
855
969
  if (require.main === module) {
@@ -875,9 +989,25 @@ if (require.main === module) {
875
989
  fs.writeFileSync(pidFile, String(child.pid));
876
990
  process.exit(0);
877
991
  }
878
- const { server, url } = startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath);
992
+ // M49 idle-TTL flag/env override. Falls through to startServer's default
993
+ // (env var GSD_T_DASHBOARD_IDLE_TTL_MS or 4h).
994
+ const ttlArg = getArg("--idle-ttl-ms");
995
+ const startOpts = {};
996
+ if (ttlArg != null && ttlArg !== "") {
997
+ const n = Number.parseInt(ttlArg, 10);
998
+ if (Number.isFinite(n)) startOpts.idleTtlMs = n;
999
+ }
1000
+
1001
+ const { server, url } = startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath, startOpts);
879
1002
  process.stdout.write("GSD-T Dashboard: " + url + "\n");
880
- function cleanup() { try { fs.unlinkSync(pidFile); } catch { /* ok */ } server.close(() => process.exit(0)); }
1003
+ function cleanup() {
1004
+ try { fs.unlinkSync(pidFile); } catch { /* ok */ }
1005
+ // M49 — also remove the lazy-probe pidfile so headless-auto-spawn sees clean state.
1006
+ try {
1007
+ fs.unlinkSync(path.join(projectDir, ".gsd-t", ".dashboard.pid"));
1008
+ } catch { /* ok */ }
1009
+ server.close(() => process.exit(0));
1010
+ }
881
1011
  process.on("SIGTERM", cleanup);
882
1012
  process.on("SIGINT", cleanup);
883
1013
  }