@tekyzinc/gsd-t 3.20.13 → 3.21.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,8 @@ A methodology for reliable, parallelizable development using Claude Code with op
15
15
  **Stack Rules Engine** — auto-detects project tech stack (React, TypeScript, Node API, Python, Go, Rust) from manifest files and injects mandatory best-practice rules into subagent prompts at execute-time. Universal security rules always apply; stack-specific rules layer on top. Includes **design-to-code** rules for pixel-perfect frontend implementation from Figma, screenshots, or design images — with Figma MCP integration, design token extraction, stack capability evaluation, and mandatory visual verification: every screen is rendered in a real browser, screenshotted at mobile/tablet/desktop, and compared pixel-by-pixel against the Figma design. Auto-bootstraps during partition when design references are detected. Extensible: drop a `.md` file in `templates/stacks/` to add a new stack.
16
16
  **External Task Orchestrator + Streaming Watcher UI (M40, v3.14.10)** — JS orchestrator drives `claude -p` one task per spawn: short-lived, fresh context, architecturally compaction-free. Benchmarks 0.72× wall-clock vs in-session on 20-task/3-wave workloads. Paired with a zero-Claude-cost local streaming UI at `127.0.0.1:7842` that renders all workers' stream-json output as a continuous claude.ai-style feed — task/wave banners, duration + usage chips, token corner bar, localStorage filters, replay via `WS /feed?from=N`. Recovery: `--resume` reconciles interrupted runs using commit + progress.md evidence; ambiguous tasks (commit without progress entry) are flagged for operator triage, never silently claimed done. CLI: `gsd-t orchestrate`, `gsd-t benchmark-orchestrator`, `gsd-t stream-feed`. Contracts: `stream-json-sink-contract.md` v1.1.0, `wave-join-contract.md`, `completion-signal-contract.md`, `metrics-schema-contract.md`.
17
17
  **Always-Headless Spawn (M43 D4, v3.16.x+) — Channel Separation** — every GSD-T command spawns detached, unconditionally. No `--watch`, no `--in-session`, no `--headless` opt-in, no context-meter threshold that reroutes. The dialog channel is reserved for human↔Claude conversation; every workflow turn is a detached headless child. Interactive session shows a launch banner + live-transcript URL + event-stream path, then exits — results surface via the read-back banner on the user's next message. Detached workers emit JSONL events to `.gsd-t/events/YYYY-MM-DD.jsonl` at every phase boundary — shared by dashboard and (historically) the watch command. The only in-session surface is the `/gsd` router (for dialog-only exploratory turns). See `.gsd-t/contracts/headless-default-contract.md` v2.0.0 and `unattended-event-stream-contract.md` v1.0.0.
18
- **Live Transcript as Primary Surface (M43 D6, v3.16.13)** — every detached spawn prints a one-line banner (`▶ Live transcript: http://127.0.0.1:{port}/transcript/{id}`) pointing at a browser viewer that SSE-streams the child's stdout and renders a collapsible "Tool Cost" sidebar panel showing per-tool attributed tokens and cost (sourced from `/transcript/:id/tool-cost`, which proxies to the M43 D2 tool-attribution library). The dashboard server auto-starts (`scripts/gsd-t-dashboard-autostart.cjs`) idempotently on each spawn — a port probe backs off when a server is already running, otherwise a fork-detach writes `.gsd-t/.dashboard.pid`. Port is project-scoped via `projectScopedDefaultPort(projectDir)` so multi-project workflows don't clobber each other. Contract: `dashboard-server-contract.md` v1.2.0.
18
+ **Live Transcript as Primary Surface (M43 D6, v3.16.13 — extended in M47, v3.21.10)** — every detached spawn prints a one-line banner (`▶ Live transcript: http://127.0.0.1:{port}/transcript/{id}`) pointing at a browser viewer that SSE-streams the child's stdout and renders a collapsible "Tool Cost" sidebar panel showing per-tool attributed tokens and cost (sourced from `/transcript/:id/tool-cost`, which proxies to the M43 D2 tool-attribution library). The dashboard server auto-starts (`scripts/gsd-t-dashboard-autostart.cjs`) idempotently on each spawn — a port probe backs off when a server is already running, otherwise a fork-detach writes `.gsd-t/.dashboard.pid`. Port is project-scoped via `projectScopedDefaultPort(projectDir)` so multi-project workflows don't clobber each other.
19
+ **Focused Visualizer Redesign (M47, v3.21.10)** — `/transcripts` opens directly to a dual-pane focused view: the **top pane** auto-streams the orchestrator's main in-session conversation (zero clicks — fetched via new `GET /api/main-session`), and the **bottom pane** streams whichever spawn the user clicks. A keyboard- and mouse-resizable splitter sits between them, with position persisted in `sessionStorage` (along with selection, completed-section toggle, and right-rail collapsed state). The left rail splits into three sections — `★ Main Session`, `Live Spawns`, and `Completed` (last 100 spawns, newest first, status-badged, collapsible). When a spawn transitions running → completed it moves rail sections live without a full reload, and stays selected if focused. The right rail (Spawn Plan / Parallelism / Tool Cost) is preserved under a new collapsible toggle. Bookmarks to `/transcript/:spawnId` continue to land that spawn pre-selected in the bottom pane. Contract: `dashboard-server-contract.md` v1.3.0 (additive — `status: 'active' \| 'completed'` field on in-session entries, derived from a 30s mtime window; `/api/main-session` endpoint with path-traversal guard + `Cache-Control: no-store`).
19
20
  - **Surgical model selection** — `bin/model-selector.js` assigns haiku/sonnet/opus per phase via a declarative rules table; `/advisor` escalation path with convention-based fallback.
20
21
  - **Per-spawn token telemetry** — `.gsd-t/token-metrics.jsonl` records one 18-field row per Task subagent spawn.
21
22
  **Context Meter (M34/M38/M43 D4) — Observational Only** — PostToolUse hook writes `.gsd-t/.context-meter-state.json` via local token estimation. Under M43 D4 (channel-separation inversion, `headless-default-contract.md` v2.0.0) the meter is OBSERVATIONAL ONLY: the pct is recorded into the token-log `Ctx%` column on the next spawn, but no threshold gates any routing decision — every command spawns detached regardless. The `context-meter-contract.md` single-band model is preserved for the value itself; it no longer drives in-flight pauses or spawn-time rerouting.
@@ -55,52 +55,49 @@ If `.gsd-t/graph/index.json` exists, the dashboard can render entity-relationshi
55
55
 
56
56
  If `$ARGUMENTS` contains "stop", skip to **Step 5**. Otherwise continue to Step 3.
57
57
 
58
- ## Step 3: Resolve Port + Check if Server is Already Running
59
-
60
- The dashboard server binds to a **per-project hashed port** in `[7433, 7532]` (see `projectScopedDefaultPort` in `gsd-t-dashboard-server.js`). Resolve it once and reuse:
58
+ ## Step 3: Check if Server is Already Running
61
59
 
60
+ Run via Bash:
62
61
  ```bash
63
- PORT=$(node -e "console.log(require('$HOME/.claude/scripts/gsd-t-dashboard-server.js').resolvePort({ projectDir: process.cwd() }))")
64
62
  if [ -f .gsd-t/dashboard.pid ]; then
65
63
  PID=$(cat .gsd-t/dashboard.pid)
66
- curl -sf http://localhost:$PORT/ping 2>/dev/null | grep -q '"ok"' && echo "SERVER_RUNNING=true" || echo "SERVER_RUNNING=false"
64
+ curl -sf http://localhost:7433/ping 2>/dev/null | grep -q '"ok"' && echo "SERVER_RUNNING=true" || echo "SERVER_RUNNING=false"
67
65
  else
68
66
  echo "SERVER_RUNNING=false"
69
67
  fi
70
68
  ```
71
69
 
72
- If output is `SERVER_RUNNING=true`, skip Step 3a and go directly to Step 4. Carry `$PORT` forward to subsequent steps.
70
+ If output is `SERVER_RUNNING=true`, skip Step 3a and go directly to Step 4.
73
71
 
74
72
  ### Step 3a: Start Server if Not Running
75
73
 
76
- Run via Bash (reuses `$PORT` from Step 3):
74
+ Run via Bash:
77
75
  ```bash
78
76
  node ~/.claude/scripts/gsd-t-dashboard-server.js --detach || true
79
77
  for i in 1 2 3 4 5; do
80
- curl -sf http://localhost:$PORT/ping 2>/dev/null | grep -q '"ok"' && break
78
+ curl -sf http://localhost:7433/ping 2>/dev/null | grep -q '"ok"' && break
81
79
  sleep 1
82
80
  done
83
81
  ```
84
82
 
85
83
  ## Step 4: Open Browser
86
84
 
87
- Run via Bash (reuses `$PORT` from Step 3):
85
+ Run via Bash:
88
86
  ```bash
89
- URL="http://localhost:$PORT"
90
- node -e "const {execFileSync}=require('child_process'); const url=process.argv[1]; try { if(process.platform==='win32'){execFileSync('cmd',['/c','start','',url],{stdio:'ignore'})}else{execFileSync(process.platform==='darwin'?'open':'xdg-open',[url],{stdio:'ignore'})} } catch(e) { console.error('Could not open browser:', e.message); }" "$URL" || true
87
+ node -e "const {execFileSync}=require('child_process'); const url='http://localhost:7433'; try { if(process.platform==='win32'){execFileSync('cmd',['/c','start','',url],{stdio:'ignore'})}else{execFileSync(process.platform==='darwin'?'open':'xdg-open',[url],{stdio:'ignore'})} } catch(e) { console.error('Could not open browser:', e.message); }" || true
91
88
  ```
92
89
 
93
- Report to the user: "Dashboard is running at $URL — browser opened."
90
+ Report to the user: "Dashboard is running at http://localhost:7433 — browser opened."
94
91
 
95
92
  ## Step 5: Stop Handler
96
93
 
97
- Run only when `$ARGUMENTS` contains "stop". Resolves the same hashed port used at start:
94
+ Run only when `$ARGUMENTS` contains "stop".
98
95
 
96
+ Run via Bash:
99
97
  ```bash
100
- PORT=$(node -e "console.log(require('$HOME/.claude/scripts/gsd-t-dashboard-server.js').resolvePort({ projectDir: process.cwd() }))")
101
98
  if [ -f .gsd-t/dashboard.pid ]; then
102
99
  PID=$(cat .gsd-t/dashboard.pid)
103
- curl -sf http://localhost:$PORT/stop 2>/dev/null || kill $PID 2>/dev/null || true
100
+ curl -sf http://localhost:7433/stop 2>/dev/null || kill $PID 2>/dev/null || true
104
101
  rm -f .gsd-t/dashboard.pid
105
102
  echo "Dashboard server stopped"
106
103
  else
@@ -75,9 +75,20 @@ The framework has no runtime — it is consumed entirely by Claude Code's slash
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
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
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.
78
- - **Contract**: `.gsd-t/contracts/dashboard-server-contract.md` v1.2.0 — new §HTTP Endpoints entries, §Banner Format, §Autostart sections.
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
 
81
+ ### Focused Visualizer Redesign (M47, v3.21.10)
82
+ - **Server endpoint** (`scripts/gsd-t-dashboard-server.js::handleMainSession`): new `GET /api/main-session` returns `{ filename, sessionId, mtimeMs }` for the most-recently-modified `in-session-*.ndjson` file in `transcripts/`, or `{ null, null, null }` when none exist. Path-traversal guarded by reusing `isValidSpawnId` on each candidate filename. Sets `Cache-Control: no-store` (the viewer hits this on every page load, never cached). Wired alongside the existing `/transcripts` route.
83
+ - **Status field derivation** (`listInSessionTranscripts`): each in-session entry now carries `status: 'active' | 'completed'` derived from a 30-second mtime window (`Date.now() - stat.mtimeMs < 30_000` → `active`). Replaces the prior hardcoded `"active"` literal. Propagates through `handleTranscriptsList` to the merged `/transcripts` JSON. Future `success | failed | killed` taxonomy is a one-file change here — the viewer code branches on the field, neutral fallback for unrecognized values.
84
+ - **Dual-pane viewer** (`scripts/gsd-t-transcript.html`): `<main>` becomes a vertical flex container holding `<section id="main-stream">` (top — auto-streams the orchestrator's main in-session conversation via `/api/main-session` + `/transcript/in-session-{sid}/stream`) + `<div class="splitter" role="separator">` (keyboard- and mouse-resizable, ArrowUp/Down ±5%, Home/End snap 20/80, position persisted in `sessionStorage` under `gsd-t.viewer.splitterPct`) + `<section id="spawn-stream">` (bottom — the user-selected spawn; legacy `<div id="stream">` lives inside, preserving all existing renderer code paths). Shared frame renderer threads an optional target via a module-scope `renderTarget` swap inside `renderFrame(frame, arrivedAt, target)`.
85
+ - **3-section left rail**: `★ Main Session` / `Live Spawns` / `Completed` (last 100 newest first, status-badged, collapsible). Bucketing in `bucketAndRender(spawns)` consumes D2's `status` field — D1 never computes status itself. Live → Completed transition is reactive (DOM diff via `data-spawn-id` lookups); selected spawn stays selected if it transitions sections.
86
+ - **sessionStorage persistence** (4 keys): `gsd-t.viewer.selectedSpawnId`, `gsd-t.viewer.splitterPct`, `gsd-t.viewer.completedExpanded`, `gsd-t.viewer.rightRailCollapsed`. `_ssGet`/`_ssSet` wrappers fail-soft so the IIFE init survives DOM-shim test sandboxes that don't provide a `sessionStorage` shim.
87
+ - **Right-rail collapse**: `<aside class="spawn-panel" data-collapsed>` toggle button flips `data-right-rail-collapsed` on `<body>`, which collapses the third grid track via `--right-rail-w: 0px` rule.
88
+ - **Contract**: `.gsd-t/contracts/dashboard-server-contract.md` **v1.3.0** — additive §`GET /api/main-session` and §`In-Session Entry Status Field` sections; `handleMainSession` added to Module Exports.
89
+ - **Integration contract**: `.gsd-t/contracts/m47-integration-points.md` — D1↔D2 wiring, dependency graph, single-wave parallel execution plan.
90
+ - **Tests**: `test/dashboard-server.test.js` — 13 new assertions across 3 describe blocks (4 status-field tests, 5 `/api/main-session` tests, 4 HTML structural-marker / sessionStorage-key / CSS toggle tests). Suite total 2058 / 2060 (M47 +13/+13; 2 pre-existing flakes preserved). Existing 5 viewer-route tests updated for new structure (regex relaxation: `grid-template-columns` allows `var(--right-rail-w)`; `<main id="stream">` allows `id="stream"` regardless of element).
91
+
81
92
  ### Headless Mode (M23 — complete)
82
93
  - **doHeadless(args)**: Dispatch function for the `headless` CLI subcommand.
83
94
  - **doHeadlessExec(command, cmdArgs, flags)**: Wraps `claude -p "/gsd-t-{command}"` via `execFileSync`. Verifies claude CLI availability, enforces timeout, writes log file if `--log` requested. Returns structured JSON if `--json` flag set. (M36 Phase 0: prompt form is `/gsd-t-X`, NOT `/gsd-t-X` — non-interactive mode rejects the `/` namespace prefix.)
@@ -684,3 +684,19 @@ Acceptance:
684
684
  - Tests in `test/m46-d1-iter-parallel.test.js` pass (serial fallback, parallel batch, mode-safety gate, error isolation, state reconciliation).
685
685
  - Proof speedup ≥ **3.0×** recorded in `.gsd-t/metrics/m46-iter-proof.json` — a synthetic `batchSize = 4` measurement of the `_runIterParallel` driver, not the production main loop (which remains serial until backlog #24 lands).
686
686
  - `.gsd-t/contracts/iter-parallel-contract.md` v1.0.0 present as the locked source of truth.
687
+
688
+ ## M47 Focused Visualizer Redesign (executed — 2026-05-06)
689
+
690
+ | REQ-ID | Requirement Summary | Domain | Task(s) | Status |
691
+ |--------|---------------------|--------|---------|--------|
692
+ | REQ-M47-D1-01 | Default `/transcripts` landing shows the main in-session conversation streaming in the top pane within 3s, zero clicks (success criterion 1). | m47-d1-viewer-redesign | T2, T4 | done |
693
+ | REQ-M47-D1-02 | Click any rail entry → loads it into the bottom pane within 1s; `gsd-t.viewer.selectedSpawnId` sessionStorage key persists selection across reload (success criterion 2). | m47-d1-viewer-redesign | T3, T5 | done |
694
+ | REQ-M47-D1-03 | Reactive Live → Completed transition without full reload; if the user is currently focused on the transitioning spawn, focus stays (no auto-revert) (success criterion 3). | m47-d1-viewer-redesign | T5 | done |
695
+ | REQ-M47-D1-04 | Completed section displays at least 100 historical spawns capped, sorted newest-first, with status badges; toggle collapses/expands; `gsd-t.viewer.completedExpanded` persists state (success criterion 4). | m47-d1-viewer-redesign | T3, T5 | done |
696
+ | REQ-M47-D1-05 | Splitter is mouse-draggable + keyboard-accessible (ArrowUp/Down ±5%, Home/End snap to 20/80); position persists in `gsd-t.viewer.splitterPct` sessionStorage. | m47-d1-viewer-redesign | T2, T6 | done |
697
+ | REQ-M47-D1-06 | Right rail (Spawn Plan / Parallelism / Tool Cost) preserved under collapsible toggle; `gsd-t.viewer.rightRailCollapsed` sessionStorage key. | m47-d1-viewer-redesign | T2 | done |
698
+ | REQ-M47-D1-07 | Back-compat: `data-spawn-id="__SPAWN_ID__"` server-side substitution preserved; bookmarks to `/transcript/:spawnId` land with that spawn pre-selected in the bottom pane. Existing 7 viewer-route/HTML tests stay green. | m47-d1-viewer-redesign | T2, T7 | done |
699
+ | REQ-M47-D2-01 | `listInSessionTranscripts` (and the merged `handleTranscriptsList` payload) returns each in-session entry with `status: 'active' \| 'completed'` derived from a 30s mtime window. | m47-d2-server-helpers | T1, T4 | done |
700
+ | REQ-M47-D2-02 | New `GET /api/main-session` endpoint returns `{ filename, sessionId, mtimeMs }` for the most-recently-modified `in-session-*.ndjson` (or `{ null, null, null }` when none exist); path-traversal-guarded; no caching. | m47-d2-server-helpers | T2, T5 | done |
701
+ | REQ-M47-D2-03 | `dashboard-server-contract.md` bumped to v1.3.0 documenting the additive `status` field semantics + `/api/main-session` schema; module exports updated. | m47-d2-server-helpers | T3 | done |
702
+ | REQ-M47-D2-04 | Test suite passes baseline 2045/2047 + new M47 tests (D1 + D2 net add); no NEW regressions in the 7 existing viewer-route/HTML tests (success criterion 5). | m47-d1-viewer-redesign + m47-d2-server-helpers | D1 T7, D2 T4–T5 | done |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.20.13",
3
+ "version": "3.21.10",
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",
@@ -184,10 +184,18 @@ function isValidSpawnId(id) {
184
184
  // NDJSONs are on disk. Synthesizes a spawn-shaped entry per in-session file
185
185
  // using filesystem timestamps; the viewer's `in-session-` prefix detection
186
186
  // then labels it as `💬 conversation`.
187
+ //
188
+ // M47 D2: `status` is derived per-entry from mtime. A file modified within the
189
+ // last 30 seconds is `active` (the conversation hook is still appending);
190
+ // otherwise `completed`. The viewer (M47 D1) buckets entries by this field
191
+ // into Live vs Completed rail sections. The `success | failed | killed`
192
+ // taxonomy is intentionally out of scope here — see contract v1.3.0.
193
+ const IN_SESSION_ACTIVE_WINDOW_MS = 30_000;
187
194
  function listInSessionTranscripts(projectDir) {
188
195
  const dir = transcriptsDir(projectDir);
189
196
  let files;
190
197
  try { files = fs.readdirSync(dir); } catch { return []; }
198
+ const now = Date.now();
191
199
  const out = [];
192
200
  for (const f of files) {
193
201
  if (!f.startsWith("in-session-") || !f.endsWith(".ndjson")) continue;
@@ -195,18 +203,48 @@ function listInSessionTranscripts(projectDir) {
195
203
  if (!isValidSpawnId(spawnId)) continue;
196
204
  let stat;
197
205
  try { stat = fs.statSync(path.join(dir, f)); } catch { continue; }
206
+ const status = (now - stat.mtimeMs) < IN_SESSION_ACTIVE_WINDOW_MS ? "active" : "completed";
198
207
  out.push({
199
208
  spawnId,
200
209
  command: "in-session conversation",
201
210
  startedAt: stat.birthtime ? stat.birthtime.toISOString() : stat.mtime.toISOString(),
202
211
  lastUpdatedAt: stat.mtime.toISOString(),
203
- status: "active", // best-effort; the viewer doesn't currently use this field
212
+ status,
204
213
  kind: "in-session",
205
214
  });
206
215
  }
207
216
  return out;
208
217
  }
209
218
 
219
+ // M47 D2: Resolve the most-recently-modified `in-session-*.ndjson` file. Used
220
+ // by the viewer's top-pane default load (zero-click main-conversation stream).
221
+ // Reuses `isValidSpawnId` for path-traversal safety.
222
+ function handleMainSession(req, res, projectDir) {
223
+ const dir = transcriptsDir(projectDir);
224
+ let files;
225
+ try { files = fs.readdirSync(dir); } catch { files = []; }
226
+ let best = null;
227
+ for (const f of files) {
228
+ if (!f.startsWith("in-session-") || !f.endsWith(".ndjson")) continue;
229
+ const spawnId = f.slice(0, -".ndjson".length);
230
+ if (!isValidSpawnId(spawnId)) continue;
231
+ let stat;
232
+ try { stat = fs.statSync(path.join(dir, f)); } catch { continue; }
233
+ if (!best || stat.mtimeMs > best.mtimeMs) {
234
+ best = { filename: f, sessionId: f.slice("in-session-".length, -".ndjson".length), mtimeMs: stat.mtimeMs };
235
+ }
236
+ }
237
+ res.writeHead(200, {
238
+ "Content-Type": "application/json",
239
+ "Cache-Control": "no-store",
240
+ });
241
+ if (!best) {
242
+ res.end(JSON.stringify({ filename: null, sessionId: null, mtimeMs: null }));
243
+ return;
244
+ }
245
+ res.end(JSON.stringify(best));
246
+ }
247
+
210
248
  function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
211
249
  const idx = readTranscriptsIndex(projectDir);
212
250
 
@@ -721,6 +759,8 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
721
759
  if (url === "/ping") return handlePing(req, res, port);
722
760
  if (url === "/stop") return handleStop(req, res, server);
723
761
  if (url === "/transcripts") return handleTranscriptsList(req, res, projDir, tHtmlPath);
762
+ // M47 D2 — most-recent in-session NDJSON for the viewer top-pane default load
763
+ if (url === "/api/main-session") return handleMainSession(req, res, projDir);
724
764
  // M44 D8 — spawn plans: GET list + SSE change stream
725
765
  if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
726
766
  if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdates(req, res, projDir);
@@ -762,6 +802,7 @@ module.exports = {
762
802
  readIndexEntry,
763
803
  isValidSpawnId,
764
804
  listInSessionTranscripts,
805
+ handleMainSession,
765
806
  handleTranscriptsList,
766
807
  handleTranscriptStream,
767
808
  handleTranscriptPage,
@@ -22,10 +22,46 @@
22
22
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
23
23
  }
24
24
  * { box-sizing: border-box; }
25
- body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 14px; line-height: 1.55; display: grid; grid-template-columns: 280px 1fr 320px; grid-template-rows: auto 1fr; min-height: 100vh; }
25
+ /* M47 D1: split-pane layout. Top pane = main in-session conversation
26
+ (zero-click default), bottom pane = user-selected spawn. Splitter
27
+ handle in the middle is keyboard- + mouse-draggable; persisted via
28
+ sessionStorage. Right-rail collapsibility is also persisted. */
29
+ :root {
30
+ --main-pane-pct: 50; /* clamped 10–90 by JS */
31
+ --right-rail-w: 320px; /* 0 when collapsed via [data-collapsed] */
32
+ }
33
+ body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 14px; line-height: 1.55; display: grid; grid-template-columns: 280px 1fr var(--right-rail-w); grid-template-rows: auto 1fr; min-height: 100vh; }
26
34
  body > header { grid-column: 1 / -1; }
27
35
  body > aside.left-rail { grid-column: 1; grid-row: 2; background: var(--bg-raised); border-right: 1px solid var(--border); padding: 12px 0; overflow-y: auto; max-height: calc(100vh - 50px); position: sticky; top: 49px; align-self: start; }
28
- body > main { grid-column: 2; grid-row: 2; }
36
+ /* M47 D1: <main> becomes a vertical flex container holding the two streams + splitter handle. */
37
+ body > main { grid-column: 2; grid-row: 2; display: flex; flex-direction: column; min-height: calc(100vh - 50px); }
38
+ /* M47 D1 — when the right rail is collapsed, the body grid track becomes 0. */
39
+ body[data-right-rail-collapsed="true"] { --right-rail-w: 0px; }
40
+ body[data-right-rail-collapsed="true"] > aside.spawn-panel { display: none; }
41
+ /* M47 D1 — split-pane: top + bottom share calc(100vh - 50px - splitter) by var %. */
42
+ main #main-stream, main #spawn-stream { overflow-y: auto; padding: 16px; padding-bottom: 80px; max-width: 960px; margin: 0 auto; width: 100%; box-sizing: border-box; }
43
+ main #main-stream { flex: 0 0 calc(var(--main-pane-pct) * 1%); border-bottom: none; }
44
+ main #spawn-stream { flex: 1 1 auto; }
45
+ main .splitter { flex: 0 0 6px; background: var(--border); cursor: row-resize; user-select: none; outline: none; transition: background 120ms ease; }
46
+ main .splitter:hover, main .splitter:focus { background: var(--accent); }
47
+ main .splitter:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); }
48
+ main .pane-empty { color: var(--fg-xdim); font-style: italic; padding: 12px; font-size: 13px; }
49
+ /* M47 D1 — left-rail 3 sections (Main / Live / Completed). */
50
+ aside.left-rail .rail-main, aside.left-rail .rail-live, aside.left-rail .rail-completed { padding: 0 0 8px 0; }
51
+ aside.left-rail .rail-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px 4px 12px; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
52
+ aside.left-rail .rail-header .chevron { background: transparent; border: 0; color: var(--fg-xdim); cursor: pointer; font-family: var(--mono); font-size: 11px; padding: 0 4px; }
53
+ aside.left-rail .rail-header .chevron:hover { color: var(--fg); }
54
+ aside.left-rail .rail-completed[data-expanded="false"] .rail-body { display: none; }
55
+ aside.left-rail .rail-completed[data-expanded="false"] .rail-header .chevron { transform: rotate(-90deg); display: inline-block; }
56
+ /* M47 D1 — status badge dots on completed entries. */
57
+ aside .node .badge-status { flex: 0 0 auto; font-family: var(--mono); font-size: 9px; padding: 1px 5px; border-radius: 8px; margin-right: 4px; border: 1px solid var(--border); color: var(--fg-xdim); background: var(--bg); }
58
+ aside .node .badge-status.success { color: var(--green); border-color: var(--green); }
59
+ aside .node .badge-status.failed { color: var(--red); border-color: var(--red); }
60
+ aside .node .badge-status.killed { color: var(--fg-xdim); border-color: var(--fg-xdim); }
61
+ aside .node .badge-status.completed { color: var(--fg-dim); border-color: var(--border); }
62
+ /* M47 D1 — right-rail collapse toggle */
63
+ .spawn-panel .panel-toggle { background: transparent; border: 1px solid var(--border); color: var(--fg-xdim); font-family: var(--mono); font-size: 11px; padding: 1px 6px; cursor: pointer; border-radius: 3px; }
64
+ .spawn-panel .panel-toggle:hover { color: var(--fg); border-color: var(--accent); }
29
65
  /* M44 D8 — right-side spawn-plan panel (additive; does not alter main stream) */
30
66
  body > aside.spawn-panel { grid-column: 3; grid-row: 2; background: var(--bg-raised); border-left: 1px solid var(--border); padding: 12px 14px; overflow-y: auto; max-height: calc(100vh - 50px); position: sticky; top: 49px; align-self: start; font-size: 12px; }
31
67
  .spawn-panel h2 { margin: 0 0 6px 0; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
@@ -121,7 +157,8 @@
121
157
  header .status.ended .dot { background: var(--fg-xdim); }
122
158
  header .status.error .dot { background: var(--red); }
123
159
 
124
- main { padding: 16px; padding-bottom: 120px; max-width: 960px; margin: 0 auto; width: 100%; }
160
+ /* M47 D1 padding moved onto the inner #main-stream / #spawn-stream containers
161
+ so the splitter sits flush between them. The outer <main> is a flex column. */
125
162
  .frame { margin: 8px 0; }
126
163
  .frame.system { color: var(--fg-xdim); font-style: italic; font-size: 12px; padding: 2px 0; }
127
164
  .frame.user { border-left: 3px solid var(--accent); padding: 6px 12px; background: var(--bg-raised); border-radius: 0 4px 4px 0; }
@@ -171,8 +208,25 @@
171
208
  <div class="status" id="hdr-status"><span class="dot"></span><span class="label">connecting…</span></div>
172
209
  </header>
173
210
  <aside class="left-rail">
174
- <h3>Spawns</h3>
175
- <div class="tree" id="tree"></div>
211
+ <!-- M47 D1 — three pre-rendered sections. Bucketing logic in Task 5
212
+ consumes D2's `status` field; until then, in-session entries land
213
+ in Main Session and everything else falls through to Live Spawns. -->
214
+ <section class="rail-main" data-rail-section="main">
215
+ <div class="rail-header"><span>★ Main Session</span></div>
216
+ <div class="rail-body" id="rail-main-body"><div class="empty">No in-session conversation captured yet.</div></div>
217
+ </section>
218
+ <section class="rail-live" data-rail-section="live">
219
+ <div class="rail-header"><span>Live Spawns</span></div>
220
+ <div class="rail-body">
221
+ <!-- Legacy mount point: existing renderTree() writes into #tree as a
222
+ no-op fallback while Task 5 bucketing rolls out. -->
223
+ <div class="tree" id="tree"></div>
224
+ </div>
225
+ </section>
226
+ <section class="rail-completed" data-rail-section="completed" data-expanded="true">
227
+ <div class="rail-header"><span>Completed</span><button class="chevron" id="rail-completed-toggle" aria-label="Toggle completed section">▾</button></div>
228
+ <div class="rail-body" id="rail-completed-body"><div class="empty">No completed spawns yet.</div></div>
229
+ </section>
176
230
  <details class="panel" id="tool-cost-panel" open>
177
231
  <summary>Tool Cost <span class="live-badge" id="tool-cost-live">off</span></summary>
178
232
  <div class="panel-body" id="tool-cost-body">
@@ -180,11 +234,28 @@
180
234
  </div>
181
235
  </details>
182
236
  </aside>
183
- <main id="stream"></main>
237
+ <main>
238
+ <!-- M47 D1 — top pane: main in-session conversation (zero-click default).
239
+ Wired by Task 4 to /api/main-session + /transcript/in-session-:id/stream. -->
240
+ <section id="main-stream" aria-label="Main session conversation">
241
+ <div class="pane-empty">No in-session conversation captured yet.</div>
242
+ </section>
243
+ <!-- Splitter: keyboard accessible (ArrowUp/ArrowDown nudge ±5%, Home/End snap). -->
244
+ <div class="splitter" id="splitter" role="separator" tabindex="0" aria-orientation="horizontal" aria-label="Resize main vs spawn pane"></div>
245
+ <!-- Bottom pane: user-selected spawn. Existing #stream id preserved so
246
+ legacy selectors keep working — it now lives inside #spawn-stream. -->
247
+ <section id="spawn-stream" aria-label="Selected spawn transcript">
248
+ <div id="stream"></div>
249
+ </section>
250
+ </main>
184
251
  <!-- M44 D8 — Spawn Plan panel: Layer 1 (project) + Layer 2 (active spawn).
185
- Additive; does not change the transcript stream rendering. -->
186
- <aside class="spawn-panel" id="spawn-plan-panel">
187
- <h2>Spawn Plan</h2>
252
+ Additive; does not change the transcript stream rendering.
253
+ M47 D1 — wrapped in <aside class="spawn-panel" data-collapsed> with a toggle. -->
254
+ <aside class="spawn-panel" id="spawn-plan-panel" data-collapsed="false">
255
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
256
+ <h2 style="margin:0;">Spawn Plan</h2>
257
+ <button class="panel-toggle" id="spawn-panel-toggle" aria-label="Collapse right rail">⟶</button>
258
+ </div>
188
259
  <section id="spawn-layer-project">
189
260
  <div class="sec-head"><span class="sec-title" id="project-title">Project</span><span class="sec-sub" id="project-sub">loading…</span></div>
190
261
  <div class="sec-totals" id="project-totals">—</div>
@@ -224,6 +295,116 @@
224
295
  const statusLabel = statusEl.querySelector('.label');
225
296
  const jumpBtn = document.getElementById('jump-btn');
226
297
 
298
+ // M47 D1 — sessionStorage keys (centralized so tests can grep them).
299
+ const SS_KEY_SELECTED = 'gsd-t.viewer.selectedSpawnId';
300
+ const SS_KEY_SPLITTER = 'gsd-t.viewer.splitterPct';
301
+ const SS_KEY_COMPLETED = 'gsd-t.viewer.completedExpanded';
302
+ const SS_KEY_RIGHT_RAIL = 'gsd-t.viewer.rightRailCollapsed';
303
+
304
+ // M47 D1 — sessionStorage helpers: wrapped so private-mode (or test
305
+ // sandboxes that don't provide a sessionStorage shim) fall through to
306
+ // safe defaults instead of throwing during IIFE init.
307
+ function _ssGet(key) { try { return (typeof sessionStorage !== 'undefined') ? sessionStorage.getItem(key) : null; } catch { return null; } }
308
+ function _ssSet(key, value) { try { if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, value); } catch { /* private mode */ } }
309
+
310
+ // M47 D1 — splitter: restore persisted pct BEFORE first paint to avoid layout shift.
311
+ (function initSplitterPct() {
312
+ const raw = _ssGet(SS_KEY_SPLITTER);
313
+ let pct = 50;
314
+ const n = parseFloat(raw);
315
+ if (isFinite(n)) pct = Math.max(10, Math.min(90, n));
316
+ if (document.documentElement && document.documentElement.style) {
317
+ document.documentElement.style.setProperty('--main-pane-pct', String(pct));
318
+ }
319
+ })();
320
+
321
+ // M47 D1 — right-rail collapse: restore persisted state.
322
+ (function initRightRailCollapsed() {
323
+ const flag = _ssGet(SS_KEY_RIGHT_RAIL) === '1';
324
+ if (document.body && document.body.setAttribute) {
325
+ document.body.setAttribute('data-right-rail-collapsed', flag ? 'true' : 'false');
326
+ }
327
+ const panel = document.getElementById('spawn-plan-panel');
328
+ if (panel && panel.setAttribute) panel.setAttribute('data-collapsed', flag ? 'true' : 'false');
329
+ })();
330
+
331
+ // M47 D1 — completed section: restore persisted state.
332
+ (function initCompletedExpanded() {
333
+ const flagRaw = _ssGet(SS_KEY_COMPLETED);
334
+ const expanded = flagRaw === null ? true : flagRaw === '1';
335
+ const sec = (document.querySelector ? document.querySelector('section.rail-completed') : null);
336
+ if (sec && sec.setAttribute) sec.setAttribute('data-expanded', expanded ? 'true' : 'false');
337
+ })();
338
+
339
+ // M47 D1 — splitter wiring (mouse + keyboard).
340
+ (function wireSplitter() {
341
+ const splitter = document.getElementById('splitter');
342
+ if (!splitter || !splitter.addEventListener) return;
343
+ function setPct(pct) {
344
+ const clamped = Math.max(10, Math.min(90, pct));
345
+ if (document.documentElement && document.documentElement.style) {
346
+ document.documentElement.style.setProperty('--main-pane-pct', String(clamped));
347
+ }
348
+ _ssSet(SS_KEY_SPLITTER, String(clamped));
349
+ }
350
+ let dragging = false;
351
+ function onMove(ev) {
352
+ if (!dragging) return;
353
+ const main = document.querySelector ? document.querySelector('body > main') : null;
354
+ if (!main || !main.getBoundingClientRect) return;
355
+ const r = main.getBoundingClientRect();
356
+ if (r.height <= 0) return;
357
+ const pct = ((ev.clientY - r.top) / r.height) * 100;
358
+ setPct(pct);
359
+ }
360
+ function onUp() { dragging = false; if (document.body && document.body.style) document.body.style.cursor = ''; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }
361
+ splitter.addEventListener('mousedown', (ev) => {
362
+ ev.preventDefault();
363
+ dragging = true;
364
+ if (document.body && document.body.style) document.body.style.cursor = 'row-resize';
365
+ window.addEventListener('mousemove', onMove);
366
+ window.addEventListener('mouseup', onUp);
367
+ });
368
+ splitter.addEventListener('keydown', (ev) => {
369
+ let cur = 50;
370
+ if (typeof getComputedStyle === 'function' && document.documentElement) {
371
+ const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--main-pane-pct'));
372
+ if (isFinite(v)) cur = v;
373
+ }
374
+ if (ev.key === 'ArrowUp') { ev.preventDefault(); setPct(cur - 5); }
375
+ else if (ev.key === 'ArrowDown') { ev.preventDefault(); setPct(cur + 5); }
376
+ else if (ev.key === 'Home') { ev.preventDefault(); setPct(20); }
377
+ else if (ev.key === 'End') { ev.preventDefault(); setPct(80); }
378
+ });
379
+ })();
380
+
381
+ // M47 D1 — right-rail collapse toggle.
382
+ (function wireRightRailToggle() {
383
+ const btn = document.getElementById('spawn-panel-toggle');
384
+ const panel = document.getElementById('spawn-plan-panel');
385
+ if (!btn || !panel || !btn.addEventListener) return;
386
+ btn.addEventListener('click', () => {
387
+ const collapsed = panel.getAttribute && panel.getAttribute('data-collapsed') === 'true';
388
+ const next = !collapsed;
389
+ if (panel.setAttribute) panel.setAttribute('data-collapsed', next ? 'true' : 'false');
390
+ if (document.body && document.body.setAttribute) document.body.setAttribute('data-right-rail-collapsed', next ? 'true' : 'false');
391
+ _ssSet(SS_KEY_RIGHT_RAIL, next ? '1' : '0');
392
+ });
393
+ })();
394
+
395
+ // M47 D1 — completed section toggle.
396
+ (function wireCompletedToggle() {
397
+ const btn = document.getElementById('rail-completed-toggle');
398
+ const sec = document.querySelector ? document.querySelector('section.rail-completed') : null;
399
+ if (!btn || !sec || !btn.addEventListener) return;
400
+ btn.addEventListener('click', () => {
401
+ const expanded = sec.getAttribute && sec.getAttribute('data-expanded') !== 'false';
402
+ const next = !expanded;
403
+ if (sec.setAttribute) sec.setAttribute('data-expanded', next ? 'true' : 'false');
404
+ _ssSet(SS_KEY_COMPLETED, next ? '1' : '0');
405
+ });
406
+ })();
407
+
227
408
  // Pair tool_result → tool_use by tool_use_id so the renderer can place
228
409
  // the result next to the call even when they come as separate frames.
229
410
  const toolUseById = new Map();
@@ -264,6 +445,13 @@
264
445
  // to "now" so manually-rendered frames stay visible too. Spotting a
265
446
  // stuck stream is then trivial — adjacent frames will show identical
266
447
  // or far-apart timestamps in the left-margin pill.
448
+ //
449
+ // M47 D1: appendFrame writes to a module-scope `renderTarget`. The
450
+ // renderFrame entry point swaps it per-call so the same code paths
451
+ // render into either the top pane (#main-stream — main-conversation
452
+ // SSE) or the bottom pane (#stream inside #spawn-stream — selected
453
+ // spawn SSE).
454
+ let renderTarget = stream;
267
455
  function appendFrame(el, arrivedAt) {
268
456
  const ts = document.createElement('span');
269
457
  ts.className = 'ts';
@@ -272,9 +460,19 @@
272
460
  // Insert as the first child so flex/inline layouts keep working.
273
461
  if (el.firstChild) el.insertBefore(ts, el.firstChild);
274
462
  else el.appendChild(ts);
275
- stream.appendChild(el);
276
- if (autoScroll) {
463
+ (renderTarget || stream).appendChild(el);
464
+ // Auto-scroll only applies to the bottom pane (where the user is
465
+ // actively focused). The top pane scrolls within itself; we let the
466
+ // user's scrollbar manage it without yanking focus.
467
+ if (renderTarget === stream && autoScroll) {
277
468
  requestAnimationFrame(() => window.scrollTo(0, document.body.scrollHeight));
469
+ } else if (renderTarget && renderTarget !== stream) {
470
+ // Auto-scroll the top pane only when its viewport is at-bottom (similar
471
+ // logic to atBottom() but pane-local).
472
+ const pane = renderTarget.parentElement || renderTarget;
473
+ if (pane && pane.scrollHeight - pane.scrollTop - pane.clientHeight < 60) {
474
+ requestAnimationFrame(() => { pane.scrollTop = pane.scrollHeight; });
475
+ }
278
476
  }
279
477
  }
280
478
 
@@ -437,7 +635,16 @@
437
635
  }
438
636
  window.__gsdtRenderCompactMarker = renderCompactMarker;
439
637
 
440
- function renderFrame(frame, arrivedAt) {
638
+ // M47 D1: optional `target` element switches render destination for the
639
+ // duration of this call (top pane = #main-stream, bottom pane = #stream).
640
+ function renderFrame(frame, arrivedAt, target) {
641
+ if (!frame || typeof frame !== 'object') return;
642
+ const prev = renderTarget;
643
+ if (target) renderTarget = target;
644
+ try { return renderFrameInner(frame, arrivedAt); }
645
+ finally { renderTarget = prev; }
646
+ }
647
+ function renderFrameInner(frame, arrivedAt) {
441
648
  if (!frame || typeof frame !== 'object') return;
442
649
  const ts = arrivedAt instanceof Date ? arrivedAt : new Date();
443
650
  const type = frame.type;
@@ -635,13 +842,130 @@
635
842
  pulseNode(latest.spawnId);
636
843
  }
637
844
 
845
+ // M47 D1 — render a single rail entry (used by bucketing).
846
+ function renderRailEntry(node, container, opts) {
847
+ const isInSession = typeof node.spawnId === 'string' && node.spawnId.indexOf('in-session-') === 0;
848
+ const currentId = (location.hash || '').slice(1) || spawnId;
849
+ const el = document.createElement('div');
850
+ el.className = 'node ' + statusClass(node);
851
+ if (isInSession) el.classList.add('in-session');
852
+ if (node.spawnId === currentId) el.classList.add('active');
853
+ el.style.paddingLeft = '12px';
854
+ const dot = document.createElement('span'); dot.className = 'dot';
855
+ const name = document.createElement('span'); name.className = 'name';
856
+ // M47 D1 — status badge (gated to completed-section entries; others
857
+ // already use the running/stopped/failed `dot` for state).
858
+ if (opts && opts.showBadge) {
859
+ const badgeKind = (function () {
860
+ const s = node.status;
861
+ if (s === 'success' || s === 'failed' || s === 'killed') return s;
862
+ return 'completed'; // neutral fallback for any other terminal value
863
+ })();
864
+ const badge = document.createElement('span');
865
+ badge.className = 'badge-status ' + badgeKind;
866
+ badge.textContent = badgeKind;
867
+ name.appendChild(badge);
868
+ }
869
+ if (isInSession) {
870
+ const tag = document.createElement('span');
871
+ tag.className = 'label-in-session';
872
+ tag.textContent = '💬 conversation';
873
+ name.appendChild(tag);
874
+ const tail = document.createElement('span');
875
+ tail.textContent = ' · ' + node.spawnId.slice(-8);
876
+ name.appendChild(tail);
877
+ } else {
878
+ const tail = document.createElement('span');
879
+ tail.textContent = (node.command || 'spawn') + ' · ' + node.spawnId.slice(-8);
880
+ name.appendChild(tail);
881
+ }
882
+ name.title = (node.description || node.spawnId) + '\n' + (node.startedAt || '');
883
+ const kill = document.createElement('button');
884
+ kill.className = 'kill';
885
+ kill.textContent = 'kill';
886
+ kill.disabled = node.status !== 'running' || !node.workerPid;
887
+ kill.addEventListener('click', (ev) => {
888
+ ev.stopPropagation();
889
+ if (!confirm('SIGTERM spawn ' + node.spawnId + ' (pid ' + node.workerPid + ')?')) return;
890
+ fetch('/transcript/' + encodeURIComponent(node.spawnId) + '/kill', { method: 'POST' })
891
+ .then((r) => r.json())
892
+ .then((j) => { kill.textContent = j.status || 'killed'; })
893
+ .catch(() => { kill.textContent = 'err'; });
894
+ });
895
+ el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
896
+ el.addEventListener('click', () => {
897
+ if (node.spawnId === currentId) return;
898
+ _ssSet(SS_KEY_SELECTED, node.spawnId);
899
+ location.hash = node.spawnId;
900
+ });
901
+ container.appendChild(el);
902
+ }
903
+ window.__gsdtRenderRailEntry = renderRailEntry;
904
+
905
+ const COMPLETED_RAIL_CAP = 100;
906
+ // M47 D1 — top-level bucketing into the 3 rail sections.
907
+ // Bucketing rules (per m47-integration-points.md):
908
+ // - in-session-* AND most-recent → Main Session (★)
909
+ // - status === 'active' → Live Spawns
910
+ // - any other (default 'completed') → Completed (capped 100)
911
+ function bucketAndRender(spawns) {
912
+ const mainBody = document.getElementById('rail-main-body');
913
+ const liveTree = document.getElementById('tree');
914
+ const completedBody = document.getElementById('rail-completed-body');
915
+ if (!mainBody || !liveTree || !completedBody) return;
916
+ const sortedByMtime = spawns.slice().sort(
917
+ (a, b) => (Date.parse(b.lastUpdatedAt || b.startedAt) || 0) - (Date.parse(a.lastUpdatedAt || a.startedAt) || 0)
918
+ );
919
+ const inSessionSorted = sortedByMtime.filter((s) => typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0);
920
+ const mainEntry = inSessionSorted[0] || null;
921
+ const others = spawns.filter((s) => s !== mainEntry);
922
+ // Live: status === 'active' OR (status absent AND not terminal — preserve
923
+ // legacy spawn-index entries that never had a status field).
924
+ const live = [];
925
+ const completed = [];
926
+ for (const s of others) {
927
+ if (s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'killed') {
928
+ completed.push(s);
929
+ } else {
930
+ live.push(s);
931
+ }
932
+ }
933
+ // Main rail body
934
+ mainBody.innerHTML = '';
935
+ if (mainEntry) {
936
+ renderRailEntry(mainEntry, mainBody, { showBadge: false });
937
+ } else {
938
+ const e = document.createElement('div'); e.className = 'empty';
939
+ e.textContent = 'No in-session conversation captured yet.';
940
+ mainBody.appendChild(e);
941
+ }
942
+ // Live rail (legacy #tree mount — preserves existing tests / selectors).
943
+ // Build a parent-indented tree from `live` only so completed-only
944
+ // entries don't duplicate.
945
+ renderTree(buildTree(live));
946
+ // Completed rail (capped 100, newest first by startedAt desc).
947
+ completedBody.innerHTML = '';
948
+ const completedSorted = completed
949
+ .slice()
950
+ .sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0))
951
+ .slice(0, COMPLETED_RAIL_CAP);
952
+ if (completedSorted.length === 0) {
953
+ const e = document.createElement('div'); e.className = 'empty';
954
+ e.textContent = 'No completed spawns yet.';
955
+ completedBody.appendChild(e);
956
+ } else {
957
+ for (const s of completedSorted) renderRailEntry(s, completedBody, { showBadge: true });
958
+ }
959
+ }
960
+ window.__gsdtBucketAndRender = bucketAndRender;
961
+
638
962
  let pollTimer = null;
639
963
  function pollSpawns() {
640
964
  fetch('/transcripts')
641
965
  .then((r) => r.json())
642
966
  .then((j) => {
643
967
  const spawns = j.spawns || [];
644
- renderTree(buildTree(spawns));
968
+ bucketAndRender(spawns);
645
969
  maybeAutoFollow(spawns);
646
970
  })
647
971
  .catch(() => { /* keep last render */ });
@@ -740,9 +1064,27 @@
740
1064
  window.__gsdtFetchToolCost = fetchToolCost;
741
1065
 
742
1066
  // ── SSE connection (reconnectable on hash change) ───────────────────
1067
+ //
1068
+ // M47 D1: dual-pane wiring.
1069
+ // - connect(id) → bottom pane (#stream inside #spawn-stream)
1070
+ // - connectMain(sid) → top pane (#main-stream)
1071
+ //
1072
+ // The two channels are independent — each owns its own EventSource. The
1073
+ // bottom pane preserves all M42/M43 behavior (auto-follow, hash
1074
+ // bookmarks, tool-cost refresh). The top pane is read-only-style: it
1075
+ // renders the in-session conversation NDJSON via /transcript/in-session-{sid}/stream.
743
1076
 
744
1077
  let src = null;
745
1078
  function connect(id) {
1079
+ if (!id) {
1080
+ // Empty bottom pane — show the click-to-focus empty state.
1081
+ if (src) { try { src.close(); } catch { /* gone */ } src = null; }
1082
+ stream.innerHTML = '<div class="pane-empty">Click any spawn in the rail to focus it here.</div>';
1083
+ toolUseById.clear();
1084
+ document.getElementById('hdr-spawn-id').textContent = '';
1085
+ setStatus('', 'idle');
1086
+ return;
1087
+ }
746
1088
  if (src) { try { src.close(); } catch { /* gone */ } src = null; }
747
1089
  stream.innerHTML = '';
748
1090
  toolUseById.clear();
@@ -750,6 +1092,8 @@
750
1092
  jumpBtn.classList.remove('visible');
751
1093
  document.getElementById('hdr-spawn-id').textContent = id;
752
1094
  setStatus('', 'connecting…');
1095
+ // M47 D1 — persist selection across reload.
1096
+ _ssSet(SS_KEY_SELECTED, id);
753
1097
  // M43 D6 — kick off an initial tool-cost fetch for this spawn.
754
1098
  fetchToolCost(id);
755
1099
  setToolCostLive(false);
@@ -764,6 +1108,7 @@
764
1108
  const arrivedAt = new Date();
765
1109
  try {
766
1110
  const frame = JSON.parse(ev.data);
1111
+ // Bottom pane = default renderTarget = #stream.
767
1112
  renderFrame(frame, arrivedAt);
768
1113
  // M43 D6 — refresh tool-cost on turn-complete frames (debounced).
769
1114
  // Various producers emit different turn-complete markers; accept
@@ -780,14 +1125,74 @@
780
1125
  };
781
1126
  }
782
1127
 
1128
+ // M47 D1 — top pane: main in-session conversation stream. Independent
1129
+ // from the bottom pane's `connect()`. Uses its own EventSource and
1130
+ // renders into #main-stream via the renderTarget swap inside renderFrame.
1131
+ let mainSrc = null;
1132
+ const mainStreamEl = document.getElementById('main-stream');
1133
+ function connectMain(sessionId) {
1134
+ if (mainSrc) { try { mainSrc.close(); } catch { /* gone */ } mainSrc = null; }
1135
+ if (!mainStreamEl) return;
1136
+ mainStreamEl.innerHTML = '';
1137
+ if (!sessionId) {
1138
+ mainStreamEl.innerHTML = '<div class="pane-empty">No in-session conversation captured yet.</div>';
1139
+ return;
1140
+ }
1141
+ const spawnId = 'in-session-' + sessionId;
1142
+ try {
1143
+ mainSrc = new EventSource('/transcript/' + encodeURIComponent(spawnId) + '/stream');
1144
+ mainSrc.onmessage = (ev) => {
1145
+ if (!ev.data) return;
1146
+ const arrivedAt = new Date();
1147
+ try {
1148
+ const frame = JSON.parse(ev.data);
1149
+ renderFrame(frame, arrivedAt, mainStreamEl);
1150
+ } catch {
1151
+ const prev = renderTarget; renderTarget = mainStreamEl;
1152
+ try { renderRaw(ev.data, arrivedAt); } finally { renderTarget = prev; }
1153
+ }
1154
+ };
1155
+ // Errors are silent — the top pane is best-effort; bottom pane drives status.
1156
+ } catch { /* EventSource unsupported */ }
1157
+ }
1158
+ window.__gsdtConnectMain = connectMain;
1159
+
1160
+ function fetchMainSession() {
1161
+ const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
1162
+ return fetch('/api/main-session')
1163
+ .then((r) => r.ok ? r.json() : null)
1164
+ .then((j) => {
1165
+ if (j && j.sessionId) {
1166
+ connectMain(j.sessionId);
1167
+ const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
1168
+ try { console.debug('[m47] top-pane connected in', Math.round(t1 - t0), 'ms'); } catch { /* ok */ }
1169
+ } else {
1170
+ connectMain(null);
1171
+ }
1172
+ })
1173
+ .catch(() => { connectMain(null); });
1174
+ }
1175
+
783
1176
  window.addEventListener('hashchange', () => {
784
1177
  const id = (location.hash || '').slice(1);
785
1178
  if (id) { connect(id); pollSpawns(); }
786
1179
  });
787
1180
 
788
- const initialId = (location.hash || '').slice(1) || spawnId;
789
- if (!location.hash && spawnId) location.hash = spawnId;
790
- connect(initialId);
1181
+ // M47 D1 initial bottom-pane resolution:
1182
+ // 1. data-spawn-id non-empty connect that (bookmark flow)
1183
+ // 2. else sessionStorage.selectedSpawnId → connect that
1184
+ // 3. else show empty state
1185
+ let initialBottomId = '';
1186
+ if (spawnId) {
1187
+ initialBottomId = spawnId;
1188
+ } else {
1189
+ initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
1190
+ }
1191
+ if (initialBottomId && !location.hash) location.hash = initialBottomId;
1192
+ connect(initialBottomId);
1193
+
1194
+ // M47 D1 — top pane: always populate via /api/main-session.
1195
+ fetchMainSession();
791
1196
 
792
1197
  pollSpawns();
793
1198
  pollTimer = setInterval(pollSpawns, 3000);