@tekyzinc/gsd-t 3.20.13 → 3.21.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [3.21.11] - 2026-05-06
6
+
7
+ ### Fixed — viewer: 4 rendering regressions surfaced post-M47
8
+
9
+ 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.
10
+
11
+ **Changes:**
12
+ - `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).
13
+ - `scripts/gsd-t-transcript.html`:
14
+ - `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.
15
+ - 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.
16
+ - 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).
17
+ - `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.
18
+ - `test/m44-transcript-timestamp.test.js`: updated for the `renderAt` / `arrivedAt` rename — semantics preserved (`arrivedAt` is now the fallback layer beneath parsed `frame.ts`).
19
+
20
+ **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.
21
+
22
+ **Suite:** 2083/2083 pass — both pre-existing M47-baseline flakes resolved on the release run.
23
+
24
+ **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.
25
+
5
26
  ## [3.20.13] - 2026-05-05
6
27
 
7
28
  ### Fixed — visualizer: surface in-session NDJSONs when `.index.json` is empty
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.11",
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
 
@@ -235,7 +273,14 @@ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
235
273
  // viewer's initialId logic falls through to location.hash (also empty)
236
274
  // and connect('') is a no-op beyond a 404 SSE attempt — harmless, since
237
275
  // the left rail polls /api/spawns-index independently.
238
- const html = data.toString("utf8").replace(/__SPAWN_ID__/g, "");
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);
239
284
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
240
285
  res.end(html);
241
286
  });
@@ -245,18 +290,36 @@ function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
245
290
  res.end(JSON.stringify({ spawns: sorted }));
246
291
  }
247
292
 
248
- function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath) {
293
+ function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath, projectDir) {
249
294
  if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
250
295
  fs.readFile(transcriptHtmlPath, (err, data) => {
251
296
  if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
252
297
  // Inject the spawn-id as a data attribute on <body> by string replacement;
253
298
  // the HTML ships with a placeholder `data-spawn-id="__SPAWN_ID__"`.
254
- const html = data.toString("utf8").replace(/__SPAWN_ID__/g, spawnId);
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);
255
306
  res.writeHead(200, { "Content-Type": "text/html" });
256
307
  res.end(html);
257
308
  });
258
309
  }
259
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, "&amp;")
317
+ .replace(/</g, "&lt;")
318
+ .replace(/>/g, "&gt;")
319
+ .replace(/"/g, "&quot;")
320
+ .replace(/'/g, "&#39;");
321
+ }
322
+
260
323
  function tailTranscriptFile(filePath, callback) {
261
324
  let offset = 0;
262
325
  let buf = "";
@@ -721,6 +784,8 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
721
784
  if (url === "/ping") return handlePing(req, res, port);
722
785
  if (url === "/stop") return handleStop(req, res, server);
723
786
  if (url === "/transcripts") return handleTranscriptsList(req, res, projDir, tHtmlPath);
787
+ // M47 D2 — most-recent in-session NDJSON for the viewer top-pane default load
788
+ if (url === "/api/main-session") return handleMainSession(req, res, projDir);
724
789
  // M44 D8 — spawn plans: GET list + SSE change stream
725
790
  if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
726
791
  if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdates(req, res, projDir);
@@ -743,7 +808,7 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
743
808
  if (streamMatch) return handleTranscriptStream(req, res, decodeURIComponent(streamMatch[1]), projDir);
744
809
  // /transcript/:spawnId — HTML viewer page
745
810
  const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
746
- if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath);
811
+ if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath, projDir);
747
812
  res.writeHead(404); res.end("Not found");
748
813
  });
749
814
  server.listen(port);
@@ -762,6 +827,7 @@ module.exports = {
762
827
  readIndexEntry,
763
828
  isValidSpawnId,
764
829
  listInSessionTranscripts,
830
+ handleMainSession,
765
831
  handleTranscriptsList,
766
832
  handleTranscriptStream,
767
833
  handleTranscriptPage,
@@ -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>GSD-T Transcript</title>
6
+ <title>__PROJECT_NAME__</title>
7
7
  <style>
8
8
  :root {
9
9
  --bg: #0d1117;
@@ -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; }
@@ -146,6 +183,17 @@
146
183
  .frame.boundary.done .label { color: var(--green); }
147
184
  .frame.boundary.failed .label { color: var(--red); }
148
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; }
149
197
 
150
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; }
151
199
  .jump-to-live.visible { display: block; }
@@ -165,14 +213,31 @@
165
213
  </head>
166
214
  <body data-spawn-id="__SPAWN_ID__">
167
215
  <header>
168
- <div class="title">GSD-T Transcript</div>
216
+ <div class="title">__PROJECT_NAME__</div>
169
217
  <div class="spawn-id" id="hdr-spawn-id"></div>
170
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>
171
219
  <div class="status" id="hdr-status"><span class="dot"></span><span class="label">connecting…</span></div>
172
220
  </header>
173
221
  <aside class="left-rail">
174
- <h3>Spawns</h3>
175
- <div class="tree" id="tree"></div>
222
+ <!-- M47 D1 — three pre-rendered sections. Bucketing logic in Task 5
223
+ consumes D2's `status` field; until then, in-session entries land
224
+ in Main Session and everything else falls through to Live Spawns. -->
225
+ <section class="rail-main" data-rail-section="main">
226
+ <div class="rail-header"><span>★ Main Session</span></div>
227
+ <div class="rail-body" id="rail-main-body"><div class="empty">No in-session conversation captured yet.</div></div>
228
+ </section>
229
+ <section class="rail-live" data-rail-section="live">
230
+ <div class="rail-header"><span>Live Spawns</span></div>
231
+ <div class="rail-body">
232
+ <!-- Legacy mount point: existing renderTree() writes into #tree as a
233
+ no-op fallback while Task 5 bucketing rolls out. -->
234
+ <div class="tree" id="tree"></div>
235
+ </div>
236
+ </section>
237
+ <section class="rail-completed" data-rail-section="completed" data-expanded="true">
238
+ <div class="rail-header"><span>Completed</span><button class="chevron" id="rail-completed-toggle" aria-label="Toggle completed section">▾</button></div>
239
+ <div class="rail-body" id="rail-completed-body"><div class="empty">No completed spawns yet.</div></div>
240
+ </section>
176
241
  <details class="panel" id="tool-cost-panel" open>
177
242
  <summary>Tool Cost <span class="live-badge" id="tool-cost-live">off</span></summary>
178
243
  <div class="panel-body" id="tool-cost-body">
@@ -180,11 +245,28 @@
180
245
  </div>
181
246
  </details>
182
247
  </aside>
183
- <main id="stream"></main>
248
+ <main>
249
+ <!-- M47 D1 — top pane: main in-session conversation (zero-click default).
250
+ Wired by Task 4 to /api/main-session + /transcript/in-session-:id/stream. -->
251
+ <section id="main-stream" aria-label="Main session conversation">
252
+ <div class="pane-empty">No in-session conversation captured yet.</div>
253
+ </section>
254
+ <!-- Splitter: keyboard accessible (ArrowUp/ArrowDown nudge ±5%, Home/End snap). -->
255
+ <div class="splitter" id="splitter" role="separator" tabindex="0" aria-orientation="horizontal" aria-label="Resize main vs spawn pane"></div>
256
+ <!-- Bottom pane: user-selected spawn. Existing #stream id preserved so
257
+ legacy selectors keep working — it now lives inside #spawn-stream. -->
258
+ <section id="spawn-stream" aria-label="Selected spawn transcript">
259
+ <div id="stream"></div>
260
+ </section>
261
+ </main>
184
262
  <!-- 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>
263
+ Additive; does not change the transcript stream rendering.
264
+ M47 D1 — wrapped in <aside class="spawn-panel" data-collapsed> with a toggle. -->
265
+ <aside class="spawn-panel" id="spawn-plan-panel" data-collapsed="false">
266
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
267
+ <h2 style="margin:0;">Spawn Plan</h2>
268
+ <button class="panel-toggle" id="spawn-panel-toggle" aria-label="Collapse right rail">⟶</button>
269
+ </div>
188
270
  <section id="spawn-layer-project">
189
271
  <div class="sec-head"><span class="sec-title" id="project-title">Project</span><span class="sec-sub" id="project-sub">loading…</span></div>
190
272
  <div class="sec-totals" id="project-totals">—</div>
@@ -224,6 +306,116 @@
224
306
  const statusLabel = statusEl.querySelector('.label');
225
307
  const jumpBtn = document.getElementById('jump-btn');
226
308
 
309
+ // M47 D1 — sessionStorage keys (centralized so tests can grep them).
310
+ const SS_KEY_SELECTED = 'gsd-t.viewer.selectedSpawnId';
311
+ const SS_KEY_SPLITTER = 'gsd-t.viewer.splitterPct';
312
+ const SS_KEY_COMPLETED = 'gsd-t.viewer.completedExpanded';
313
+ const SS_KEY_RIGHT_RAIL = 'gsd-t.viewer.rightRailCollapsed';
314
+
315
+ // M47 D1 — sessionStorage helpers: wrapped so private-mode (or test
316
+ // sandboxes that don't provide a sessionStorage shim) fall through to
317
+ // safe defaults instead of throwing during IIFE init.
318
+ function _ssGet(key) { try { return (typeof sessionStorage !== 'undefined') ? sessionStorage.getItem(key) : null; } catch { return null; } }
319
+ function _ssSet(key, value) { try { if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, value); } catch { /* private mode */ } }
320
+
321
+ // M47 D1 — splitter: restore persisted pct BEFORE first paint to avoid layout shift.
322
+ (function initSplitterPct() {
323
+ const raw = _ssGet(SS_KEY_SPLITTER);
324
+ let pct = 50;
325
+ const n = parseFloat(raw);
326
+ if (isFinite(n)) pct = Math.max(10, Math.min(90, n));
327
+ if (document.documentElement && document.documentElement.style) {
328
+ document.documentElement.style.setProperty('--main-pane-pct', String(pct));
329
+ }
330
+ })();
331
+
332
+ // M47 D1 — right-rail collapse: restore persisted state.
333
+ (function initRightRailCollapsed() {
334
+ const flag = _ssGet(SS_KEY_RIGHT_RAIL) === '1';
335
+ if (document.body && document.body.setAttribute) {
336
+ document.body.setAttribute('data-right-rail-collapsed', flag ? 'true' : 'false');
337
+ }
338
+ const panel = document.getElementById('spawn-plan-panel');
339
+ if (panel && panel.setAttribute) panel.setAttribute('data-collapsed', flag ? 'true' : 'false');
340
+ })();
341
+
342
+ // M47 D1 — completed section: restore persisted state.
343
+ (function initCompletedExpanded() {
344
+ const flagRaw = _ssGet(SS_KEY_COMPLETED);
345
+ const expanded = flagRaw === null ? true : flagRaw === '1';
346
+ const sec = (document.querySelector ? document.querySelector('section.rail-completed') : null);
347
+ if (sec && sec.setAttribute) sec.setAttribute('data-expanded', expanded ? 'true' : 'false');
348
+ })();
349
+
350
+ // M47 D1 — splitter wiring (mouse + keyboard).
351
+ (function wireSplitter() {
352
+ const splitter = document.getElementById('splitter');
353
+ if (!splitter || !splitter.addEventListener) return;
354
+ function setPct(pct) {
355
+ const clamped = Math.max(10, Math.min(90, pct));
356
+ if (document.documentElement && document.documentElement.style) {
357
+ document.documentElement.style.setProperty('--main-pane-pct', String(clamped));
358
+ }
359
+ _ssSet(SS_KEY_SPLITTER, String(clamped));
360
+ }
361
+ let dragging = false;
362
+ function onMove(ev) {
363
+ if (!dragging) return;
364
+ const main = document.querySelector ? document.querySelector('body > main') : null;
365
+ if (!main || !main.getBoundingClientRect) return;
366
+ const r = main.getBoundingClientRect();
367
+ if (r.height <= 0) return;
368
+ const pct = ((ev.clientY - r.top) / r.height) * 100;
369
+ setPct(pct);
370
+ }
371
+ function onUp() { dragging = false; if (document.body && document.body.style) document.body.style.cursor = ''; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }
372
+ splitter.addEventListener('mousedown', (ev) => {
373
+ ev.preventDefault();
374
+ dragging = true;
375
+ if (document.body && document.body.style) document.body.style.cursor = 'row-resize';
376
+ window.addEventListener('mousemove', onMove);
377
+ window.addEventListener('mouseup', onUp);
378
+ });
379
+ splitter.addEventListener('keydown', (ev) => {
380
+ let cur = 50;
381
+ if (typeof getComputedStyle === 'function' && document.documentElement) {
382
+ const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--main-pane-pct'));
383
+ if (isFinite(v)) cur = v;
384
+ }
385
+ if (ev.key === 'ArrowUp') { ev.preventDefault(); setPct(cur - 5); }
386
+ else if (ev.key === 'ArrowDown') { ev.preventDefault(); setPct(cur + 5); }
387
+ else if (ev.key === 'Home') { ev.preventDefault(); setPct(20); }
388
+ else if (ev.key === 'End') { ev.preventDefault(); setPct(80); }
389
+ });
390
+ })();
391
+
392
+ // M47 D1 — right-rail collapse toggle.
393
+ (function wireRightRailToggle() {
394
+ const btn = document.getElementById('spawn-panel-toggle');
395
+ const panel = document.getElementById('spawn-plan-panel');
396
+ if (!btn || !panel || !btn.addEventListener) return;
397
+ btn.addEventListener('click', () => {
398
+ const collapsed = panel.getAttribute && panel.getAttribute('data-collapsed') === 'true';
399
+ const next = !collapsed;
400
+ if (panel.setAttribute) panel.setAttribute('data-collapsed', next ? 'true' : 'false');
401
+ if (document.body && document.body.setAttribute) document.body.setAttribute('data-right-rail-collapsed', next ? 'true' : 'false');
402
+ _ssSet(SS_KEY_RIGHT_RAIL, next ? '1' : '0');
403
+ });
404
+ })();
405
+
406
+ // M47 D1 — completed section toggle.
407
+ (function wireCompletedToggle() {
408
+ const btn = document.getElementById('rail-completed-toggle');
409
+ const sec = document.querySelector ? document.querySelector('section.rail-completed') : null;
410
+ if (!btn || !sec || !btn.addEventListener) return;
411
+ btn.addEventListener('click', () => {
412
+ const expanded = sec.getAttribute && sec.getAttribute('data-expanded') !== 'false';
413
+ const next = !expanded;
414
+ if (sec.setAttribute) sec.setAttribute('data-expanded', next ? 'true' : 'false');
415
+ _ssSet(SS_KEY_COMPLETED, next ? '1' : '0');
416
+ });
417
+ })();
418
+
227
419
  // Pair tool_result → tool_use by tool_use_id so the renderer can place
228
420
  // the result next to the call even when they come as separate frames.
229
421
  const toolUseById = new Map();
@@ -259,11 +451,27 @@
259
451
  }
260
452
  window.__gsdtFmtTs = fmtTs;
261
453
 
262
- // Per-frame arrival timestamp. The renderer captures Date.now() once
263
- // per SSE message and threads it through; everything else falls back
264
- // to "now" so manually-rendered frames stay visible too. Spotting a
265
- // stuck stream is then trivial adjacent frames will show identical
266
- // or far-apart timestamps in the left-margin pill.
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.
460
+ //
461
+ // M47 D1: appendFrame writes to a module-scope `renderTarget`. The
462
+ // renderFrame entry point swaps it per-call so the same code paths
463
+ // render into either the top pane (#main-stream — main-conversation
464
+ // SSE) or the bottom pane (#stream inside #spawn-stream — selected
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;
474
+ let renderTarget = stream;
267
475
  function appendFrame(el, arrivedAt) {
268
476
  const ts = document.createElement('span');
269
477
  ts.className = 'ts';
@@ -272,9 +480,19 @@
272
480
  // Insert as the first child so flex/inline layouts keep working.
273
481
  if (el.firstChild) el.insertBefore(ts, el.firstChild);
274
482
  else el.appendChild(ts);
275
- stream.appendChild(el);
276
- if (autoScroll) {
483
+ (renderTarget || stream).appendChild(el);
484
+ // Auto-scroll only applies to the bottom pane (where the user is
485
+ // actively focused). The top pane scrolls within itself; we let the
486
+ // user's scrollbar manage it without yanking focus.
487
+ if (renderTarget === stream && autoScroll) {
277
488
  requestAnimationFrame(() => window.scrollTo(0, document.body.scrollHeight));
489
+ } else if (renderTarget && renderTarget !== stream) {
490
+ // Auto-scroll the top pane only when its viewport is at-bottom (similar
491
+ // logic to atBottom() but pane-local).
492
+ const pane = renderTarget.parentElement || renderTarget;
493
+ if (pane && pane.scrollHeight - pane.scrollTop - pane.clientHeight < 60) {
494
+ requestAnimationFrame(() => { pane.scrollTop = pane.scrollHeight; });
495
+ }
278
496
  }
279
497
  }
280
498
 
@@ -406,6 +624,74 @@
406
624
  appendFrame(div, arrivedAt);
407
625
  }
408
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
+
409
695
  function renderCompactMarker(frame, arrivedAt) {
410
696
  const div = document.createElement('div');
411
697
  div.className = 'frame compact-marker';
@@ -437,14 +723,34 @@
437
723
  }
438
724
  window.__gsdtRenderCompactMarker = renderCompactMarker;
439
725
 
440
- function renderFrame(frame, arrivedAt) {
726
+ // M47 D1: optional `target` element switches render destination for the
727
+ // duration of this call (top pane = #main-stream, bottom pane = #stream).
728
+ function renderFrame(frame, arrivedAt, target) {
441
729
  if (!frame || typeof frame !== 'object') return;
442
- const ts = arrivedAt instanceof Date ? arrivedAt : new Date();
730
+ const prev = renderTarget;
731
+ if (target) renderTarget = target;
732
+ try { return renderFrameInner(frame, arrivedAt); }
733
+ finally { renderTarget = prev; }
734
+ }
735
+ function renderFrameInner(frame, arrivedAt) {
736
+ if (!frame || typeof frame !== 'object') return;
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());
443
744
  const type = frame.type;
444
745
  if (type === 'compact_marker') { renderCompactMarker(frame, ts); return; }
445
746
  if (type === 'system') { renderSystem(frame, ts); return; }
446
747
  if (type === 'task-boundary') { renderBoundary(frame, ts); return; }
447
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; }
448
754
  if (type === 'assistant' && frame.message && Array.isArray(frame.message.content)) {
449
755
  for (const b of frame.message.content) {
450
756
  if (b.type === 'text') renderAssistantText(b.text || '', ts);
@@ -564,6 +870,14 @@
564
870
  });
565
871
  el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
566
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;
567
881
  if (node.spawnId === currentId) return;
568
882
  location.hash = node.spawnId;
569
883
  });
@@ -615,9 +929,14 @@
615
929
  function maybeAutoFollow(spawns) {
616
930
  if (!autoFollowEl.checked) return;
617
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.
618
936
  // Most recent running spawn by startedAt (descending).
619
937
  const running = spawns
620
938
  .filter((s) => s.status === 'running')
939
+ .filter((s) => !(typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0))
621
940
  .sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
622
941
  if (!running.length) return;
623
942
  const latest = running[0];
@@ -635,13 +954,136 @@
635
954
  pulseNode(latest.spawnId);
636
955
  }
637
956
 
957
+ // M47 D1 — render a single rail entry (used by bucketing).
958
+ function renderRailEntry(node, container, opts) {
959
+ const isInSession = typeof node.spawnId === 'string' && node.spawnId.indexOf('in-session-') === 0;
960
+ const currentId = (location.hash || '').slice(1) || spawnId;
961
+ const el = document.createElement('div');
962
+ el.className = 'node ' + statusClass(node);
963
+ if (isInSession) el.classList.add('in-session');
964
+ if (node.spawnId === currentId) el.classList.add('active');
965
+ el.style.paddingLeft = '12px';
966
+ const dot = document.createElement('span'); dot.className = 'dot';
967
+ const name = document.createElement('span'); name.className = 'name';
968
+ // M47 D1 — status badge (gated to completed-section entries; others
969
+ // already use the running/stopped/failed `dot` for state).
970
+ if (opts && opts.showBadge) {
971
+ const badgeKind = (function () {
972
+ const s = node.status;
973
+ if (s === 'success' || s === 'failed' || s === 'killed') return s;
974
+ return 'completed'; // neutral fallback for any other terminal value
975
+ })();
976
+ const badge = document.createElement('span');
977
+ badge.className = 'badge-status ' + badgeKind;
978
+ badge.textContent = badgeKind;
979
+ name.appendChild(badge);
980
+ }
981
+ if (isInSession) {
982
+ const tag = document.createElement('span');
983
+ tag.className = 'label-in-session';
984
+ tag.textContent = '💬 conversation';
985
+ name.appendChild(tag);
986
+ const tail = document.createElement('span');
987
+ tail.textContent = ' · ' + node.spawnId.slice(-8);
988
+ name.appendChild(tail);
989
+ } else {
990
+ const tail = document.createElement('span');
991
+ tail.textContent = (node.command || 'spawn') + ' · ' + node.spawnId.slice(-8);
992
+ name.appendChild(tail);
993
+ }
994
+ name.title = (node.description || node.spawnId) + '\n' + (node.startedAt || '');
995
+ const kill = document.createElement('button');
996
+ kill.className = 'kill';
997
+ kill.textContent = 'kill';
998
+ kill.disabled = node.status !== 'running' || !node.workerPid;
999
+ kill.addEventListener('click', (ev) => {
1000
+ ev.stopPropagation();
1001
+ if (!confirm('SIGTERM spawn ' + node.spawnId + ' (pid ' + node.workerPid + ')?')) return;
1002
+ fetch('/transcript/' + encodeURIComponent(node.spawnId) + '/kill', { method: 'POST' })
1003
+ .then((r) => r.json())
1004
+ .then((j) => { kill.textContent = j.status || 'killed'; })
1005
+ .catch(() => { kill.textContent = 'err'; });
1006
+ });
1007
+ el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
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;
1015
+ if (node.spawnId === currentId) return;
1016
+ _ssSet(SS_KEY_SELECTED, node.spawnId);
1017
+ location.hash = node.spawnId;
1018
+ });
1019
+ container.appendChild(el);
1020
+ }
1021
+ window.__gsdtRenderRailEntry = renderRailEntry;
1022
+
1023
+ const COMPLETED_RAIL_CAP = 100;
1024
+ // M47 D1 — top-level bucketing into the 3 rail sections.
1025
+ // Bucketing rules (per m47-integration-points.md):
1026
+ // - in-session-* AND most-recent → Main Session (★)
1027
+ // - status === 'active' → Live Spawns
1028
+ // - any other (default 'completed') → Completed (capped 100)
1029
+ function bucketAndRender(spawns) {
1030
+ const mainBody = document.getElementById('rail-main-body');
1031
+ const liveTree = document.getElementById('tree');
1032
+ const completedBody = document.getElementById('rail-completed-body');
1033
+ if (!mainBody || !liveTree || !completedBody) return;
1034
+ const sortedByMtime = spawns.slice().sort(
1035
+ (a, b) => (Date.parse(b.lastUpdatedAt || b.startedAt) || 0) - (Date.parse(a.lastUpdatedAt || a.startedAt) || 0)
1036
+ );
1037
+ const inSessionSorted = sortedByMtime.filter((s) => typeof s.spawnId === 'string' && s.spawnId.indexOf('in-session-') === 0);
1038
+ const mainEntry = inSessionSorted[0] || null;
1039
+ const others = spawns.filter((s) => s !== mainEntry);
1040
+ // Live: status === 'active' OR (status absent AND not terminal — preserve
1041
+ // legacy spawn-index entries that never had a status field).
1042
+ const live = [];
1043
+ const completed = [];
1044
+ for (const s of others) {
1045
+ if (s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'killed') {
1046
+ completed.push(s);
1047
+ } else {
1048
+ live.push(s);
1049
+ }
1050
+ }
1051
+ // Main rail body
1052
+ mainBody.innerHTML = '';
1053
+ if (mainEntry) {
1054
+ renderRailEntry(mainEntry, mainBody, { showBadge: false });
1055
+ } else {
1056
+ const e = document.createElement('div'); e.className = 'empty';
1057
+ e.textContent = 'No in-session conversation captured yet.';
1058
+ mainBody.appendChild(e);
1059
+ }
1060
+ // Live rail (legacy #tree mount — preserves existing tests / selectors).
1061
+ // Build a parent-indented tree from `live` only so completed-only
1062
+ // entries don't duplicate.
1063
+ renderTree(buildTree(live));
1064
+ // Completed rail (capped 100, newest first by startedAt desc).
1065
+ completedBody.innerHTML = '';
1066
+ const completedSorted = completed
1067
+ .slice()
1068
+ .sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0))
1069
+ .slice(0, COMPLETED_RAIL_CAP);
1070
+ if (completedSorted.length === 0) {
1071
+ const e = document.createElement('div'); e.className = 'empty';
1072
+ e.textContent = 'No completed spawns yet.';
1073
+ completedBody.appendChild(e);
1074
+ } else {
1075
+ for (const s of completedSorted) renderRailEntry(s, completedBody, { showBadge: true });
1076
+ }
1077
+ }
1078
+ window.__gsdtBucketAndRender = bucketAndRender;
1079
+
638
1080
  let pollTimer = null;
639
1081
  function pollSpawns() {
640
1082
  fetch('/transcripts')
641
1083
  .then((r) => r.json())
642
1084
  .then((j) => {
643
1085
  const spawns = j.spawns || [];
644
- renderTree(buildTree(spawns));
1086
+ bucketAndRender(spawns);
645
1087
  maybeAutoFollow(spawns);
646
1088
  })
647
1089
  .catch(() => { /* keep last render */ });
@@ -740,9 +1182,27 @@
740
1182
  window.__gsdtFetchToolCost = fetchToolCost;
741
1183
 
742
1184
  // ── SSE connection (reconnectable on hash change) ───────────────────
1185
+ //
1186
+ // M47 D1: dual-pane wiring.
1187
+ // - connect(id) → bottom pane (#stream inside #spawn-stream)
1188
+ // - connectMain(sid) → top pane (#main-stream)
1189
+ //
1190
+ // The two channels are independent — each owns its own EventSource. The
1191
+ // bottom pane preserves all M42/M43 behavior (auto-follow, hash
1192
+ // bookmarks, tool-cost refresh). The top pane is read-only-style: it
1193
+ // renders the in-session conversation NDJSON via /transcript/in-session-{sid}/stream.
743
1194
 
744
1195
  let src = null;
745
1196
  function connect(id) {
1197
+ if (!id) {
1198
+ // Empty bottom pane — show the click-to-focus empty state.
1199
+ if (src) { try { src.close(); } catch { /* gone */ } src = null; }
1200
+ stream.innerHTML = '<div class="pane-empty">Click any spawn in the rail to focus it here.</div>';
1201
+ toolUseById.clear();
1202
+ document.getElementById('hdr-spawn-id').textContent = '';
1203
+ setStatus('', 'idle');
1204
+ return;
1205
+ }
746
1206
  if (src) { try { src.close(); } catch { /* gone */ } src = null; }
747
1207
  stream.innerHTML = '';
748
1208
  toolUseById.clear();
@@ -750,6 +1210,8 @@
750
1210
  jumpBtn.classList.remove('visible');
751
1211
  document.getElementById('hdr-spawn-id').textContent = id;
752
1212
  setStatus('', 'connecting…');
1213
+ // M47 D1 — persist selection across reload.
1214
+ _ssSet(SS_KEY_SELECTED, id);
753
1215
  // M43 D6 — kick off an initial tool-cost fetch for this spawn.
754
1216
  fetchToolCost(id);
755
1217
  setToolCostLive(false);
@@ -758,13 +1220,15 @@
758
1220
  src.onerror = () => { setStatus('error', 'disconnected'); setToolCostLive(false); };
759
1221
  src.onmessage = (ev) => {
760
1222
  if (!ev.data) return;
761
- // Capture arrival time once per SSE message every render call for
762
- // this frame stamps the same wall-clock value, so the user can spot
763
- // a stuck stream by adjacent identical/far-apart timestamps.
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.
764
1226
  const arrivedAt = new Date();
765
1227
  try {
766
1228
  const frame = JSON.parse(ev.data);
767
- renderFrame(frame, arrivedAt);
1229
+ const renderAt = frameTs(frame, arrivedAt);
1230
+ // Bottom pane = default renderTarget = #stream.
1231
+ renderFrame(frame, renderAt);
768
1232
  // M43 D6 — refresh tool-cost on turn-complete frames (debounced).
769
1233
  // Various producers emit different turn-complete markers; accept
770
1234
  // any of them.
@@ -780,14 +1244,84 @@
780
1244
  };
781
1245
  }
782
1246
 
1247
+ // M47 D1 — top pane: main in-session conversation stream. Independent
1248
+ // from the bottom pane's `connect()`. Uses its own EventSource and
1249
+ // renders into #main-stream via the renderTarget swap inside renderFrame.
1250
+ let mainSrc = null;
1251
+ const mainStreamEl = document.getElementById('main-stream');
1252
+ function connectMain(sessionId) {
1253
+ if (mainSrc) { try { mainSrc.close(); } catch { /* gone */ } mainSrc = null; }
1254
+ if (!mainStreamEl) return;
1255
+ mainStreamEl.innerHTML = '';
1256
+ if (!sessionId) {
1257
+ mainStreamEl.innerHTML = '<div class="pane-empty">No in-session conversation captured yet.</div>';
1258
+ return;
1259
+ }
1260
+ const spawnId = 'in-session-' + sessionId;
1261
+ try {
1262
+ mainSrc = new EventSource('/transcript/' + encodeURIComponent(spawnId) + '/stream');
1263
+ mainSrc.onmessage = (ev) => {
1264
+ if (!ev.data) return;
1265
+ const arrivedAt = new Date();
1266
+ try {
1267
+ const frame = JSON.parse(ev.data);
1268
+ const renderAt = frameTs(frame, arrivedAt);
1269
+ renderFrame(frame, renderAt, mainStreamEl);
1270
+ } catch {
1271
+ const prev = renderTarget; renderTarget = mainStreamEl;
1272
+ try { renderRaw(ev.data, arrivedAt); } finally { renderTarget = prev; }
1273
+ }
1274
+ };
1275
+ // Errors are silent — the top pane is best-effort; bottom pane drives status.
1276
+ } catch { /* EventSource unsupported */ }
1277
+ }
1278
+ window.__gsdtConnectMain = connectMain;
1279
+
1280
+ function fetchMainSession() {
1281
+ const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
1282
+ return fetch('/api/main-session')
1283
+ .then((r) => r.ok ? r.json() : null)
1284
+ .then((j) => {
1285
+ if (j && j.sessionId) {
1286
+ connectMain(j.sessionId);
1287
+ const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
1288
+ try { console.debug('[m47] top-pane connected in', Math.round(t1 - t0), 'ms'); } catch { /* ok */ }
1289
+ } else {
1290
+ connectMain(null);
1291
+ }
1292
+ })
1293
+ .catch(() => { connectMain(null); });
1294
+ }
1295
+
783
1296
  window.addEventListener('hashchange', () => {
784
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; }
785
1300
  if (id) { connect(id); pollSpawns(); }
786
1301
  });
787
1302
 
788
- const initialId = (location.hash || '').slice(1) || spawnId;
789
- if (!location.hash && spawnId) location.hash = spawnId;
790
- connect(initialId);
1303
+ // M47 D1 initial bottom-pane resolution:
1304
+ // 1. data-spawn-id non-empty connect that (bookmark flow)
1305
+ // 2. else sessionStorage.selectedSpawnId → connect that
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.
1310
+ let initialBottomId = '';
1311
+ if (spawnId) {
1312
+ initialBottomId = spawnId;
1313
+ } else {
1314
+ initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
1315
+ }
1316
+ if (typeof initialBottomId === 'string' && initialBottomId.indexOf('in-session-') === 0) {
1317
+ initialBottomId = '';
1318
+ _ssSet(SS_KEY_SELECTED, '');
1319
+ }
1320
+ if (initialBottomId && !location.hash) location.hash = initialBottomId;
1321
+ connect(initialBottomId);
1322
+
1323
+ // M47 D1 — top pane: always populate via /api/main-session.
1324
+ fetchMainSession();
791
1325
 
792
1326
  pollSpawns();
793
1327
  pollTimer = setInterval(pollSpawns, 3000);