@tekyzinc/gsd-t 3.21.10 → 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 +45 -0
- package/bin/gsd-t.js +150 -4
- package/bin/headless-auto-spawn.cjs +84 -19
- package/docs/architecture.md +2 -2
- package/package.json +1 -1
- package/scripts/gsd-t-dashboard-server.js +166 -11
- package/scripts/gsd-t-transcript.html +142 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
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
|
+
|
|
29
|
+
## [3.21.11] - 2026-05-06
|
|
30
|
+
|
|
31
|
+
### Fixed — viewer: 4 rendering regressions surfaced post-M47
|
|
32
|
+
|
|
33
|
+
The M47 viewer redesign shipped four user-visible rendering bugs that only became apparent when a project's in-session conversation was actually being viewed against a non-GSD-T project. Discovered when the dashboard for `Move-Zoom-Recordings-to-GDrive` showed three captured `in-session-*.ndjson` files but rendered them with a hardcoded "GSD-T Transcript" header, identical timestamps on every frame, raw `JSON.stringify` dumps in place of chat turns, and the same content in both top and bottom panes.
|
|
34
|
+
|
|
35
|
+
**Changes:**
|
|
36
|
+
- `scripts/gsd-t-dashboard-server.js`: `<title>` and `.title` div now carry a `__PROJECT_NAME__` placeholder substituted server-side via `path.basename(path.resolve(projectDir))` in both `handleTranscriptsList` and `handleTranscriptPage`. New `_escapeHtml()` helper escapes `<` / `&` / `"` in basenames; the substitution uses the function form of `replace` to defuse `$&` / `$1` / `$$` backreference patterns in basenames (Red Team BUG-1).
|
|
37
|
+
- `scripts/gsd-t-transcript.html`:
|
|
38
|
+
- `frameTs(frame, fallback)` parses each frame's ISO `ts` field and only falls back to the SSE-handler-captured `arrivedAt` when absent or invalid. `connect()` and `connectMain()` now thread `renderAt = frameTs(frame, arrivedAt)` to `renderFrame`. Initial-replay batches no longer collapse 200 distinct timestamps into one.
|
|
39
|
+
- 4 new render helpers (`renderUserTurn` / `renderAssistantTurn` / `renderSessionStart` / `renderToolUseLine`) plus dispatch arms in `renderFrameInner` BEFORE the `JSON.stringify` fallback. New CSS for `.frame.assistant-turn` (green border-left), `.frame.session-start` (small inline badge), `.frame.tool-call-line`, `.frame.truncated-tag`. `user_turn` reuses `.frame.user` bubble styling. Truncated content gets a "(truncated)" tag.
|
|
40
|
+
- 5 separate guards keep `in-session-*` ids out of the bottom pane: `renderRailEntry` click handler returns early on `isInSession`; initial bottom-pane resolution scrubs `in-session-*` from `SS_KEY_SELECTED` sessionStorage before `connect()`; `hashchange` handler returns early; `maybeAutoFollow` filters in-session spawns out; legacy `renderTree` click handler in the live-bucket fallback path also gets the guard (Red Team BUG-2).
|
|
41
|
+
- `test/m48-viewer-rendering-fixes.test.js`: 23 new regression tests — 5 Bug-1, 5 Bug-2 (incl. functional `frameTs` eval-extract probe), 7 Bug-3, 5 Bug-4, 1 functional probe (Red Team test-quality concern). Includes explicit `$&` and `$1` regression tests for the BUG-1 fix.
|
|
42
|
+
- `test/m44-transcript-timestamp.test.js`: updated for the `renderAt` / `arrivedAt` rename — semantics preserved (`arrivedAt` is now the fallback layer beneath parsed `frame.ts`).
|
|
43
|
+
|
|
44
|
+
**Migration:** existing dashboards pick up the new code on next refresh after `gsd-t update-all` propagates the package; the per-project transcript page reflects the project's directory basename automatically. No state migration.
|
|
45
|
+
|
|
46
|
+
**Suite:** 2083/2083 pass — both pre-existing M47-baseline flakes resolved on the release run.
|
|
47
|
+
|
|
48
|
+
**Red Team adversarial QA (opus):** initial sweep found 1 MEDIUM (`$&`-corruption in basename → fixed via function-form replace) + 1 LOW (legacy `renderTree` click handler → fixed via `isInSession` guard) + 1 test-quality recommendation (addressed via functional `frameTs` probe). Re-verification: GRUDGING PASS — no new bugs introduced.
|
|
49
|
+
|
|
5
50
|
## [3.20.13] - 2026-05-05
|
|
6
51
|
|
|
7
52
|
### Fixed — visualizer: surface in-session NDJSONs when `.index.json` is empty
|
package/bin/gsd-t.js
CHANGED
|
@@ -2758,7 +2758,145 @@ async function checkDoctorContextMeter(projectDir) {
|
|
|
2758
2758
|
return issues;
|
|
2759
2759
|
}
|
|
2760
2760
|
|
|
2761
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
131
|
+
// M49 — Lazy 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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
+
// M49 — Conditional 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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -273,7 +273,14 @@ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
|
|
|
273
273
|
// viewer's initialId logic falls through to location.hash (also empty)
|
|
274
274
|
// and connect('') is a no-op beyond a 404 SSE attempt — harmless, since
|
|
275
275
|
// the left rail polls /api/spawns-index independently.
|
|
276
|
-
const
|
|
276
|
+
const projectName = path.basename(path.resolve(projectDir || "."));
|
|
277
|
+
// Function-form replacement: a string replacement would interpret
|
|
278
|
+
// `$&`, `$1`, `$$`, etc. in the project basename as backreferences,
|
|
279
|
+
// re-injecting the placeholder or fragments of it (Red Team BUG-1).
|
|
280
|
+
const escapedName = _escapeHtml(projectName);
|
|
281
|
+
const html = data.toString("utf8")
|
|
282
|
+
.replace(/__SPAWN_ID__/g, () => "")
|
|
283
|
+
.replace(/__PROJECT_NAME__/g, () => escapedName);
|
|
277
284
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
278
285
|
res.end(html);
|
|
279
286
|
});
|
|
@@ -283,18 +290,36 @@ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
|
|
|
283
290
|
res.end(JSON.stringify({ spawns: sorted }));
|
|
284
291
|
}
|
|
285
292
|
|
|
286
|
-
function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath) {
|
|
293
|
+
function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath, projectDir) {
|
|
287
294
|
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
288
295
|
fs.readFile(transcriptHtmlPath, (err, data) => {
|
|
289
296
|
if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
|
|
290
297
|
// Inject the spawn-id as a data attribute on <body> by string replacement;
|
|
291
298
|
// the HTML ships with a placeholder `data-spawn-id="__SPAWN_ID__"`.
|
|
292
|
-
const
|
|
299
|
+
const projectName = path.basename(path.resolve(projectDir || "."));
|
|
300
|
+
// Function-form replacement: see comment in handleTranscriptsList. Even
|
|
301
|
+
// though isValidSpawnId guards spawnId against `$`, defence in depth.
|
|
302
|
+
const escapedName = _escapeHtml(projectName);
|
|
303
|
+
const html = data.toString("utf8")
|
|
304
|
+
.replace(/__SPAWN_ID__/g, () => spawnId)
|
|
305
|
+
.replace(/__PROJECT_NAME__/g, () => escapedName);
|
|
293
306
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
294
307
|
res.end(html);
|
|
295
308
|
});
|
|
296
309
|
}
|
|
297
310
|
|
|
311
|
+
// HTML-escape just enough to make a directory basename safe in <title> and
|
|
312
|
+
// <div class="title">. Project basenames effectively never contain quotes or
|
|
313
|
+
// angle brackets, but we still escape to keep the surface tight.
|
|
314
|
+
function _escapeHtml(s) {
|
|
315
|
+
return String(s == null ? "" : s)
|
|
316
|
+
.replace(/&/g, "&")
|
|
317
|
+
.replace(/</g, "<")
|
|
318
|
+
.replace(/>/g, ">")
|
|
319
|
+
.replace(/"/g, """)
|
|
320
|
+
.replace(/'/g, "'");
|
|
321
|
+
}
|
|
322
|
+
|
|
298
323
|
function tailTranscriptFile(filePath, callback) {
|
|
299
324
|
let offset = 0;
|
|
300
325
|
let buf = "";
|
|
@@ -748,13 +773,106 @@ function handleSpawnPlanUpdates(req, res, projectDir) {
|
|
|
748
773
|
req.on("close", () => { clearInterval(timer); if (dirWatcher) { try { dirWatcher.close(); } catch { /* ok */ } } });
|
|
749
774
|
}
|
|
750
775
|
|
|
751
|
-
|
|
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) {
|
|
752
856
|
const projDir = projectDir || path.resolve(eventsDir, "..", "..");
|
|
753
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
|
+
|
|
754
871
|
const server = http.createServer((req, res) => {
|
|
872
|
+
tracker.bump(); // bump on every HTTP request handler entry
|
|
755
873
|
const url = req.url.split("?")[0];
|
|
756
874
|
if (url === "/" || url === "") return handleRoot(req, res, htmlPath);
|
|
757
|
-
if (url === "/events") return
|
|
875
|
+
if (url === "/events") return handleEventsSse(req, res, eventsDir);
|
|
758
876
|
if (url === "/metrics") return handleMetrics(req, res, projDir);
|
|
759
877
|
if (url === "/ping") return handlePing(req, res, port);
|
|
760
878
|
if (url === "/stop") return handleStop(req, res, server);
|
|
@@ -763,7 +881,7 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
|
|
|
763
881
|
if (url === "/api/main-session") return handleMainSession(req, res, projDir);
|
|
764
882
|
// M44 D8 — spawn plans: GET list + SSE change stream
|
|
765
883
|
if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
|
|
766
|
-
if (url === "/api/spawn-plans/stream") return
|
|
884
|
+
if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdatesSse(req, res, projDir);
|
|
767
885
|
// M44 D9 — parallelism observability (additive, read-only)
|
|
768
886
|
if (url === "/api/parallelism") return handleParallelism(req, res, projDir);
|
|
769
887
|
if (url === "/api/parallelism/report") return handleParallelismReport(req, res, projDir);
|
|
@@ -780,14 +898,30 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
|
|
|
780
898
|
if (usageMatch) return handleTranscriptUsage(req, res, decodeURIComponent(usageMatch[1]), projDir);
|
|
781
899
|
// /transcript/:spawnId/stream — SSE tail of per-spawn ndjson
|
|
782
900
|
const streamMatch = url.match(/^\/transcript\/([^/]+)\/stream$/);
|
|
783
|
-
if (streamMatch) return
|
|
901
|
+
if (streamMatch) return handleTranscriptStreamSse(req, res, decodeURIComponent(streamMatch[1]), projDir);
|
|
784
902
|
// /transcript/:spawnId — HTML viewer page
|
|
785
903
|
const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
|
|
786
|
-
if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath);
|
|
904
|
+
if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath, projDir);
|
|
787
905
|
res.writeHead(404); res.end("Not found");
|
|
788
906
|
});
|
|
789
907
|
server.listen(port);
|
|
790
|
-
|
|
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 };
|
|
791
925
|
}
|
|
792
926
|
|
|
793
927
|
module.exports = {
|
|
@@ -825,6 +959,11 @@ module.exports = {
|
|
|
825
959
|
handleParallelism,
|
|
826
960
|
handleParallelismReport,
|
|
827
961
|
handleUnattendedStop,
|
|
962
|
+
// M49 — idle-TTL exports for tests
|
|
963
|
+
_activityTracker,
|
|
964
|
+
_wrapSseHandler,
|
|
965
|
+
_startIdleTtlTimer,
|
|
966
|
+
DEFAULT_IDLE_TTL_MS,
|
|
828
967
|
};
|
|
829
968
|
|
|
830
969
|
if (require.main === module) {
|
|
@@ -850,9 +989,25 @@ if (require.main === module) {
|
|
|
850
989
|
fs.writeFileSync(pidFile, String(child.pid));
|
|
851
990
|
process.exit(0);
|
|
852
991
|
}
|
|
853
|
-
|
|
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);
|
|
854
1002
|
process.stdout.write("GSD-T Dashboard: " + url + "\n");
|
|
855
|
-
function cleanup() {
|
|
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
|
+
}
|
|
856
1011
|
process.on("SIGTERM", cleanup);
|
|
857
1012
|
process.on("SIGINT", cleanup);
|
|
858
1013
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>
|
|
6
|
+
<title>__PROJECT_NAME__</title>
|
|
7
7
|
<style>
|
|
8
8
|
:root {
|
|
9
9
|
--bg: #0d1117;
|
|
@@ -183,6 +183,17 @@
|
|
|
183
183
|
.frame.boundary.done .label { color: var(--green); }
|
|
184
184
|
.frame.boundary.failed .label { color: var(--red); }
|
|
185
185
|
.frame.boundary .meta { color: var(--fg-dim); font-family: var(--mono); font-size: 12px; }
|
|
186
|
+
/* M48 — chat-bubble frame types emitted by the in-session conversation
|
|
187
|
+
capture hook. user_turn reuses .frame.user styling; assistant_turn
|
|
188
|
+
gets a softer right-aligned-feel bubble; session_start is a tiny badge. */
|
|
189
|
+
.frame.assistant-turn { border-left: 3px solid var(--green); padding: 6px 12px; background: var(--bg-raised); border-radius: 0 4px 4px 0; }
|
|
190
|
+
.frame.assistant-turn .prefix { color: var(--green); font-weight: 600; margin-right: 6px; font-family: var(--mono); }
|
|
191
|
+
.frame.assistant-turn .body { white-space: pre-wrap; word-break: break-word; }
|
|
192
|
+
.frame.user-turn .body { white-space: pre-wrap; word-break: break-word; }
|
|
193
|
+
.frame.session-start { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0; padding: 3px 10px; background: rgba(88,166,255,0.10); border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 11px; color: var(--fg-dim); }
|
|
194
|
+
.frame.session-start .badge { color: var(--accent); font-weight: 600; }
|
|
195
|
+
.frame.tool-call-line { font-family: var(--mono); font-size: 12px; color: var(--accent-warm); padding: 2px 0; }
|
|
196
|
+
.frame.truncated-tag { color: var(--fg-xdim); font-style: italic; font-size: 11px; margin-left: 6px; }
|
|
186
197
|
|
|
187
198
|
.jump-to-live { position: fixed; bottom: 24px; right: 24px; background: var(--accent); color: #fff; border: none; padding: 10px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; font-family: var(--sans); font-weight: 600; box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: none; z-index: 20; }
|
|
188
199
|
.jump-to-live.visible { display: block; }
|
|
@@ -202,7 +213,7 @@
|
|
|
202
213
|
</head>
|
|
203
214
|
<body data-spawn-id="__SPAWN_ID__">
|
|
204
215
|
<header>
|
|
205
|
-
<div class="title">
|
|
216
|
+
<div class="title">__PROJECT_NAME__</div>
|
|
206
217
|
<div class="spawn-id" id="hdr-spawn-id"></div>
|
|
207
218
|
<label class="auto-follow" title="When ON, snap focus to the most recent live spawn as soon as it appears."><input type="checkbox" id="auto-follow" checked> auto-follow latest</label>
|
|
208
219
|
<div class="status" id="hdr-status"><span class="dot"></span><span class="label">connecting…</span></div>
|
|
@@ -440,17 +451,26 @@
|
|
|
440
451
|
}
|
|
441
452
|
window.__gsdtFmtTs = fmtTs;
|
|
442
453
|
|
|
443
|
-
// Per-frame
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
454
|
+
// Per-frame timestamp. We prefer the producer-side `frame.ts`
|
|
455
|
+
// (ISO string written by the conversation-capture hook) when it
|
|
456
|
+
// parses cleanly, and only fall back to SSE arrival time when no
|
|
457
|
+
// `ts` field is present. Without this, an initial-load replay of
|
|
458
|
+
// N frames all arrives within one millisecond and every row shows
|
|
459
|
+
// the same HH:MM:SS — useless for spotting stuck or stale streams.
|
|
448
460
|
//
|
|
449
461
|
// M47 D1: appendFrame writes to a module-scope `renderTarget`. The
|
|
450
462
|
// renderFrame entry point swaps it per-call so the same code paths
|
|
451
463
|
// render into either the top pane (#main-stream — main-conversation
|
|
452
464
|
// SSE) or the bottom pane (#stream inside #spawn-stream — selected
|
|
453
465
|
// spawn SSE).
|
|
466
|
+
function frameTs(frame, fallback) {
|
|
467
|
+
if (frame && typeof frame.ts === 'string') {
|
|
468
|
+
const d = new Date(frame.ts);
|
|
469
|
+
if (!isNaN(d.getTime())) return d;
|
|
470
|
+
}
|
|
471
|
+
return fallback instanceof Date ? fallback : new Date();
|
|
472
|
+
}
|
|
473
|
+
window.__gsdtFrameTs = frameTs;
|
|
454
474
|
let renderTarget = stream;
|
|
455
475
|
function appendFrame(el, arrivedAt) {
|
|
456
476
|
const ts = document.createElement('span');
|
|
@@ -604,6 +624,74 @@
|
|
|
604
624
|
appendFrame(div, arrivedAt);
|
|
605
625
|
}
|
|
606
626
|
|
|
627
|
+
// M48 — chat-bubble renderers for the in-session conversation NDJSON.
|
|
628
|
+
// The conversation-capture hook emits frames of type
|
|
629
|
+
// `user_turn`, `assistant_turn`, `session_start`, and `tool_use`.
|
|
630
|
+
// Without explicit handling these fall through to renderRaw and the
|
|
631
|
+
// user sees a JSON.stringify dump per row.
|
|
632
|
+
function _appendTruncatedTag(div, frame) {
|
|
633
|
+
if (!frame || !frame.truncated) return;
|
|
634
|
+
const tag = document.createElement('span');
|
|
635
|
+
tag.className = 'truncated-tag';
|
|
636
|
+
tag.textContent = '(truncated)';
|
|
637
|
+
div.appendChild(tag);
|
|
638
|
+
}
|
|
639
|
+
function renderUserTurn(frame, arrivedAt) {
|
|
640
|
+
const div = document.createElement('div');
|
|
641
|
+
div.className = 'frame user user-turn';
|
|
642
|
+
const p = document.createElement('span');
|
|
643
|
+
p.className = 'prefix';
|
|
644
|
+
p.textContent = '>';
|
|
645
|
+
div.appendChild(p);
|
|
646
|
+
const body = document.createElement('span');
|
|
647
|
+
body.className = 'body';
|
|
648
|
+
body.textContent = (frame && typeof frame.content === 'string') ? frame.content : '';
|
|
649
|
+
div.appendChild(body);
|
|
650
|
+
_appendTruncatedTag(div, frame);
|
|
651
|
+
appendFrame(div, arrivedAt);
|
|
652
|
+
}
|
|
653
|
+
function renderAssistantTurn(frame, arrivedAt) {
|
|
654
|
+
const div = document.createElement('div');
|
|
655
|
+
div.className = 'frame assistant-turn';
|
|
656
|
+
const p = document.createElement('span');
|
|
657
|
+
p.className = 'prefix';
|
|
658
|
+
p.textContent = '⏺';
|
|
659
|
+
div.appendChild(p);
|
|
660
|
+
const body = document.createElement('span');
|
|
661
|
+
body.className = 'body';
|
|
662
|
+
body.textContent = (frame && typeof frame.content === 'string') ? frame.content : '';
|
|
663
|
+
div.appendChild(body);
|
|
664
|
+
_appendTruncatedTag(div, frame);
|
|
665
|
+
appendFrame(div, arrivedAt);
|
|
666
|
+
}
|
|
667
|
+
function renderSessionStart(frame, arrivedAt) {
|
|
668
|
+
const div = document.createElement('div');
|
|
669
|
+
div.className = 'frame session-start';
|
|
670
|
+
const badge = document.createElement('span');
|
|
671
|
+
badge.className = 'badge';
|
|
672
|
+
badge.textContent = '◆ session';
|
|
673
|
+
div.appendChild(badge);
|
|
674
|
+
if (frame && typeof frame.session_id === 'string') {
|
|
675
|
+
const sid = document.createElement('span');
|
|
676
|
+
sid.textContent = frame.session_id.slice(0, 8);
|
|
677
|
+
div.appendChild(sid);
|
|
678
|
+
}
|
|
679
|
+
appendFrame(div, arrivedAt);
|
|
680
|
+
}
|
|
681
|
+
function renderToolUseLine(frame, arrivedAt) {
|
|
682
|
+
const div = document.createElement('div');
|
|
683
|
+
div.className = 'frame tool-call-line';
|
|
684
|
+
const span = document.createElement('span');
|
|
685
|
+
const name = (frame && typeof frame.name === 'string') ? frame.name : 'tool';
|
|
686
|
+
span.textContent = '⎿ ' + name + '()';
|
|
687
|
+
div.appendChild(span);
|
|
688
|
+
appendFrame(div, arrivedAt);
|
|
689
|
+
}
|
|
690
|
+
window.__gsdtRenderUserTurn = renderUserTurn;
|
|
691
|
+
window.__gsdtRenderAssistantTurn = renderAssistantTurn;
|
|
692
|
+
window.__gsdtRenderSessionStart = renderSessionStart;
|
|
693
|
+
window.__gsdtRenderToolUseLine = renderToolUseLine;
|
|
694
|
+
|
|
607
695
|
function renderCompactMarker(frame, arrivedAt) {
|
|
608
696
|
const div = document.createElement('div');
|
|
609
697
|
div.className = 'frame compact-marker';
|
|
@@ -646,12 +734,23 @@
|
|
|
646
734
|
}
|
|
647
735
|
function renderFrameInner(frame, arrivedAt) {
|
|
648
736
|
if (!frame || typeof frame !== 'object') return;
|
|
649
|
-
|
|
737
|
+
// Defense in depth: if a caller passes a non-Date `arrivedAt`, still
|
|
738
|
+
// try to derive a real timestamp from frame.ts before falling back
|
|
739
|
+
// to "now". Without this guard, every renderFrame() call from a
|
|
740
|
+
// non-SSE path (e.g. tests, manual dispatch) collapses to one Date.
|
|
741
|
+
const ts = (arrivedAt instanceof Date && !isNaN(arrivedAt.getTime()))
|
|
742
|
+
? arrivedAt
|
|
743
|
+
: frameTs(frame, new Date());
|
|
650
744
|
const type = frame.type;
|
|
651
745
|
if (type === 'compact_marker') { renderCompactMarker(frame, ts); return; }
|
|
652
746
|
if (type === 'system') { renderSystem(frame, ts); return; }
|
|
653
747
|
if (type === 'task-boundary') { renderBoundary(frame, ts); return; }
|
|
654
748
|
if (type === 'raw') { renderRaw(frame.line || '', ts); return; }
|
|
749
|
+
// M48 — in-session conversation-capture frame types.
|
|
750
|
+
if (type === 'session_start') { renderSessionStart(frame, ts); return; }
|
|
751
|
+
if (type === 'user_turn') { renderUserTurn(frame, ts); return; }
|
|
752
|
+
if (type === 'assistant_turn') { renderAssistantTurn(frame, ts); return; }
|
|
753
|
+
if (type === 'tool_use') { renderToolUseLine(frame, ts); return; }
|
|
655
754
|
if (type === 'assistant' && frame.message && Array.isArray(frame.message.content)) {
|
|
656
755
|
for (const b of frame.message.content) {
|
|
657
756
|
if (b.type === 'text') renderAssistantText(b.text || '', ts);
|
|
@@ -771,6 +870,14 @@
|
|
|
771
870
|
});
|
|
772
871
|
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
773
872
|
el.addEventListener('click', () => {
|
|
873
|
+
// M48 — symmetric with renderRailEntry: in-session entries
|
|
874
|
+
// belong to the TOP pane only. Without this guard the legacy
|
|
875
|
+
// renderTree path (called for the `live` bucket when ≥2
|
|
876
|
+
// in-session NDJSONs exist) would mutate location.hash to an
|
|
877
|
+
// in-session-* value, polluting the URL and the rail's active
|
|
878
|
+
// highlight even though the hashchange handler now blocks
|
|
879
|
+
// bottom-pane SSE pinning.
|
|
880
|
+
if (isInSession(node)) return;
|
|
774
881
|
if (node.spawnId === currentId) return;
|
|
775
882
|
location.hash = node.spawnId;
|
|
776
883
|
});
|
|
@@ -822,9 +929,14 @@
|
|
|
822
929
|
function maybeAutoFollow(spawns) {
|
|
823
930
|
if (!autoFollowEl.checked) return;
|
|
824
931
|
const currentId = (location.hash || '').slice(1) || spawnId;
|
|
932
|
+
// M48 — auto-follow drives the BOTTOM pane (selected-spawn). The
|
|
933
|
+
// top pane is owned by /api/main-session and shows in-session-*
|
|
934
|
+
// entries automatically. Excluding them here prevents the auto-
|
|
935
|
+
// follow loop from also pinning them into the bottom pane.
|
|
825
936
|
// Most recent running spawn by startedAt (descending).
|
|
826
937
|
const running = spawns
|
|
827
938
|
.filter((s) => s.status === 'running')
|
|
939
|
+
.filter((s) => !(typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0))
|
|
828
940
|
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
|
|
829
941
|
if (!running.length) return;
|
|
830
942
|
const latest = running[0];
|
|
@@ -894,6 +1006,12 @@
|
|
|
894
1006
|
});
|
|
895
1007
|
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
896
1008
|
el.addEventListener('click', () => {
|
|
1009
|
+
// M48 — in-session conversation entries belong in the TOP pane only.
|
|
1010
|
+
// The top pane is wired to /api/main-session and streams the
|
|
1011
|
+
// current orchestrator session. Routing them through location.hash
|
|
1012
|
+
// would also load them into the bottom pane (the SELECTED-SPAWN
|
|
1013
|
+
// pane), making both panes show identical content.
|
|
1014
|
+
if (isInSession) return;
|
|
897
1015
|
if (node.spawnId === currentId) return;
|
|
898
1016
|
_ssSet(SS_KEY_SELECTED, node.spawnId);
|
|
899
1017
|
location.hash = node.spawnId;
|
|
@@ -1102,14 +1220,15 @@
|
|
|
1102
1220
|
src.onerror = () => { setStatus('error', 'disconnected'); setToolCostLive(false); };
|
|
1103
1221
|
src.onmessage = (ev) => {
|
|
1104
1222
|
if (!ev.data) return;
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1223
|
+
// Arrival time is the FALLBACK; we prefer the producer-side
|
|
1224
|
+
// frame.ts so a 200-frame initial replay shows actual event
|
|
1225
|
+
// times (spread out over minutes), not the same millisecond.
|
|
1108
1226
|
const arrivedAt = new Date();
|
|
1109
1227
|
try {
|
|
1110
1228
|
const frame = JSON.parse(ev.data);
|
|
1229
|
+
const renderAt = frameTs(frame, arrivedAt);
|
|
1111
1230
|
// Bottom pane = default renderTarget = #stream.
|
|
1112
|
-
renderFrame(frame,
|
|
1231
|
+
renderFrame(frame, renderAt);
|
|
1113
1232
|
// M43 D6 — refresh tool-cost on turn-complete frames (debounced).
|
|
1114
1233
|
// Various producers emit different turn-complete markers; accept
|
|
1115
1234
|
// any of them.
|
|
@@ -1146,7 +1265,8 @@
|
|
|
1146
1265
|
const arrivedAt = new Date();
|
|
1147
1266
|
try {
|
|
1148
1267
|
const frame = JSON.parse(ev.data);
|
|
1149
|
-
|
|
1268
|
+
const renderAt = frameTs(frame, arrivedAt);
|
|
1269
|
+
renderFrame(frame, renderAt, mainStreamEl);
|
|
1150
1270
|
} catch {
|
|
1151
1271
|
const prev = renderTarget; renderTarget = mainStreamEl;
|
|
1152
1272
|
try { renderRaw(ev.data, arrivedAt); } finally { renderTarget = prev; }
|
|
@@ -1175,6 +1295,8 @@
|
|
|
1175
1295
|
|
|
1176
1296
|
window.addEventListener('hashchange', () => {
|
|
1177
1297
|
const id = (location.hash || '').slice(1);
|
|
1298
|
+
// M48 — keep in-session-* ids out of the bottom pane (top pane only).
|
|
1299
|
+
if (id && id.indexOf('in-session-') === 0) { return; }
|
|
1178
1300
|
if (id) { connect(id); pollSpawns(); }
|
|
1179
1301
|
});
|
|
1180
1302
|
|
|
@@ -1182,12 +1304,19 @@
|
|
|
1182
1304
|
// 1. data-spawn-id non-empty → connect that (bookmark flow)
|
|
1183
1305
|
// 2. else sessionStorage.selectedSpawnId → connect that
|
|
1184
1306
|
// 3. else show empty state
|
|
1307
|
+
// M48 — never seed the bottom pane with an in-session-* id; the top
|
|
1308
|
+
// pane already owns the main session, and showing it in both panes
|
|
1309
|
+
// is one of the regressions Bug 4 fixes.
|
|
1185
1310
|
let initialBottomId = '';
|
|
1186
1311
|
if (spawnId) {
|
|
1187
1312
|
initialBottomId = spawnId;
|
|
1188
1313
|
} else {
|
|
1189
1314
|
initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
|
|
1190
1315
|
}
|
|
1316
|
+
if (typeof initialBottomId === 'string' && initialBottomId.indexOf('in-session-') === 0) {
|
|
1317
|
+
initialBottomId = '';
|
|
1318
|
+
_ssSet(SS_KEY_SELECTED, '');
|
|
1319
|
+
}
|
|
1191
1320
|
if (initialBottomId && !location.hash) location.hash = initialBottomId;
|
|
1192
1321
|
connect(initialBottomId);
|
|
1193
1322
|
|